3855 lines
170 KiB
Python
3855 lines
170 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import inspect
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
from app.api import space_api
|
||
from app.api.rag_ask_contract import sanitize_customer_rag_payload
|
||
from app.schemas.pmflow_ai_agent_runtime import (
|
||
PMFlowAIAgentActionCandidateLedgerEntry,
|
||
PMFlowAIAgentAnswerIntentPlan,
|
||
PMFlowAIAgentBoundary,
|
||
PMFlowAIAgentClaim,
|
||
PMFlowAIAgentCriticResult,
|
||
PMFlowAIAgentEvidenceNormalization,
|
||
PMFlowAIAgentFactLedgerEntry,
|
||
PMFlowAIAgentGapLedgerEntry,
|
||
PMFlowAIAgentGoalFrame,
|
||
PMFlowAIAgentObservation,
|
||
PMFlowAIAgentStepProposal,
|
||
PMFlowAIAgentTerminalAnswerState,
|
||
PMFlowAIAgentWorkspace,
|
||
)
|
||
from app.schemas.pmflow_ai_operating_runtime import (
|
||
PMFlowAIAgentWorkProduct,
|
||
PMFlowAIAutonomyDecision,
|
||
PMFlowAIEvidenceLedgerEntry,
|
||
PMFlowAIOperatingMission,
|
||
PMFlowAIOperatingPlan,
|
||
PMFlowAIProjectWorldFacet,
|
||
PMFlowAIProjectWorldStatePacket,
|
||
)
|
||
from app.schemas.rag import RAGAskRequest
|
||
from app.services.pmflow_agent_runtime import (
|
||
PMFlowAgentEnvironmentService,
|
||
PMFlowAgentRuntimeService,
|
||
PMFlowAgentSpecialistRuntimeService,
|
||
)
|
||
from app.services.pmflow_agent_runtime.service import _prepared_actions_from_agent_payload
|
||
from app.services.pmflow_ai_agent_runtime import PMFlowAIAgentAnswerRuntime, PMFlowAIAgentRunService
|
||
from app.services.pmflow_ai_agent_runtime.agent_answer_runtime import (
|
||
build_agent_terminal_answer_state,
|
||
critique_agent_customer_answer,
|
||
render_agent_terminal_state_fallback,
|
||
)
|
||
from app.services.pmflow_ai_agent_runtime.agent_run_service import _recovery_proposal_from_critic
|
||
from app.services.pmflow_ai_agent_runtime.agent_evidence_normalizer import (
|
||
PMFlowAIAgentEvidenceNormalizer,
|
||
apply_evidence_normalization,
|
||
fallback_normalize_observation,
|
||
)
|
||
from app.services.pmflow_ai_agent_runtime.agent_goal_frame_runtime import (
|
||
_ensure_compound_phrase_interpretation_branches,
|
||
_goal_frame_safe_conversation_context,
|
||
)
|
||
from app.services.pmflow_ai_agent_runtime.agent_state_critic import PMFlowAIAgentStateCritic, _enforce_critic_contract
|
||
from app.services.pmflow_ai_agent_runtime.agent_step_planner import PMFlowAIAgentStepPlanner
|
||
from app.services.pmflow_ai_agent_runtime.agent_workspace import (
|
||
agent_workspace_from_context_capsules,
|
||
upsert_agent_workspace_capsule,
|
||
workspace_from_conversation_context,
|
||
)
|
||
from app.services.pmflow_ai_agent_runtime.agent_tool_registry import (
|
||
AGENT_TOOL_CONTRACTS,
|
||
PMFlowAIAgentToolRegistry,
|
||
_AgentRuntimeExternalPlanner,
|
||
)
|
||
from app.services.pmflow_ai_operating_runtime import PMFlowAIOperatingRunService
|
||
from app.services.pmflow_ai_operating_runtime.agent_registry import PMFlowAIOperatingAgentRegistry
|
||
from app.services.pmflow_ai_operating_runtime.run_service import (
|
||
_claim_texts,
|
||
_compose_research_operating_answer,
|
||
_external_source_rows,
|
||
)
|
||
from app.services.prompt_runtime_service import PromptRuntimeRenderResult
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_runtime_step1_builds_run_workspace_and_process_packet() -> None:
|
||
events = []
|
||
|
||
async def sink(event: dict) -> None:
|
||
events.append(event)
|
||
|
||
payload = await PMFlowAIAgentRunService(
|
||
object(),
|
||
tenant_id=12,
|
||
planner=_FakePlanner("inspect_space_collections"),
|
||
tool_registry=_FakeToolRegistry(),
|
||
critic=_FakeCritic(ready=True),
|
||
answer_runtime=PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()),
|
||
).run_turn(
|
||
request=RAGAskRequest(question="帮我分析下用AI来协助采购监督的系统怎么样"),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
started_at=None,
|
||
event_sink=sink,
|
||
)
|
||
|
||
assert payload["processing_stage"] == "agent_runtime"
|
||
assert payload["answer_mode"] == "agent_runtime"
|
||
assert payload["agent_run_packet"]["packet_version"] == "pmflow_ai_agent_run.v1"
|
||
assert payload["agent_workspace"]["workspace_version"] == "pmflow_ai_agent_workspace.v1"
|
||
assert payload["agent_workspace"]["objective_ledger"]
|
||
assert payload["agent_workspace"]["fact_ledger"]
|
||
assert payload["agent_workspace"]["goal_frame"]["packet_version"] == "pmflow_ai_agent_goal_frame.v1"
|
||
assert payload["agent_workspace"]["claim_ledger"]
|
||
assert payload["agent_workspace"]["evidence_normalizations"]
|
||
assert payload["agent_workspace"]["evidence_normalizations"][0]["packet_version"] == "pmflow_ai_agent_evidence_normalization.v1"
|
||
assert payload["agent_workspace"]["attempt_ledger"]
|
||
assert payload["agent_workspace"]["action_ledger"]
|
||
assert payload["agent_workspace"]["deliverable_state"]["status"]
|
||
assert payload["deliverable_packet"]["packet_version"] == "pmflow_ai_agent_deliverable_packet.v1"
|
||
assert payload["deliverable_packet"]["deliverable_kind"] == "status_brief"
|
||
assert payload["agent_workspace"]["deliverable_state"]["deliverable_kind"] == payload["deliverable_packet"]["deliverable_kind"]
|
||
assert payload["answer_canvas"]["deliverable_kind"] == payload["deliverable_packet"]["deliverable_kind"]
|
||
assert all(isinstance(item, dict) and item.get("title") for item in payload["answer_canvas"]["next_actions"])
|
||
assert payload["customer_evidence_packet"]["answer_type"] == payload["deliverable_packet"]["deliverable_kind"]
|
||
assert payload["answer_process_packet"]["route_display"] == "agent_runtime"
|
||
assert [step["key"] for step in payload["answer_process_packet"]["steps"]] == [
|
||
"agent_understand_goal",
|
||
"agent_workspace_snapshot",
|
||
"agent_step_planner",
|
||
"agent_tool_inspect_space_collections",
|
||
"agent_evidence_normalizer",
|
||
"agent_state_critic",
|
||
"agent_answer_runtime",
|
||
]
|
||
assert payload["answer_process_packet"]["steps"][-1]["status"] == "completed"
|
||
assert "我是 PMFlow AI" not in payload["answer"]
|
||
assert "项目操作副驾" not in payload["answer"]
|
||
assert "说明能力边界" not in payload["answer"]
|
||
assert [event["type"] for event in events].count("step.started") >= 5
|
||
assert [event["type"] for event in events].count("step.completed") >= 7
|
||
assert events[0]["step"]["key"] == "agent_understand_goal"
|
||
assert any(event["step"]["key"] == "agent_tool_inspect_space_collections" for event in events if event["type"] == "step.completed")
|
||
assert not any("prompt" in str(event).lower() or "trace" in str(event).lower() for event in events)
|
||
|
||
|
||
def test_space_api_default_entry_uses_agent_runtime_not_legacy_turn_runtime() -> None:
|
||
source = inspect.getsource(space_api._run_pmflow_agent_turn_payload)
|
||
|
||
assert "PMFlowAgentRuntimeService" in source
|
||
assert "run_space_turn" in source
|
||
assert "PMFlowAIOperatingRunService" not in source
|
||
assert "PMFlowAIAgentRunService" not in source
|
||
assert "PMFlowAITurnRuntimeService" not in source
|
||
|
||
|
||
def test_agent_runtime_tool_environment_has_no_scenario_specific_opportunity_tool() -> None:
|
||
assert "run_opportunity_discovery_agents" not in AGENT_TOOL_CONTRACTS
|
||
assert "discover_project_opportunities" not in str(AGENT_TOOL_CONTRACTS)
|
||
assert "run_market_opportunity_review" not in str(AGENT_TOOL_CONTRACTS)
|
||
assert "inspect_pending_confirmations" not in AGENT_TOOL_CONTRACTS
|
||
assert "inspect_pending_confirmations" not in str(AGENT_TOOL_CONTRACTS)
|
||
assert "inspect_attention_queue" in AGENT_TOOL_CONTRACTS
|
||
assert "inspect_project_execution_package" in AGENT_TOOL_CONTRACTS
|
||
|
||
|
||
def test_pmflow_ai_thread_message_projection_hides_raw_run_internals() -> None:
|
||
payload = {
|
||
"answer": "这是用户应该看到的回答。",
|
||
"agent_os_run_id": "run_demo",
|
||
"answer_process_packet": {
|
||
"steps": [
|
||
{
|
||
"key": "workspace_binding",
|
||
"label": "绑定工作区",
|
||
"status": "completed",
|
||
"display_summary": "已读取当前空间。",
|
||
"duration_ms": 18,
|
||
},
|
||
{
|
||
"key": "final_answer",
|
||
"label": "step_plan_abc123",
|
||
"status": "passed",
|
||
"display_summary": "target_ref final_answer passed",
|
||
},
|
||
]
|
||
},
|
||
"agent_os_artifacts": [
|
||
{"artifact_id": "art_1", "kind": "final_answer", "title": "PMFlow AI 回答", "summary": "这是用户应该看到的回答。"},
|
||
{"artifact_id": "art_2", "kind": "research_note", "title": "推进建议", "summary": "先收口最高优先级动作。"},
|
||
],
|
||
"agent_os_verifications": [{"verification_id": "verification_1", "target_ref": "step_plan_abc123", "result": "passed"}],
|
||
}
|
||
|
||
message_payload = space_api._pmflow_ai_assistant_message_payload(payload)
|
||
|
||
assert message_payload["work_log"][0]["label"] == "绑定工作区"
|
||
assert message_payload["work_log"][0]["status_label"] == "已完成"
|
||
assert message_payload["work_log"][1]["label"] == "推进这轮问题的第 2 步"
|
||
assert message_payload["work_log"][1]["summary"] == "把可确认的信息、边界和下一步整理成用户能直接看的回复。"
|
||
assert message_payload["artifacts"] == [
|
||
{"id": "art_2", "title": "推进建议", "summary": "先收口最高优先级动作。", "status_label": "已完成"}
|
||
]
|
||
assert "verifications" not in message_payload
|
||
assert "step_plan_abc123" not in str(message_payload)
|
||
assert "passed" not in str(message_payload)
|
||
|
||
|
||
def test_pmflow_ai_thread_message_projection_returns_visible_and_total_work_log_counts() -> None:
|
||
payload = {
|
||
"answer": "已经整理完成。",
|
||
"answer_process_packet": {
|
||
"steps": [
|
||
{
|
||
"key": f"step_{index}",
|
||
"label": f"读取材料 {index}",
|
||
"status": "completed",
|
||
"display_summary": f"已完成第 {index} 步。",
|
||
}
|
||
for index in range(1, 20)
|
||
]
|
||
},
|
||
}
|
||
|
||
message_payload = space_api._pmflow_ai_assistant_message_payload(payload)
|
||
|
||
assert len(message_payload["work_log"]) == 12
|
||
assert len(message_payload["work_log_full"]) == 19
|
||
assert message_payload["work_log_meta"]["visible_step_count"] == 12
|
||
assert message_payload["work_log_meta"]["total_step_count"] == 19
|
||
assert message_payload["work_log_meta"]["truncated"] is True
|
||
|
||
|
||
def test_pmflow_ai_message_view_backfills_full_work_log_from_run_steps() -> None:
|
||
row = SimpleNamespace(
|
||
public_message_id="msg_1",
|
||
role="assistant",
|
||
content="已经完成本轮处理。",
|
||
run_public_id="run_1",
|
||
qa_record_id=None,
|
||
sent_at=None,
|
||
message_payload={
|
||
"work_log": [
|
||
{
|
||
"id": f"stored_{index}",
|
||
"label": f"已存步骤 {index}",
|
||
"summary": "历史投影里只有前 12 条。",
|
||
"status": "done",
|
||
}
|
||
for index in range(1, 13)
|
||
],
|
||
},
|
||
)
|
||
run_steps = [
|
||
{
|
||
"id": f"step_{index}",
|
||
"label": f"真实运行步骤 {index}",
|
||
"summary": f"已执行第 {index} 步。",
|
||
"status": "completed",
|
||
}
|
||
for index in range(1, 20)
|
||
]
|
||
|
||
view = space_api._pmflow_ai_message_view(row, run_steps=run_steps)
|
||
|
||
assert view["work_log_meta"]["visible_step_count"] == 12
|
||
assert view["work_log_meta"]["total_step_count"] == 19
|
||
assert view["work_log_meta"]["truncated"] is True
|
||
assert len(view["work_log"]) == 12
|
||
assert len(view["work_log_full"]) == 19
|
||
|
||
|
||
def test_pmflow_ai_thread_message_projection_hides_deliverable_artifacts_and_unsafe_actions() -> None:
|
||
payload = {
|
||
"answer": "你当前最该处理的是确认变更 2,进入变更审查页后确认或退回。",
|
||
"answer_canvas": {
|
||
"direct_answer": "你当前最该处理的是确认变更 2,进入变更审查页后确认或退回。",
|
||
"action_candidates": [
|
||
{
|
||
"title": "确认变更 2",
|
||
"summary": "打开变更审查页处理这条待确认项。",
|
||
"href": "/space/change-review?projectId=141",
|
||
}
|
||
],
|
||
},
|
||
"agent_os_artifacts": [
|
||
{
|
||
"artifact_id": "artifact_bad",
|
||
"kind": "agent_deliverable",
|
||
"title": "明确儿童项目下一步",
|
||
"summary": "GoalFrame 明确限定 project:141 和 approval:21。",
|
||
},
|
||
{
|
||
"artifact_id": "artifact_report",
|
||
"kind": "customer_report",
|
||
"title": "审查说明",
|
||
"summary": "当前最高优先级动作需要你本人确认。",
|
||
},
|
||
],
|
||
"agent_os_prepared_action_packets": [
|
||
{
|
||
"action_id": "action_good",
|
||
"source": "deliverable_action_candidate",
|
||
"title": "确认变更 2",
|
||
"summary": "打开变更审查页处理这条待确认项。",
|
||
"href": "/space/change-review?projectId=141",
|
||
},
|
||
{
|
||
"action_id": "action_no_href",
|
||
"source": "deliverable_action_candidate",
|
||
"title": "修改后再审 3",
|
||
"summary": "没有可跳转目标的动作不应展示成按钮。",
|
||
},
|
||
{
|
||
"action_id": "action_internal",
|
||
"source": "deliverable_action_candidate",
|
||
"title": "确认变更 2;对象:重析;依据:GoalFrame top_next_actions[0]",
|
||
"href": "/space/change-review?projectId=141",
|
||
},
|
||
],
|
||
}
|
||
|
||
message_payload = space_api._pmflow_ai_assistant_message_payload(payload)
|
||
|
||
assert message_payload["artifacts"] == [
|
||
{"id": "artifact_report", "title": "审查说明", "summary": "当前最高优先级动作需要你本人确认。", "status_label": "已完成"}
|
||
]
|
||
assert message_payload["actions"] == [
|
||
{
|
||
"id": "action_good",
|
||
"label": "确认变更 2",
|
||
"summary": "打开变更审查页处理这条待确认项。",
|
||
"href": "/space/change-review?projectId=141",
|
||
}
|
||
]
|
||
assert "GoalFrame" not in str(message_payload)
|
||
assert "approval:21" not in str(message_payload)
|
||
|
||
|
||
def test_pmflow_ai_thread_message_projection_preserves_operating_answer_and_confirmation_action_type() -> None:
|
||
payload = {
|
||
"answer": "这轮先继续补公开研究来源,确认前不会写项目记忆或发起执行。",
|
||
"reply_text": "这轮先继续补公开研究来源,确认前不会写项目记忆或发起执行。",
|
||
"processing_stage": "multi_agent_operating_runtime",
|
||
"answer_canvas": {
|
||
"direct_answer": "这轮结果里混入了内部运行信息,我先不展示那段内容。",
|
||
"action_candidates": [],
|
||
},
|
||
"agent_os_confirmation_requests": [
|
||
{
|
||
"confirmation_id": "creq_test",
|
||
"title": "确认准备动作:继续补研究",
|
||
"summary": "确认后只继续做只读研究补证。",
|
||
"action_label": "继续补研究",
|
||
"action_type": "continue_research",
|
||
"can_execute_after_confirmation": True,
|
||
"action_payload": {"research_recovery": {"queries": ["2026 market report"]}},
|
||
}
|
||
],
|
||
}
|
||
|
||
message_payload = space_api._pmflow_ai_assistant_message_payload(payload)
|
||
|
||
assert message_payload["answer_canvas"]["direct_answer"] == payload["answer"]
|
||
assert message_payload["answer"] == payload["answer"]
|
||
assert message_payload["processing_stage"] == "multi_agent_operating_runtime"
|
||
assert message_payload["confirmations"][0]["action_type"] == "continue_research"
|
||
assert message_payload["confirmations"][0]["can_execute"] is True
|
||
|
||
|
||
def test_agent_runtime_message_projection_uses_single_customer_truth_for_blocked_research() -> None:
|
||
raw_answer = (
|
||
"这轮还不能负责任地下市场结论。真实进展是:已整理 7 个候选来源、0 条可用判断;"
|
||
"主要缺口:搜索结果只作为候选来源,未读正文前不能形成 ResearchNote claim;"
|
||
"claim_ledger 仍为空,gap_ledger 中仍存在 blocking gaps。"
|
||
)
|
||
payload = {
|
||
"answer": raw_answer,
|
||
"reply_text": raw_answer,
|
||
"processing_stage": "agent_runtime",
|
||
"answer_canvas": {
|
||
"direct_answer": "这轮结果里混入了内部运行信息,我先不展示那段内容。",
|
||
"action_candidates": [],
|
||
},
|
||
"deliverable_packet": {
|
||
"deliverable_kind": "blocked_state",
|
||
"status": "blocked",
|
||
"answer_text": raw_answer,
|
||
"summary": raw_answer,
|
||
},
|
||
"customer_evidence_packet": {
|
||
"answer_type": "blocked_state",
|
||
"fallback_summary": raw_answer,
|
||
},
|
||
}
|
||
|
||
sanitized = sanitize_customer_rag_payload(payload, apply_presentation_contract=False)
|
||
message_payload = space_api._pmflow_ai_assistant_message_payload(sanitized)
|
||
|
||
assert sanitized["answer"]
|
||
assert message_payload["answer"] == sanitized["answer"]
|
||
assert message_payload["reply_text"] == sanitized["answer"]
|
||
assert message_payload["answer_canvas"]["direct_answer"] == sanitized["answer"]
|
||
assert "混入了内部运行信息" not in message_payload["answer_canvas"]["direct_answer"]
|
||
assert "ResearchNote claim" not in str(message_payload)
|
||
assert "claim_ledger" not in str(message_payload)
|
||
assert "gap_ledger" not in str(message_payload)
|
||
|
||
|
||
def test_research_answer_projection_blocks_when_research_note_claims_are_not_gated() -> None:
|
||
answer = _compose_research_operating_answer(
|
||
mission=PMFlowAIOperatingMission(original_user_input="最近AI会议市场怎么样"),
|
||
project_world_state=PMFlowAIProjectWorldStatePacket(
|
||
primary_project_ref="space:external_research",
|
||
status="partial",
|
||
remaining_gaps=["external_public_sources_need_agent_reading"],
|
||
),
|
||
research_product=PMFlowAIAgentWorkProduct(
|
||
product_type="ResearchNote",
|
||
structured_output={
|
||
"evidence_state": {"source_count": 5, "usable_claim_count": 2, "ready_for_opportunity_brief": True},
|
||
"market_claims": [
|
||
"Forbes Councils Membership Learn more about our services and contact us.",
|
||
"公开来源候选目前只能作为候选线索,尚未抽取能直接支撑业务判断的具体事实。",
|
||
],
|
||
"research_gaps": ["external_public_sources_need_agent_reading"],
|
||
},
|
||
),
|
||
operating_plan=PMFlowAIOperatingPlan(highest_leverage_move="继续补三类研究来源"),
|
||
autonomy_gate=PMFlowAIAutonomyDecision(
|
||
confirmation_requests=[
|
||
{
|
||
"action_type": "continue_research",
|
||
"action_label": "继续补研究",
|
||
}
|
||
]
|
||
),
|
||
operating_cycle=SimpleNamespace(consumed_learning_ledger=SimpleNamespace(learning_items=[{"summary": "上一轮来源不可用"}])),
|
||
)
|
||
|
||
assert answer.startswith("这轮还不能负责任地下市场结论。")
|
||
assert "初步市场判断" not in answer
|
||
|
||
|
||
def test_research_answer_projection_uses_gated_partial_claims_before_opportunity_threshold() -> None:
|
||
answer = _compose_research_operating_answer(
|
||
mission=PMFlowAIOperatingMission(original_user_input="最近智能会议市场怎么样"),
|
||
project_world_state=PMFlowAIProjectWorldStatePacket(
|
||
primary_project_ref="space:external_research",
|
||
status="partial",
|
||
remaining_gaps=["还缺少第二个可核验公开来源"],
|
||
),
|
||
research_product=PMFlowAIAgentWorkProduct(
|
||
product_type="ResearchNote",
|
||
structured_output={
|
||
"evidence_state": {"source_count": 1, "usable_claim_count": 1, "ready_for_opportunity_brief": False},
|
||
"market_claims": [
|
||
"2025年智能会议系统市场规模达到186亿元,具备AI能力的产品占比超过45%。",
|
||
],
|
||
"research_gaps": ["还缺少第二个可核验公开来源"],
|
||
},
|
||
),
|
||
operating_plan=PMFlowAIOperatingPlan(highest_leverage_move="继续补研究来源"),
|
||
autonomy_gate=PMFlowAIAutonomyDecision(confirmation_requests=[]),
|
||
operating_cycle=SimpleNamespace(consumed_learning_ledger=SimpleNamespace(learning_items=[])),
|
||
)
|
||
|
||
assert answer.startswith("这轮能先给一个有边界的初判:")
|
||
assert "186亿元" in answer
|
||
assert "还不能升级成机会简报或执行动作" in answer
|
||
assert "Forbes Councils" not in answer
|
||
assert "公开来源候选" not in answer
|
||
assert "external_public_sources_need_agent_reading" not in answer
|
||
assert "Project Memory" not in answer
|
||
assert "handoff" not in answer
|
||
assert space_api._customer_safe_display_text(answer, fallback="fallback", limit=4000) == answer
|
||
|
||
|
||
def test_agent_payload_prepared_actions_only_use_complete_action_candidates() -> None:
|
||
payload = {
|
||
"answer_canvas": {
|
||
"action_candidates": [
|
||
{
|
||
"title": "确认变更 2",
|
||
"summary": "打开变更审查页处理这条待确认项。",
|
||
"href": "/space/change-review?projectId=141",
|
||
"target_ref": "approval:21",
|
||
},
|
||
{
|
||
"title": "确认变更 2;对象:重析;依据:GoalFrame top_next_actions[0]",
|
||
"href": "/space/change-review?projectId=141",
|
||
},
|
||
{
|
||
"title": "修改后再审 3",
|
||
"summary": "缺少 href 的候选动作不能投影成前端按钮。",
|
||
"target_ref": "approval:20",
|
||
},
|
||
],
|
||
"next_actions": [
|
||
{
|
||
"title": "GoalFrame 明确限定仅绑定 project:141 和 approval:21,其他待办项虽存在但不满足目标约束条件。",
|
||
}
|
||
],
|
||
}
|
||
}
|
||
|
||
actions = _prepared_actions_from_agent_payload(payload)
|
||
|
||
assert actions == [
|
||
{
|
||
"action_id": "agent_action_1",
|
||
"title": "确认变更 2",
|
||
"summary": "打开变更审查页处理这条待确认项。",
|
||
"href": "/space/change-review?projectId=141",
|
||
"target_ref": "approval:21",
|
||
"status": "suggested",
|
||
"confirmation_required": False,
|
||
"source": "deliverable_action_candidate",
|
||
}
|
||
]
|
||
|
||
|
||
def test_agent_answer_quality_rejects_internal_goalframe_and_repairs_from_clean_actions() -> None:
|
||
terminal_state = PMFlowAIAgentTerminalAnswerState(
|
||
user_goal="儿童那个项目下一步我要做什么呢",
|
||
terminal_status="answerable",
|
||
claims=[
|
||
PMFlowAIAgentClaim(
|
||
claim_id="claim_internal",
|
||
claim_type="project_next_action",
|
||
claim_text="GoalFrame 明确限定仅绑定 project:141 和 approval:21,必须满足 top_next_actions[0]。",
|
||
allowed_to_state=True,
|
||
)
|
||
],
|
||
action_candidates=[
|
||
PMFlowAIAgentActionCandidateLedgerEntry(
|
||
title="确认变更 2",
|
||
target_title="童析—学龄前儿童行为特质AI评估助手",
|
||
reason="这是当前工作台里优先级最高的待确认项。",
|
||
href="/space/change-review?projectId=141",
|
||
requires_confirmation=True,
|
||
)
|
||
],
|
||
answer_intent_plan=PMFlowAIAgentAnswerIntentPlan(lead_claim_id="claim_internal"),
|
||
)
|
||
|
||
bad_answer = "GoalFrame 明确限定 project:141 和 approval:21,且不满足 top_next_actions[0] 唯一性约束。"
|
||
|
||
assert "internal_runtime_terms" in critique_agent_customer_answer(terminal_state=terminal_state, answer=bad_answer)
|
||
|
||
repaired = render_agent_terminal_state_fallback(terminal_state)
|
||
|
||
assert "确认变更 2" in repaired
|
||
assert "童析" in repaired
|
||
assert "GoalFrame" not in repaired
|
||
assert "top_next_actions" not in repaired
|
||
assert "approval:21" not in repaired
|
||
assert "project:141" not in repaired
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pmflow_agent_runtime_step1_persists_run_and_keeps_customer_answer() -> None:
|
||
db = _FakeDB()
|
||
payload = await PMFlowAgentRuntimeService(
|
||
db,
|
||
tenant_id=12,
|
||
delivery_runtime=_FakeDeliveryRuntime(),
|
||
).run_space_turn(
|
||
request=RAGAskRequest(question="你是谁"),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
)
|
||
|
||
assert payload["processing_stage"] == "pmflow_agent_runtime"
|
||
assert payload["answer_mode"] == "pmflow_agent_runtime"
|
||
assert payload["pmflow_agent_run"]["packet_version"] == "pmflow_agent_run.v1"
|
||
assert payload["pmflow_agent_run"]["run_id"] == payload["agent_os_run_id"]
|
||
assert payload["agent_os_environment"]["packet_version"] == "pmflow_agent_environment.v1"
|
||
assert payload["agent_os_steps"][0]["kind"] == "agent_answer_runtime"
|
||
assert not any(step.get("tool_name") == "read_capability_contract" for step in payload["agent_os_steps"])
|
||
assert not any(step.get("tool_name") == "read_space_state" for step in payload["agent_os_steps"])
|
||
assert payload["agent_os_steps"]
|
||
assert payload["agent_os_observations"]
|
||
assert not any(observation.get("source_kind") == "inspect_project_operating_state" for observation in payload["agent_os_observations"])
|
||
assert payload["agent_os_artifacts"]
|
||
assert payload["agent_os_verifications"]
|
||
assert payload["answer"] == "项目当前需要先收口核心验证,再准备可确认的下一步。"
|
||
assert "project_object_required_for_world_state" not in payload["answer"]
|
||
assert any(item.__class__.__name__ == "PMFlowAgentRun" for item in db.items)
|
||
assert any(item.__class__.__name__ == "PMFlowAgentStep" for item in db.items)
|
||
assert any(item.__class__.__name__ == "PMFlowAgentObservation" for item in db.items)
|
||
assert any(item.__class__.__name__ == "PMFlowAgentArtifact" for item in db.items)
|
||
assert any(item.__class__.__name__ == "PMFlowAgentVerification" for item in db.items)
|
||
sanitized = sanitize_customer_rag_payload(payload, apply_presentation_contract=False)
|
||
assert sanitized["processing_stage"] == "pmflow_agent_runtime"
|
||
assert sanitized["pmflow_agent_run"]["packet_version"] == "pmflow_agent_run.v1"
|
||
assert sanitized["agent_os_environment"]["packet_version"] == "pmflow_agent_environment.v1"
|
||
assert "answer_runtime_packet" not in sanitized
|
||
assert "agent_workspace" not in sanitized
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pmflow_agent_environment_step2_reads_project_world_state_only_with_structured_anchor() -> None:
|
||
visible_only_registry = _RecordingAgentOSToolRegistry()
|
||
visible_only = await PMFlowAgentEnvironmentService(
|
||
db=object(),
|
||
tenant_id=12,
|
||
tool_registry=visible_only_registry,
|
||
).observe_initial(
|
||
request=RAGAskRequest(question="现在有哪些项目"),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
)
|
||
|
||
assert [item["tool_name"] for item in visible_only_registry.calls] == ["inspect_space_collections"]
|
||
assert not any(step.tool_name == "inspect_project_operating_state" for step in visible_only.steps)
|
||
assert any(gap.get("type") == "object_anchor_gap" for observation in visible_only.observations for gap in observation.gaps)
|
||
|
||
anchored_registry = _RecordingAgentOSToolRegistry()
|
||
anchored = await PMFlowAgentEnvironmentService(
|
||
db=object(),
|
||
tenant_id=12,
|
||
tool_registry=anchored_registry,
|
||
).observe_initial(
|
||
request=RAGAskRequest(question="这个项目怎么推进", project_id=141),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
)
|
||
|
||
assert [item["tool_name"] for item in anchored_registry.calls] == [
|
||
"inspect_space_collections",
|
||
"inspect_project_operating_state",
|
||
]
|
||
assert anchored_registry.calls[-1]["tool_input"] == {"project_ref": "project:141"}
|
||
assert any(step.tool_name == "inspect_project_operating_state" for step in anchored.steps)
|
||
assert any(
|
||
fact.get("type") == "project_world_state"
|
||
for observation in anchored.observations
|
||
for fact in observation.facts
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pmflow_agent_runtime_step3_uses_dynamic_agent_runtime_without_specialist_override() -> None:
|
||
db = _FakeDB()
|
||
payload = await PMFlowAgentRuntimeService(
|
||
db,
|
||
tenant_id=12,
|
||
delivery_runtime=_FakeDeliveryRuntime(),
|
||
environment_service=PMFlowAgentEnvironmentService(
|
||
db=object(),
|
||
tenant_id=12,
|
||
tool_registry=_RecordingAgentOSToolRegistry(),
|
||
),
|
||
specialist_runtime=PMFlowAgentSpecialistRuntimeService(
|
||
db=object(),
|
||
tenant_id=12,
|
||
project_world_state_service=_FakeProjectWorldStateService(),
|
||
),
|
||
).run_space_turn(
|
||
request=RAGAskRequest(question="这个项目怎么推进好呢", project_id=141),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
)
|
||
|
||
assert payload["processing_stage"] == "pmflow_agent_runtime"
|
||
assert payload["policy_version"] == "pmflow-agent-os-v2"
|
||
assert payload["agent_os_specialists"] == {}
|
||
assert payload["agent_os_specialist_assignments"] == []
|
||
assert payload["agent_os_work_products"] == []
|
||
assert payload["pmflow_agent_run"]["work_products"] == payload["agent_os_work_products"]
|
||
assert payload["agent_os_operating_cycle"] == {}
|
||
assert payload["agent_os_learning_ledger"] == {}
|
||
assert payload["agent_os_next_cycle_triggers"] == []
|
||
assert any(item["kind"] == "agent_deliverable" for item in payload["agent_os_artifacts"])
|
||
assert any(item["target_ref"] == "final_answer" for item in payload["agent_os_verifications"])
|
||
assert payload["answer"] == "项目当前需要先收口核心验证,再准备可确认的下一步。"
|
||
assert "project_object_required_for_world_state" not in payload["answer"]
|
||
assert [step["key"] for step in payload["answer_process_packet"]["steps"][:1]] == ["agent_answer_runtime"]
|
||
sanitized = sanitize_customer_rag_payload(payload, apply_presentation_contract=False)
|
||
assert sanitized["agent_os_specialists"] == {}
|
||
assert sanitized["agent_os_autonomy_gate"] == {}
|
||
assert "answer_runtime_packet" not in sanitized
|
||
assert "agent_workspace" not in sanitized
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pmflow_agent_runtime_step4_prepares_actions_but_blocks_writes_and_handoffs() -> None:
|
||
payload = await PMFlowAgentRuntimeService(
|
||
_FakeDB(),
|
||
tenant_id=12,
|
||
delivery_runtime=_FakeDeliveryRuntime(),
|
||
environment_service=PMFlowAgentEnvironmentService(
|
||
db=object(),
|
||
tenant_id=12,
|
||
tool_registry=_RecordingAgentOSToolRegistry(),
|
||
),
|
||
specialist_runtime=PMFlowAgentSpecialistRuntimeService(
|
||
db=object(),
|
||
tenant_id=12,
|
||
project_world_state_service=_FakeProjectWorldStateService(),
|
||
),
|
||
).run_space_turn(
|
||
request=RAGAskRequest(question="这个项目怎么推进好呢", project_id=141),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
)
|
||
|
||
assert payload["agent_os_prepared_action_packets"]
|
||
assert payload["agent_os_confirmation_requests"] == []
|
||
assert payload["agent_os_execution_preparations"] == []
|
||
assert payload["prepared_action_packets"] == payload["agent_os_prepared_action_packets"]
|
||
assert all(item["confirmation_required"] is False for item in payload["agent_os_prepared_action_packets"])
|
||
assert all(item["status"] == "suggested" for item in payload["agent_os_prepared_action_packets"])
|
||
assert payload["agent_os_autonomy_gate"] == {}
|
||
assert "项目当前需要先收口核心验证" in payload["answer"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pmflow_agent_runtime_step5_consumes_updates_and_persists_learning_ledger() -> None:
|
||
previous_ledger = {
|
||
"packet_version": "pmflow_ai_learning_ledger.v1",
|
||
"ledger_id": "lgr_previous",
|
||
"scope_ref": "project:141",
|
||
"learning_items": [
|
||
{
|
||
"learning_key": "single_priority_order",
|
||
"summary": "上一轮确认:用户希望只看一个优先级顺序。",
|
||
"outcome_type": "user_preference",
|
||
"outcome_status": "observed",
|
||
}
|
||
],
|
||
"decision_outcomes": [],
|
||
"experiment_results": [],
|
||
"execution_outcomes": [],
|
||
"user_preferences": [],
|
||
"consumed_memory_refs": [],
|
||
}
|
||
payload = await PMFlowAgentRuntimeService(
|
||
_FakeDB(),
|
||
tenant_id=12,
|
||
delivery_runtime=_FakeDeliveryRuntime(),
|
||
environment_service=PMFlowAgentEnvironmentService(
|
||
db=object(),
|
||
tenant_id=12,
|
||
tool_registry=_RecordingAgentOSToolRegistry(),
|
||
),
|
||
specialist_runtime=PMFlowAgentSpecialistRuntimeService(
|
||
db=object(),
|
||
tenant_id=12,
|
||
project_world_state_service=_FakeProjectWorldStateService(),
|
||
),
|
||
).run_space_turn(
|
||
request=RAGAskRequest(question="继续推进这个项目", project_id=141),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={
|
||
"context_capsules": [
|
||
{
|
||
"capsule_type": "operating_learning_ledger",
|
||
"scope_ref": "project:141",
|
||
"learning_ledger": previous_ledger,
|
||
}
|
||
],
|
||
"operating_outcomes": [
|
||
{
|
||
"outcome_type": "recommendation_decision",
|
||
"outcome_status": "accepted",
|
||
"source_ref": "creq_previous",
|
||
"target_ref": "project:141",
|
||
"summary": "用户接受了上一轮 MVP 收口建议。",
|
||
"learning_signals": {"decision_state": "accepted"},
|
||
}
|
||
],
|
||
},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
)
|
||
|
||
assert payload["policy_version"] == "pmflow-agent-os-v2"
|
||
assert payload["agent_os_consumed_learning_ledger"] == {}
|
||
assert payload["agent_os_learning_ledger"] == {}
|
||
assert payload["learning_ledger"] == {}
|
||
assert payload["agent_os_next_cycle_triggers"] == []
|
||
assert any(step["key"] == "agent_answer_runtime" for step in payload["answer_process_packet"]["steps"])
|
||
assert any(item.__class__.__name__ == "PMFlowAgentMemory" for item in payload["pmflow_agent_run"].get("memories", [])) is False
|
||
assert "项目当前需要先收口核心验证" in payload["answer"]
|
||
sanitized = sanitize_customer_rag_payload(payload, apply_presentation_contract=False)
|
||
assert sanitized["agent_os_operating_cycle"] == {}
|
||
assert sanitized["agent_os_learning_ledger"] == {}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pmflow_agent_specialist_runtime_step3_does_not_bind_visible_project_list() -> None:
|
||
result = await PMFlowAgentSpecialistRuntimeService(
|
||
db=object(),
|
||
tenant_id=12,
|
||
project_world_state_service=_FakeProjectWorldStateService(),
|
||
).run(
|
||
request=RAGAskRequest(question="你是谁"),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
)
|
||
|
||
assert result.skipped_reason == "本轮没有强项目锚点,未运行项目专业 agent。"
|
||
assert result.assignments == []
|
||
assert result.work_products == []
|
||
assert result.answer is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pmflow_agent_runtime_step3_keeps_direct_dialogue_answer_even_with_project_anchor() -> None:
|
||
payload = await PMFlowAgentRuntimeService(
|
||
_FakeDB(),
|
||
tenant_id=12,
|
||
delivery_runtime=_FakeDirectDeliveryRuntime(),
|
||
environment_service=PMFlowAgentEnvironmentService(
|
||
db=object(),
|
||
tenant_id=12,
|
||
tool_registry=_RecordingAgentOSToolRegistry(),
|
||
),
|
||
specialist_runtime=PMFlowAgentSpecialistRuntimeService(
|
||
db=object(),
|
||
tenant_id=12,
|
||
project_world_state_service=_FakeProjectWorldStateService(),
|
||
),
|
||
).run_space_turn(
|
||
request=RAGAskRequest(question="你是谁", project_id=141),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
)
|
||
|
||
assert payload["answer"].startswith("我是 PMFlow AI")
|
||
assert "可以帮你查看空间、项目和下一步" in payload["answer"]
|
||
assert payload["agent_os_work_products"] == []
|
||
assert "最应该先推进" not in payload["answer"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_operating_runtime_step1_wraps_answer_agent_with_mission_and_work_products() -> None:
|
||
payload = await PMFlowAIOperatingRunService(
|
||
object(),
|
||
tenant_id=12,
|
||
project_world_state_service=_FakeProjectWorldStateService(),
|
||
delivery_runtime=_FakeDeliveryRuntime(),
|
||
).run_turn(
|
||
request=RAGAskRequest(question="这个项目怎么推进好呢", project_id=141),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
)
|
||
|
||
assert payload["processing_stage"] == "multi_agent_operating_runtime"
|
||
assert payload["answer_mode"] == "operating_runtime"
|
||
assert payload["operating_mission"]["packet_version"] == "pmflow_ai_operating_mission.v1"
|
||
assert payload["operating_mission"]["project_scope"]["object_refs"] == ["project:141"]
|
||
assert payload["project_world_state_packet"]["packet_version"] == "pmflow_ai_project_world_state.v1"
|
||
assert payload["project_world_state_packet"]["status"] == "ready"
|
||
assert payload["project_world_state_packet"]["coverage"]["covered_facets"]
|
||
assert payload["evidence_ledger"]
|
||
assert payload["operating_run_packet"]["packet_version"] == "pmflow_ai_operating_run.v1"
|
||
assert payload["operating_run_packet"]["project_world_state"]["packet_version"] == "pmflow_ai_project_world_state.v1"
|
||
assert payload["operating_run_packet"]["agent_council_review"]["packet_version"] == "pmflow_ai_agent_council_review.v1"
|
||
assert payload["operating_run_packet"]["status"] == "waiting_for_confirmation"
|
||
assert len(payload["agent_assignments"]) >= 6
|
||
assert {item["agent_id"] for item in payload["agent_assignments"]} >= {
|
||
"product_strategy_agent",
|
||
"mvp_scope_agent",
|
||
"pm_breakdown_agent",
|
||
"tech_architect_agent",
|
||
"qa_risk_agent",
|
||
"learning_agent",
|
||
}
|
||
assert all("project_world_state_packet" in item["input_refs"] for item in payload["agent_assignments"])
|
||
assert all("learning_ledger" in item["input_refs"] for item in payload["agent_assignments"])
|
||
assert payload["agent_work_products"]
|
||
assert all(item["claims"] for item in payload["agent_work_products"])
|
||
assert {item["product_type"] for item in payload["agent_work_products"]} >= {
|
||
"OpportunityBrief",
|
||
"MVPOption",
|
||
"TaskDraft",
|
||
"TechnicalArchitectureAssessment",
|
||
"QAPlan",
|
||
"LearningUpdate",
|
||
}
|
||
assert all(item["structured_output"] for item in payload["agent_work_products"])
|
||
assert all(item["verification"]["role_contract"] == item["product_type"] for item in payload["agent_work_products"])
|
||
assert any("evd_test" in item["evidence_refs"] for item in payload["agent_work_products"])
|
||
assert payload["product_object_projections"]
|
||
assert payload["product_object_projections"][0]["packet_version"] == "pmflow_ai_product_object_projection.v1"
|
||
assert payload["product_object_projections"] == payload["command_payload"]["product_object_projections"]
|
||
assert payload["answer"] != "项目当前需要先收口核心验证,再准备可确认的下一步。"
|
||
assert "最高杠杆" in payload["answer"] or "最该推进" in payload["answer"]
|
||
assert payload["command_payload"]["packet_version"] == "pmflow_ai_operating_command_payload.v1"
|
||
assert payload["command_payload"]["project_world_state"]["status"] == "ready"
|
||
assert payload["command_payload"]["evidence_ledger"]
|
||
assert payload["agent_council_review"]["priority_order"]
|
||
assert payload["command_payload"]["priority_order"] == payload["agent_council_review"]["priority_order"]
|
||
assert payload["operating_plan"]["priority_order"] == payload["agent_council_review"]["priority_order"]
|
||
assert payload["operating_plan"]["conflict_ledger"] == payload["conflict_ledger"]
|
||
assert payload["command_payload"]["prepared_actions"]
|
||
assert payload["autonomy_gate"]["packet_version"] == "pmflow_ai_autonomy_gate.v1"
|
||
assert payload["autonomy_gate"]["status"] == "prepared_waiting_confirmation"
|
||
assert payload["prepared_action_packets"] == payload["command_payload"]["prepared_actions"]
|
||
assert payload["confirmation_requests"] == payload["command_payload"]["confirmation_requests"]
|
||
assert payload["execution_preparations"] == payload["command_payload"]["execution_preparations"]
|
||
assert payload["operating_cycle"]["packet_version"] == "pmflow_ai_operating_cycle_state.v1"
|
||
assert payload["operating_cycle"]["trigger"]["trigger_type"] == "user_turn"
|
||
assert {item["trigger_type"] for item in payload["operating_cycle"]["supported_triggers"]} >= {
|
||
"user_turn",
|
||
"signal",
|
||
"project_creation",
|
||
"task_update",
|
||
"execution_result",
|
||
"daily_review",
|
||
"weekly_strategy_review",
|
||
}
|
||
assert payload["learning_ledger"]["packet_version"] == "pmflow_ai_learning_ledger.v1"
|
||
assert payload["learning_updates"] == payload["command_payload"]["learning_updates"]
|
||
assert payload["next_cycle_triggers"] == payload["command_payload"]["next_cycle"]["triggers"]
|
||
assert any(item.get("source_agent") in {"mvp_scope_agent", "pm_breakdown_agent", "product_strategy_agent"} for item in payload["prepared_action_packets"])
|
||
assert payload["answer_process_packet"]["route_display"] == "multi_agent_operating_runtime"
|
||
assert [step["key"] for step in payload["answer_process_packet"]["steps"][:8]] == [
|
||
"operating_mission",
|
||
"specialist_agent_assignment",
|
||
"project_world_state_read",
|
||
"specialist_work_products",
|
||
"agent_council_review",
|
||
"operating_plan_compile",
|
||
"autonomy_gate_prepared_actions",
|
||
"learning_ledger_update",
|
||
]
|
||
sanitized = sanitize_customer_rag_payload(payload, apply_presentation_contract=False)
|
||
assert sanitized["processing_stage"] == "multi_agent_operating_runtime"
|
||
assert sanitized["project_world_state_packet"]["packet_version"] == "pmflow_ai_project_world_state.v1"
|
||
assert sanitized["agent_council_review"]["packet_version"] == "pmflow_ai_agent_council_review.v1"
|
||
assert sanitized["autonomy_gate"]["packet_version"] == "pmflow_ai_autonomy_gate.v1"
|
||
assert sanitized["conflict_ledger"]
|
||
assert sanitized["prepared_action_packets"]
|
||
assert sanitized["confirmation_requests"]
|
||
assert sanitized["execution_preparations"]
|
||
assert sanitized["product_object_projections"]
|
||
assert sanitized["operating_cycle"]["packet_version"] == "pmflow_ai_operating_cycle_state.v1"
|
||
assert sanitized["learning_ledger"]["packet_version"] == "pmflow_ai_learning_ledger.v1"
|
||
assert sanitized["outcome_observations"]
|
||
assert sanitized["learning_updates"]
|
||
assert sanitized["next_cycle_triggers"]
|
||
assert sanitized["evidence_ledger"]
|
||
assert sanitized["command_payload"]["packet_version"] == "pmflow_ai_operating_command_payload.v1"
|
||
assert "agent_workspace" not in sanitized
|
||
assert "answer_runtime_packet" not in sanitized
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_operating_runtime_accepts_stream_event_sink_and_delegates_it() -> None:
|
||
delivery_runtime = _RecordingEventSinkDeliveryRuntime()
|
||
events: list[dict] = []
|
||
|
||
async def sink(event: dict) -> None:
|
||
events.append(event)
|
||
|
||
payload = await PMFlowAIOperatingRunService(
|
||
object(),
|
||
tenant_id=12,
|
||
project_world_state_service=_FakeProjectWorldStateService(),
|
||
delivery_runtime=delivery_runtime,
|
||
).run_turn(
|
||
request=RAGAskRequest(question="这个项目怎么推进好呢", project_id=141),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
event_sink=sink,
|
||
)
|
||
|
||
assert payload["processing_stage"] == "multi_agent_operating_runtime"
|
||
assert delivery_runtime.saw_event_sink is True
|
||
assert events == [{"type": "step.completed", "step": {"key": "agent_answer_runtime", "label": "生成回答", "status": "completed"}}]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_operating_runtime_open_research_projects_partial_evidence_to_research_recovery() -> None:
|
||
payload = await PMFlowAIOperatingRunService(
|
||
object(),
|
||
tenant_id=12,
|
||
project_world_state_service=_FakeOpenResearchProjectWorldStateService(),
|
||
delivery_runtime=_FakeOpenResearchDeliveryRuntime(),
|
||
).run_turn(
|
||
request=RAGAskRequest(question="最近AI会议市场怎么样"),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": []}},
|
||
)
|
||
|
||
world_state = payload["project_world_state_packet"]
|
||
research_facet = next(item for item in world_state["facets"] if item["facet_key"] == "external_research_evidence")
|
||
projection = payload["product_object_projections"][0]
|
||
prepared = payload["prepared_action_packets"]
|
||
confirmations = payload["confirmation_requests"]
|
||
|
||
assert world_state["project_refs"] == []
|
||
assert research_facet["status"] == "partial"
|
||
assert projection["object_type"] == "ResearchNote"
|
||
assert projection["status"] == "draft"
|
||
assert any(item["action_family"] == "research_recovery" for item in prepared)
|
||
assert any(item["source_action_type"] == "continue_research" for item in prepared)
|
||
assert any(item["action_type"] == "continue_research" and item["action_label"] == "继续补研究" for item in confirmations)
|
||
assert all(item["can_execute_after_confirmation"] is True for item in confirmations if item["action_type"] == "continue_research")
|
||
recovery_payload = next(item for item in prepared if item["action_family"] == "research_recovery")["payload"]["research_recovery"]
|
||
assert len(recovery_payload["source_plan"]) == 3
|
||
assert recovery_payload["queries"]
|
||
assert "OpportunityBrief" not in {item["object_type"] for item in payload["product_object_projections"]}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_operating_runtime_promotes_nested_agent_run_observations_before_specialists() -> None:
|
||
payload = await PMFlowAIOperatingRunService(
|
||
object(),
|
||
tenant_id=12,
|
||
project_world_state_service=_FakeOpenResearchProjectWorldStateService(),
|
||
delivery_runtime=_FakeNestedResearchDeliveryRuntime(),
|
||
).run_turn(
|
||
request=RAGAskRequest(question="最近AI会议市场怎么样"),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": []}},
|
||
)
|
||
|
||
world_state = payload["project_world_state_packet"]
|
||
research_facet = next(item for item in world_state["facets"] if item["facet_key"] == "external_research_evidence")
|
||
facts = {item["fact_key"]: item["value"] for item in research_facet["facts"]}
|
||
confirmations = payload["confirmation_requests"]
|
||
|
||
assert len(facts["source_candidates"]) == 2
|
||
assert len(facts["usable_claims"]) == 1
|
||
assert {item["agent_id"] for item in payload["agent_work_products"]} == {
|
||
"idea_incubation_agent",
|
||
"market_research_agent",
|
||
"competitor_agent",
|
||
"product_strategy_agent",
|
||
"growth_agent",
|
||
"monetization_agent",
|
||
"learning_agent",
|
||
}
|
||
assert {item["action_family"] for item in payload["prepared_action_packets"]} == {"research_recovery"}
|
||
assert all(item["action_type"] == "continue_research" for item in confirmations)
|
||
assert all(item["can_execute_after_confirmation"] is True for item in confirmations)
|
||
assert payload["answer_canvas"]["direct_answer"] == payload["answer"]
|
||
blocked = {item["agent_id"]: item for item in payload["agent_work_products"] if item["status"] == "blocked"}
|
||
assert {"competitor_agent", "growth_agent", "monetization_agent"}.issubset(blocked)
|
||
|
||
|
||
def test_operating_registry_keeps_requested_specialists_and_leaves_dependency_gate_to_roles() -> None:
|
||
mission = PMFlowAIOperatingMission(
|
||
stage="research",
|
||
original_user_input="最近AI会议市场怎么样",
|
||
required_agents=["growth_agent", "monetization_agent", "market_research_agent"],
|
||
)
|
||
|
||
assignments = PMFlowAIOperatingAgentRegistry().assignments_for_mission(mission)
|
||
|
||
assert [item.agent_id for item in assignments] == ["growth_agent", "monetization_agent", "market_research_agent"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_operating_runtime_step6_consumes_and_updates_learning_ledger() -> None:
|
||
previous_ledger = {
|
||
"packet_version": "pmflow_ai_learning_ledger.v1",
|
||
"ledger_id": "lgr_previous",
|
||
"scope_ref": "project:141",
|
||
"learning_items": [
|
||
{
|
||
"learning_key": "single_priority_order",
|
||
"summary": "上一轮确认:用户希望只看一个优先级顺序。",
|
||
"outcome_type": "user_preference",
|
||
"outcome_status": "observed",
|
||
}
|
||
],
|
||
"decision_outcomes": [],
|
||
"experiment_results": [],
|
||
"execution_outcomes": [],
|
||
"user_preferences": [],
|
||
"consumed_memory_refs": [],
|
||
}
|
||
payload = await PMFlowAIOperatingRunService(
|
||
object(),
|
||
tenant_id=12,
|
||
project_world_state_service=_FakeProjectWorldStateService(),
|
||
delivery_runtime=_FakeDeliveryRuntime(),
|
||
).run_turn(
|
||
request=RAGAskRequest(question="继续推进这个项目", project_id=141),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={
|
||
"context_capsules": [
|
||
{
|
||
"capsule_type": "operating_learning_ledger",
|
||
"scope_ref": "project:141",
|
||
"learning_ledger": previous_ledger,
|
||
}
|
||
],
|
||
"operating_outcomes": [
|
||
{
|
||
"outcome_type": "recommendation_decision",
|
||
"outcome_status": "accepted",
|
||
"source_ref": "creq_previous",
|
||
"target_ref": "project:141",
|
||
"summary": "用户接受了上一轮 MVP 收口建议。",
|
||
"learning_signals": {"decision_state": "accepted"},
|
||
}
|
||
],
|
||
},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
)
|
||
|
||
assert payload["consumed_learning_ledger"]["ledger_id"] == "lgr_previous"
|
||
assert payload["consumed_learning_ledger"]["learning_items"][0]["learning_key"] == "single_priority_order"
|
||
assert payload["learning_ledger"]["ledger_id"] == "lgr_previous"
|
||
assert any(item["outcome_status"] == "accepted" for item in payload["learning_ledger"]["decision_outcomes"])
|
||
assert any(item["trigger_type"] == "execution_result" for item in payload["next_cycle_triggers"])
|
||
assert "读取了上一轮" in payload["answer"]
|
||
capsules = space_api._upsert_operating_learning_ledger_capsule(capsules=[], learning_ledger=payload["learning_ledger"])
|
||
assert capsules[0]["capsule_type"] == "operating_learning_ledger"
|
||
assert capsules[0]["learning_ledger"]["ledger_id"] == "lgr_previous"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_operating_runtime_step5_prepares_actions_but_blocks_writes_and_handoffs() -> None:
|
||
payload = await PMFlowAIOperatingRunService(
|
||
object(),
|
||
tenant_id=12,
|
||
project_world_state_service=_FakeProjectWorldStateService(),
|
||
delivery_runtime=_FakeDeliveryRuntime(),
|
||
).run_turn(
|
||
request=RAGAskRequest(question="这个项目怎么推进好呢", project_id=141),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
)
|
||
|
||
families = {item["action_family"] for item in payload["prepared_action_packets"]}
|
||
required_before = {
|
||
required
|
||
for request in payload["confirmation_requests"]
|
||
for required in request.get("required_before", [])
|
||
}
|
||
prohibited = set(payload["autonomy_gate"]["prohibited_side_effects"])
|
||
|
||
assert {"project_memory_candidate", "product_object_projection"} <= families
|
||
assert payload["execution_preparations"]
|
||
assert payload["execution_preparations"][0]["packet_version"] == "pmflow_ai_execution_preparation.v1"
|
||
assert payload["execution_preparations"][0]["readiness"] == "prepared_waiting_confirmation"
|
||
assert payload["execution_preparations"][0]["handoff_package"]["packet_version"] == "execution_bridge_handoff_preparation.v1"
|
||
assert payload["execution_preparations"][0]["handoff_package"]["result_writeback_contract"]["protocol_version"] == "execution_result_protocol_v1"
|
||
assert all(item["confirmation_required"] is True for item in payload["prepared_action_packets"])
|
||
assert all(item["status"] == "prepared_waiting_confirmation" for item in payload["prepared_action_packets"])
|
||
assert all(request["status"] == "waiting_for_user_confirmation" for request in payload["confirmation_requests"])
|
||
assert "write_project_memory" in prohibited
|
||
assert "execute_handoff" in prohibited
|
||
assert "write_project_memory" in required_before
|
||
assert "execute_handoff" in required_before
|
||
executable_requests = [request for request in payload["confirmation_requests"] if request.get("can_execute_after_confirmation")]
|
||
assert executable_requests
|
||
assert {request.get("action_type") for request in executable_requests} >= {"save_candidate", "start_execution_handoff"}
|
||
assert any(
|
||
request.get("action_payload", {}).get("payload", {}).get("handoff_package", {}).get("result_writeback_contract", {}).get("protocol_version")
|
||
== "execution_result_protocol_v1"
|
||
for request in executable_requests
|
||
)
|
||
assert "确认前只展示候选,不会写入项目资料、改动任务或交给执行环境" in payload["answer"]
|
||
|
||
|
||
def test_space_confirmation_continue_research_builds_server_side_rerun_prompt() -> None:
|
||
prompt = space_api._research_recovery_prompt_from_confirmation(
|
||
confirmation={
|
||
"title": "确认准备动作:准备 ResearchNote",
|
||
"action_payload": {
|
||
"payload": {
|
||
"research_recovery": {
|
||
"research_question": "最近 AI 会议市场怎么样",
|
||
"queries": ["AI conference market 2026 sponsorship", "AI event platform market"],
|
||
"research_gaps": ["缺少参会增长", "缺少商业化证据"],
|
||
}
|
||
}
|
||
},
|
||
},
|
||
request=space_api.PMFlowAIConfirmationExecutionRequest(),
|
||
)
|
||
|
||
assert "只读研究补证" in prompt
|
||
assert "重新经过 specialist/council" in prompt
|
||
assert "AI conference market 2026 sponsorship" in prompt
|
||
assert "不要写 Project Memory" in prompt
|
||
|
||
|
||
def test_space_confirmation_execution_handoff_parses_formal_task_contract() -> None:
|
||
result = space_api._execution_handoff_request_from_confirmation(
|
||
action_payload={
|
||
"target_ref": "project:141",
|
||
"payload": {
|
||
"handoff_package": {
|
||
"task_candidates": [{"target_ref": "task:9001", "title": "实现执行桥回写"}],
|
||
"repo_binding_id": "8",
|
||
"runtime_target": "codex",
|
||
}
|
||
},
|
||
},
|
||
supplement_text="先跑最小测试。",
|
||
)
|
||
|
||
assert result["project_id"] == 141
|
||
assert result["task_id"] == 9001
|
||
assert result["repo_binding_id"] == "8"
|
||
assert result["runtime_target"] == "codex"
|
||
assert result["note"] == "先跑最小测试。"
|
||
|
||
|
||
def test_space_confirmation_execution_handoff_blocks_without_formal_task() -> None:
|
||
result = space_api._execution_handoff_request_from_confirmation(
|
||
action_payload={"target_ref": "project:141", "payload": {"handoff_package": {"task_candidates": []}}},
|
||
supplement_text="",
|
||
)
|
||
|
||
assert result["blocked"] is True
|
||
assert "正式任务" in result["missing"]
|
||
assert "不能创建执行交接" in result["customer_message"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_operating_runtime_step4_council_records_risk_veto_and_single_priority() -> None:
|
||
payload = await PMFlowAIOperatingRunService(
|
||
object(),
|
||
tenant_id=12,
|
||
project_world_state_service=_FakeProjectWorldStateService(
|
||
blocked_actions=[
|
||
{
|
||
"display_label": "先处理当前任务阻塞",
|
||
"reason": "项目世界状态显示该动作仍受阻,需要先解除阻塞。",
|
||
}
|
||
]
|
||
),
|
||
delivery_runtime=_FakeDeliveryRuntime(),
|
||
).run_turn(
|
||
request=RAGAskRequest(question="这个项目怎么推进好呢", project_id=141),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
)
|
||
|
||
conflict_types = {item["conflict_type"] for item in payload["conflict_ledger"]}
|
||
blocked_titles = {item["title"] for item in payload["operating_plan"]["blocked_actions"]}
|
||
priority_titles = [item["title"] for item in payload["operating_plan"]["priority_order"]]
|
||
|
||
assert payload["agent_council_review"]["status"] == "blocked"
|
||
assert "risk_veto_conflict" in conflict_types
|
||
assert "先处理当前任务阻塞" in blocked_titles
|
||
assert "先处理当前任务阻塞" not in priority_titles
|
||
assert priority_titles == [item["title"] for item in payload["command_payload"]["priority_order"]]
|
||
assert len(priority_titles) == len(set(priority_titles))
|
||
|
||
|
||
def test_space_api_frame_type_ignores_legacy_turn_state() -> None:
|
||
frame_type = space_api._space_frame_type_from_agent_payload(
|
||
{
|
||
"pmflow_ai_turn_state": {"answer_state": {"answer_shape": "legacy_status_answer"}},
|
||
"terminal_answer_state": {"terminal_status": "answerable"},
|
||
"agent_run_packet": {"status": "answered"},
|
||
}
|
||
)
|
||
|
||
assert frame_type == "answerable"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_runtime_step2_lets_planner_choose_external_search_without_identity_template() -> None:
|
||
payload = await PMFlowAIAgentRunService(
|
||
object(),
|
||
tenant_id=12,
|
||
planner=_FakePlanner("external_search"),
|
||
tool_registry=_FakeToolRegistry(),
|
||
critic=_FakeCritic(ready=True),
|
||
answer_runtime=PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()),
|
||
).run_turn(
|
||
request=RAGAskRequest(question="帮我分析下用AI来协助采购监督的系统怎么样"),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
started_at=None,
|
||
)
|
||
|
||
step_keys = [step["key"] for step in payload["answer_process_packet"]["steps"]]
|
||
assert "agent_step_planner" in step_keys
|
||
assert "agent_tool_external_search" in step_keys
|
||
assert "agent_tool_inspect_source_detail" in step_keys
|
||
assert "agent_state_critic" in step_keys
|
||
assert payload["agent_workspace"]["observations"][-1]["observation_type"] == "external_source_document"
|
||
assert payload["sources"] == [{"label": "公开来源", "url": "https://example.com/source"}]
|
||
assert "我是 PMFlow AI" not in payload["answer"]
|
||
assert "身份" not in payload["answer"]
|
||
assert "能力边界" not in payload["answer"]
|
||
assert payload["terminal_answer_state"]["packet_version"] == "pmflow_ai_agent_terminal_answer_state.v1"
|
||
assert payload["answer_runtime_packet"]["packet_version"] == "pmflow_ai_agent_answer_runtime_packet.v1"
|
||
assert payload["deliverable_packet"]["source_observation_ids"]
|
||
assert payload["deliverable_packet"]["sections"]
|
||
assert "判断依据:" not in payload["answer"]
|
||
assert "Claim Ledger" not in payload["answer"]
|
||
sanitized = sanitize_customer_rag_payload(payload, apply_presentation_contract=False)
|
||
assert "answer_runtime_packet" not in sanitized
|
||
assert "agent_workspace" not in sanitized
|
||
assert "pmflow_ai_turn_state" not in sanitized
|
||
assert "pmflow_ai_route_plan" not in sanitized
|
||
assert sanitized["deliverable_packet"]["packet_version"] == "pmflow_ai_agent_deliverable_packet.v1"
|
||
assert sanitized["terminal_answer_state"]["packet_version"] == "pmflow_ai_agent_terminal_answer_state.v1"
|
||
|
||
|
||
def test_agent_runtime_step2_keeps_legacy_goal_assessor_out_of_new_service() -> None:
|
||
from app.services.pmflow_ai_agent_runtime import agent_run_service
|
||
|
||
source = inspect.getsource(agent_run_service)
|
||
|
||
assert "PROMPT_PMFLOW_AI_TURN_GOAL_STATE_ASSESSOR" not in source
|
||
assert "PMFlowAITurnRuntimeService" not in source
|
||
assert "looks_like_" not in source
|
||
assert "operation_kind" not in source
|
||
assert "external_search" in AGENT_TOOL_CONTRACTS
|
||
|
||
|
||
def test_pmflow_ai_runtime_prompt_renderers_do_not_use_code_fallback() -> None:
|
||
service_roots = [
|
||
Path("backend/app/services/pmflow_ai_agent_runtime"),
|
||
Path("backend/app/services/pmflow_ai_operating_runtime"),
|
||
]
|
||
source = "\n".join(
|
||
path.read_text(encoding="utf-8")
|
||
for root in service_roots
|
||
for path in root.glob("*.py")
|
||
)
|
||
|
||
assert "code_fallback" not in source
|
||
assert "fallback_template=" not in source
|
||
|
||
|
||
def test_agent_tool_environment_exposes_typed_contracts_not_name_whitelist() -> None:
|
||
contract = AGENT_TOOL_CONTRACTS["inspect_project_operating_state"]
|
||
|
||
assert contract.read_or_write == "read"
|
||
assert contract.risk_level == "low"
|
||
assert "inspect_project_status" in contract.aliases
|
||
assert contract.input_schema
|
||
assert contract.output_schema
|
||
assert contract.permission_boundary
|
||
assert contract.success_signal
|
||
assert contract.failure_signal
|
||
assert contract.side_effects == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_runtime_v2_replans_when_critic_requests_more_evidence() -> None:
|
||
payload = await PMFlowAIAgentRunService(
|
||
object(),
|
||
tenant_id=12,
|
||
planner=_SequencePlanner(["inspect_space_collections", "external_search"]),
|
||
tool_registry=_FakeToolRegistry(),
|
||
critic=_SequenceCritic(
|
||
[
|
||
PMFlowAIAgentCriticResult(
|
||
ready_to_answer=False,
|
||
should_continue=True,
|
||
state_delta={"remaining_gap": "needs_external_source"},
|
||
reason="还需要继续读取外部证据。",
|
||
),
|
||
PMFlowAIAgentCriticResult(
|
||
ready_to_answer=True,
|
||
should_continue=False,
|
||
state_delta={"evidence_ready": True},
|
||
reason="证据已足够。",
|
||
),
|
||
]
|
||
),
|
||
answer_runtime=PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()),
|
||
).run_turn(
|
||
request=RAGAskRequest(question="帮我分析下用AI来协助采购监督的系统怎么样"),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
started_at=None,
|
||
)
|
||
|
||
step_keys = [step["key"] for step in payload["answer_process_packet"]["steps"]]
|
||
assert "agent_step_planner_2" in step_keys
|
||
assert "agent_tool_external_search_2" in step_keys
|
||
assert "agent_tool_inspect_source_detail_2" in step_keys
|
||
assert "agent_state_critic_2" in step_keys
|
||
assert payload["agent_run_packet"]["stop_reason"] == "ready_to_answer"
|
||
assert payload["agent_run_packet"]["iterations"] == 2
|
||
assert payload["agent_run_packet"]["tool_call_count"] == 3
|
||
assert [event["event_type"] for event in payload["agent_run_packet"]["event_log"]].count("plan") == 2
|
||
assert len(payload["agent_workspace"]["action_ledger"]) >= 2
|
||
assert any(item.get("tool_name") == "external_search" for item in payload["agent_workspace"]["attempt_ledger"])
|
||
assert payload["agent_workspace"]["deliverable_state"]["stop_reason"] == "ready_to_answer"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_runtime_recovers_from_failed_source_detail_by_reading_untried_ranked_sources() -> None:
|
||
tool_registry = _RecoveryExternalToolRegistry()
|
||
payload = await PMFlowAIAgentRunService(
|
||
object(),
|
||
tenant_id=12,
|
||
planner=_SequencePlanner(["external_search"]),
|
||
tool_registry=tool_registry,
|
||
critic=_SequenceCritic(
|
||
[
|
||
PMFlowAIAgentCriticResult(
|
||
ready_to_answer=False,
|
||
should_continue=False,
|
||
retry_would_change_state=False,
|
||
state_delta={"source_quality_blocked_claims": True},
|
||
reason="第一个来源不可读,旧链路会直接停住。",
|
||
),
|
||
PMFlowAIAgentCriticResult(
|
||
ready_to_answer=True,
|
||
should_continue=False,
|
||
retry_would_change_state=False,
|
||
state_delta={"recovered_with_second_source": True},
|
||
reason="补读未尝试来源后可以回答。",
|
||
),
|
||
]
|
||
),
|
||
answer_runtime=PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()),
|
||
).run_turn(
|
||
request=RAGAskRequest(question="最近AI会议市场怎么样"),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": []}},
|
||
started_at=None,
|
||
)
|
||
|
||
step_keys = [step["key"] for step in payload["answer_process_packet"]["steps"]]
|
||
assert "agent_recovery_planner_2" in step_keys
|
||
assert "agent_tool_inspect_source_detail_2" in step_keys
|
||
assert payload["agent_run_packet"]["stop_reason"] == "ready_to_answer"
|
||
assert payload["agent_run_packet"]["tool_call_count"] == 3
|
||
failed_first_url = tool_registry.inspect_calls[0][0]
|
||
assert failed_first_url not in tool_registry.inspect_calls[1]
|
||
assert "https://example.com/ai-conference-attendance-2026" in tool_registry.inspect_calls[1]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_runtime_recovers_when_only_source_candidate_claims_exist() -> None:
|
||
payload = await PMFlowAIAgentRunService(
|
||
object(),
|
||
tenant_id=12,
|
||
planner=_SequencePlanner(["external_search"]),
|
||
tool_registry=_CandidateClaimRecoveryToolRegistry(),
|
||
evidence_normalizer=_CandidateOnlyNormalizer(),
|
||
critic=_SequenceCritic(
|
||
[
|
||
PMFlowAIAgentCriticResult(
|
||
ready_to_answer=False,
|
||
should_continue=True,
|
||
retry_would_change_state=True,
|
||
state_delta={"remaining_gaps": ["needs_conference_specific_sources"]},
|
||
reason="当前只有候选来源,没有可陈述业务事实,继续检索会改变状态。",
|
||
),
|
||
PMFlowAIAgentCriticResult(
|
||
ready_to_answer=True,
|
||
should_continue=False,
|
||
state_delta={"recovered": True},
|
||
reason="第二轮已补充观察。",
|
||
),
|
||
]
|
||
),
|
||
answer_runtime=PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()),
|
||
).run_turn(
|
||
request=RAGAskRequest(question="最近AI会议市场怎么样"),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": []}},
|
||
started_at=None,
|
||
)
|
||
|
||
step_keys = [step["key"] for step in payload["answer_process_packet"]["steps"]]
|
||
assert "agent_recovery_planner_2" in step_keys
|
||
assert "agent_tool_external_search_2" in step_keys
|
||
assert payload["agent_run_packet"]["stop_reason"] == "ready_to_answer"
|
||
assert payload["agent_run_packet"]["iterations"] == 2
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pmflow_agent_runtime_uses_external_search_and_ledger_for_open_market_goal() -> None:
|
||
dynamic_runtime = PMFlowAIAgentRunService(
|
||
object(),
|
||
tenant_id=12,
|
||
planner=_SequencePlanner(["external_search"]),
|
||
tool_registry=_FakeToolRegistry(),
|
||
critic=_SequenceCritic(
|
||
[
|
||
PMFlowAIAgentCriticResult(
|
||
ready_to_answer=True,
|
||
should_continue=False,
|
||
state_delta={"external_claim_ready": True},
|
||
reason="公开材料已进入 claim ledger。",
|
||
),
|
||
]
|
||
),
|
||
answer_runtime=PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()),
|
||
)
|
||
|
||
payload = await PMFlowAgentRuntimeService(
|
||
_FakeDB(),
|
||
tenant_id=12,
|
||
delivery_runtime=dynamic_runtime,
|
||
).run_space_turn(
|
||
request=RAGAskRequest(question="帮我看看市场上有没有什么新的好项目"),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
)
|
||
|
||
process_keys = [step["key"] for step in payload["answer_process_packet"]["steps"]]
|
||
assert "agent_tool_external_search" in process_keys
|
||
assert "agent_evidence_normalizer" in process_keys
|
||
assert any(step.get("tool_name") == "external_search" for step in payload["agent_os_steps"])
|
||
assert any(observation.get("source_kind") == "external_search" for observation in payload["agent_os_observations"])
|
||
assert not any(step.get("tool_name") == "run_opportunity_discovery_agents" for step in payload["agent_os_steps"])
|
||
assert "我是 PMFlow AI" not in payload["answer"]
|
||
assert payload["customer_evidence_packet"]["answer_type"]
|
||
assert payload["agent_os_verifications"][-1]["target_ref"] == "final_answer"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_runtime_blocks_raw_external_source_noise_from_customer_answer() -> None:
|
||
payload = await PMFlowAIAgentRunService(
|
||
object(),
|
||
tenant_id=12,
|
||
planner=_SequencePlanner(["external_search", "inspect_source_detail"]),
|
||
tool_registry=_NoisyExternalToolRegistry(),
|
||
critic=PMFlowAIAgentStateCritic(db=object(), tenant_id=12, generator=object()),
|
||
answer_runtime=PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()),
|
||
).run_turn(
|
||
request=RAGAskRequest(question="最近AI漫剧行业怎么样"),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": []}},
|
||
started_at=None,
|
||
)
|
||
|
||
answer = payload["answer"]
|
||
assert payload["deliverable_packet"]["status"] == "blocked"
|
||
assert payload["agent_workspace"]["claim_ledger"] == []
|
||
assert payload["agent_workspace"]["evidence_normalizations"][0]["source_quality"] == "noisy"
|
||
assert "news@iimedia.cn" not in answer
|
||
assert "020-8922-4700" not in answer
|
||
assert " " not in answer
|
||
assert "联系我们 订阅邮箱" not in answer
|
||
assert "网页噪音" in answer or "证据" in answer
|
||
assert all("news@iimedia.cn" not in str(item) for item in payload["deliverable_packet"]["sections"])
|
||
|
||
|
||
def test_agent_answer_runtime_never_promotes_fact_ledger_to_customer_claims() -> None:
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="检查外部来源")
|
||
workspace.fact_ledger = [
|
||
PMFlowAIAgentFactLedgerEntry(
|
||
fact_type="external_evidence",
|
||
fact_text="原始网页:news@example.com 020-1234-5678   联系我们 订阅邮箱",
|
||
source_tool="external_search",
|
||
)
|
||
]
|
||
|
||
terminal_state = build_agent_terminal_answer_state(
|
||
question="检查外部来源",
|
||
workspace=workspace,
|
||
proposal=PMFlowAIAgentStepProposal(action="stop_blocked", rationale="停止"),
|
||
observation=None,
|
||
critic_result=PMFlowAIAgentCriticResult(
|
||
ready_to_answer=False,
|
||
should_continue=False,
|
||
retry_would_change_state=False,
|
||
missing_state=["缺少可入账 claim"],
|
||
reason="claim ledger 为空。",
|
||
),
|
||
)
|
||
answer = render_agent_terminal_state_fallback(terminal_state)
|
||
|
||
assert terminal_state.claims == []
|
||
assert "news@example.com" not in answer
|
||
assert "020-1234-5678" not in answer
|
||
assert " " not in answer
|
||
assert "这轮还没有形成可以可靠展示的结论" in answer
|
||
|
||
|
||
def test_non_usable_normalization_cannot_enter_claim_ledger() -> None:
|
||
observation = PMFlowAIAgentObservation(
|
||
source="external_search",
|
||
observation_type="external_public_evidence",
|
||
summary="已查询外部公开来源。",
|
||
facts=[
|
||
{
|
||
"type": "external_evidence",
|
||
"title": "公开页面",
|
||
"summary": "联系我们 020-1234-5678 news@example.com   订阅邮箱",
|
||
"url": "https://example.com/noisy",
|
||
}
|
||
],
|
||
sources=[{"label": "公开页面", "url": "https://example.com/noisy"}],
|
||
confidence="high",
|
||
relevance_to_goal="high",
|
||
)
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="检查外部来源")
|
||
normalization = fallback_normalize_observation(workspace=workspace, observation=observation)
|
||
apply_evidence_normalization(workspace, normalization=normalization)
|
||
|
||
assert normalization.source_quality == "noisy"
|
||
assert normalization.usable_claims == []
|
||
assert workspace.claim_ledger == []
|
||
assert workspace.boundary_ledger
|
||
|
||
|
||
def test_source_candidate_normalization_does_not_enter_claim_ledger() -> None:
|
||
observation = PMFlowAIAgentObservation(
|
||
source="external_search",
|
||
observation_type="external_public_evidence",
|
||
summary="已查询外部公开来源。",
|
||
facts=[
|
||
{
|
||
"type": "external_evidence",
|
||
"title": "AI Meeting Assistants 2026",
|
||
"summary": "This page may contain related market material.",
|
||
"url": "https://example.com/ai-meeting-assistants",
|
||
}
|
||
],
|
||
sources=[{"label": "AI Meeting Assistants 2026", "url": "https://example.com/ai-meeting-assistants"}],
|
||
confidence="medium",
|
||
relevance_to_goal="high",
|
||
)
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议行业怎么样")
|
||
normalization = fallback_normalize_observation(workspace=workspace, observation=observation)
|
||
apply_evidence_normalization(workspace, normalization=normalization)
|
||
|
||
assert normalization.source_quality == "noisy"
|
||
assert normalization.usable_claims == []
|
||
assert workspace.claim_ledger == []
|
||
assert workspace.boundary_ledger
|
||
assert any("候选来源" in boundary.customer_safe_reason for boundary in workspace.boundary_ledger)
|
||
|
||
|
||
def test_external_search_candidate_summary_never_enters_claim_ledger_before_reading_source() -> None:
|
||
observation = PMFlowAIAgentObservation(
|
||
source="external_search",
|
||
observation_type="external_public_evidence",
|
||
summary="已查询外部公开来源。",
|
||
facts=[
|
||
{
|
||
"type": "external_evidence",
|
||
"title": "Industry report candidate",
|
||
"summary": "A 2026 public report says adoption increased by 42% and sponsors expanded budgets.",
|
||
"url": "https://example.com/report-candidate",
|
||
}
|
||
],
|
||
sources=[{"label": "Industry report candidate", "url": "https://example.com/report-candidate"}],
|
||
confidence="high",
|
||
relevance_to_goal="high",
|
||
)
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="最近行业市场怎么样")
|
||
normalization = fallback_normalize_observation(workspace=workspace, observation=observation)
|
||
apply_evidence_normalization(workspace, normalization=normalization)
|
||
|
||
assert normalization.source_quality == "noisy"
|
||
assert normalization.usable_claims == []
|
||
assert workspace.claim_ledger == []
|
||
assert normalization.source_normalizations[0].rejected_reason == "search_candidate_requires_source_reading"
|
||
|
||
|
||
def test_external_source_document_can_use_event_market_signals_after_source_reading() -> None:
|
||
observation = PMFlowAIAgentObservation(
|
||
source="inspect_source_detail",
|
||
observation_type="external_source_document",
|
||
summary="已读取公开来源正文。",
|
||
facts=[
|
||
{
|
||
"type": "external_source_text",
|
||
"title": "RAISE Summit 2026",
|
||
"url": "https://example.com/raise-summit",
|
||
"text": (
|
||
"European AI conferences are shifting toward applied enterprise AI, buyer networking, "
|
||
"and sponsor-backed communities. Upcoming events like RAISE Summit 2026 report over "
|
||
"9,000 attendees and dedicated enterprise AI tracks."
|
||
),
|
||
}
|
||
],
|
||
sources=[{"label": "RAISE Summit 2026", "url": "https://example.com/raise-summit"}],
|
||
confidence="medium",
|
||
relevance_to_goal="high",
|
||
)
|
||
workspace = PMFlowAIAgentWorkspace(
|
||
current_goal="最近AI会议市场怎么样",
|
||
goal_frame=PMFlowAIAgentGoalFrame(
|
||
user_goal="判断AI会议活动市场的近期态势",
|
||
desired_output="给出有边界的市场研究判断",
|
||
evidence_requirements=["AI conference attendance", "conference market activity"],
|
||
interpretation_branches=[
|
||
{
|
||
"branch_id": "b1",
|
||
"meaning": "AI会议和活动市场",
|
||
"user_language_prior": "high",
|
||
"anchor_terms": ["AI conference", "conference", "event", "attendance", "attendees", "summit"],
|
||
},
|
||
{
|
||
"branch_id": "b2",
|
||
"meaning": "AI技术软件市场",
|
||
"user_language_prior": "medium",
|
||
"anchor_terms": ["software", "model", "agentic AI"],
|
||
},
|
||
],
|
||
),
|
||
)
|
||
|
||
normalization = fallback_normalize_observation(
|
||
question="最近AI会议市场怎么样",
|
||
workspace=workspace,
|
||
observation=observation,
|
||
)
|
||
apply_evidence_normalization(workspace, normalization=normalization)
|
||
|
||
assert normalization.source_quality == "usable"
|
||
assert workspace.claim_ledger
|
||
assert any("9,000 attendees" in claim.claim_text for claim in workspace.claim_ledger)
|
||
assert all("###" not in claim.claim_text for claim in workspace.claim_ledger)
|
||
|
||
|
||
def test_external_source_page_shell_does_not_enter_claim_ledger() -> None:
|
||
observation = PMFlowAIAgentObservation(
|
||
source="inspect_source_detail",
|
||
observation_type="external_source_document",
|
||
summary="已读取公开来源正文。",
|
||
facts=[
|
||
{
|
||
"type": "external_source_text",
|
||
"title": "Forbes Councils Article",
|
||
"url": "https://example.com/forbes-councils",
|
||
"text": (
|
||
"Forbes Councils Paid Program Subscribe Sign In Newsletter Share This Article "
|
||
"Contact Us Privacy Terms Copyright 'AI events' Read More Premium "
|
||
"About Us Site Map Follow Us Advertisement"
|
||
),
|
||
}
|
||
],
|
||
sources=[{"label": "Forbes Councils Article", "url": "https://example.com/forbes-councils"}],
|
||
confidence="medium",
|
||
relevance_to_goal="high",
|
||
)
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="做一轮公开市场研究")
|
||
normalization = fallback_normalize_observation(workspace=workspace, observation=observation)
|
||
apply_evidence_normalization(workspace, normalization=normalization)
|
||
|
||
assert normalization.source_quality == "noisy"
|
||
assert normalization.usable_claims == []
|
||
assert workspace.claim_ledger == []
|
||
assert normalization.source_normalizations[0].rejected_reason == "raw_source_noise"
|
||
|
||
|
||
def test_operating_research_evidence_ignores_raw_and_generic_claim_texts() -> None:
|
||
workspace = {
|
||
"claim_ledger": [
|
||
{
|
||
"claim_type": "external_source_evidence",
|
||
"claim_text": "公开来源《Example》提供了与本轮目标相关的材料:Subscribe Sign In Newsletter Privacy Terms Advertisement",
|
||
},
|
||
{
|
||
"claim_type": "external_source_evidence",
|
||
"claim_text": "A 2026 industry survey reported 42% revenue growth among vendors, and enterprise buyers increased budgets for specialized solutions.",
|
||
},
|
||
{
|
||
"claim_type": "external_source_evidence",
|
||
"claim_text": "3万人 | Example | ### 关键增长驱动因素 - 预计2026年形成$330-480亿美元市场 --- ## 区域市场格局",
|
||
},
|
||
]
|
||
}
|
||
deliverable = {
|
||
"sections": [
|
||
{
|
||
"section_kind": "claims",
|
||
"items": [
|
||
{"summary": "候选来源需要结合其它来源交叉验证。"},
|
||
{"summary": "The report shows demand increased in 2026 as budgets shifted toward practical adoption programs."},
|
||
],
|
||
}
|
||
]
|
||
}
|
||
|
||
claims = _claim_texts(workspace, deliverable)
|
||
|
||
assert len(claims) == 2
|
||
assert all("Subscribe" not in item for item in claims)
|
||
assert all("候选来源" not in item for item in claims)
|
||
assert all("###" not in item for item in claims)
|
||
|
||
|
||
def test_external_source_document_fallback_extracts_goal_relevant_claims_without_llm() -> None:
|
||
observation = PMFlowAIAgentObservation(
|
||
source="inspect_source_detail",
|
||
observation_type="external_source_document",
|
||
summary="已读取公开来源正文。",
|
||
facts=[
|
||
{
|
||
"type": "external_source_text",
|
||
"title": "AI Conference Outlook 2026",
|
||
"url": "https://example.com/ai-conference-outlook",
|
||
"text": (
|
||
"AI Conference Outlook 2026 reports that enterprise buyers are increasing budgets for specialized "
|
||
"AI conferences and event programs. The same report says organizers are packaging sponsorship, "
|
||
"training and community formats around practical AI adoption, with revenue growth expected in 2026."
|
||
),
|
||
}
|
||
],
|
||
sources=[{"label": "AI Conference Outlook 2026", "url": "https://example.com/ai-conference-outlook"}],
|
||
confidence="medium",
|
||
relevance_to_goal="high",
|
||
)
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议市场怎么样")
|
||
normalization = fallback_normalize_observation(workspace=workspace, observation=observation)
|
||
apply_evidence_normalization(workspace, normalization=normalization)
|
||
|
||
assert normalization.source_quality == "usable"
|
||
assert normalization.usable_claims
|
||
assert workspace.claim_ledger
|
||
assert workspace.claim_ledger[0].claim_kind == "substantive_fact"
|
||
assert workspace.claim_ledger[0].claim_type == "external_source_evidence"
|
||
assert "提供了与本轮目标相关的材料" not in workspace.claim_ledger[0].claim_text
|
||
|
||
|
||
def test_source_relevance_gate_blocks_raw_ai_market_page_as_research_claim() -> None:
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议市场怎么样")
|
||
workspace.goal_frame = PMFlowAIAgentGoalFrame(
|
||
user_goal="获取近期人工智能相关会议市场的整体态势",
|
||
desired_output="简要综述 AI 会议市场现状、活动活跃度及主要趋势。",
|
||
interpretation_branches=[
|
||
{
|
||
"branch_id": "b1",
|
||
"meaning": "行业活动生态:关注AI领域会议、峰会、论坛等活动的近期动态",
|
||
"semantic_decomposition": "AI领域 + 会议活动 + 市场活跃度 + 状态评估",
|
||
"user_language_prior": "high",
|
||
"confidence": "high",
|
||
"anchor_terms": ["AI", "会议", "市场"],
|
||
}
|
||
],
|
||
evidence_requirements=[
|
||
"近期主要AI会议活动及其关注热点",
|
||
"AI会议活动的整体趋势描述",
|
||
"公开媒体或行业分析对会议活跃度的评价",
|
||
],
|
||
)
|
||
observation = PMFlowAIAgentObservation(
|
||
source="inspect_source_detail",
|
||
observation_type="external_source_document",
|
||
summary="已读取公开来源正文。",
|
||
facts=[
|
||
{
|
||
"type": "external_source_text",
|
||
"title": "中国CIO峰会",
|
||
"url": "https://example.com/cio-summit",
|
||
"text": (
|
||
"中国CIO峰会 上海站 | 2026年10月23日 深圳站:5月15日 立即注册 "
|
||
"当前,中国人工智能市场发展迅猛,IDC数据显示,2024年中国生成式AI占AI市场投资总规模的18.2%。"
|
||
"随着生成式AI技术发展,2029年生成式AI投资占比将达到41.1%,投资规模超450亿美元。"
|
||
"企业员工可借助生成式AI生成结构化业务洞察,开发人员能够进行代码重构。"
|
||
),
|
||
}
|
||
],
|
||
confidence="medium",
|
||
relevance_to_goal="high",
|
||
)
|
||
|
||
normalization = fallback_normalize_observation(
|
||
question="最近AI会议市场怎么样",
|
||
workspace=workspace,
|
||
observation=observation,
|
||
)
|
||
apply_evidence_normalization(workspace, normalization=normalization)
|
||
|
||
assert normalization.source_quality != "usable"
|
||
assert workspace.claim_ledger == []
|
||
|
||
|
||
def test_research_note_threshold_blocks_single_dimension_external_claims() -> None:
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议市场怎么样")
|
||
workspace.goal_frame = PMFlowAIAgentGoalFrame(
|
||
user_goal="获取近期人工智能相关会议市场的整体态势",
|
||
desired_output="简要综述 AI 会议市场现状、活动活跃度及主要趋势。",
|
||
evidence_requirements=[
|
||
"近期主要AI会议活动及其关注热点",
|
||
"AI会议活动的整体趋势描述",
|
||
"公开媒体或行业分析对会议活跃度的评价",
|
||
],
|
||
interpretation_branches=[
|
||
{
|
||
"branch_id": "b1",
|
||
"meaning": "行业活动生态:关注AI领域会议、峰会、论坛等活动的近期动态",
|
||
"user_language_prior": "high",
|
||
"confidence": "high",
|
||
}
|
||
],
|
||
)
|
||
workspace.claim_ledger = [
|
||
PMFlowAIAgentClaim(
|
||
claim_type="external_source_evidence",
|
||
claim_text="2026年某公开来源显示多个AI主题会议正在聚焦智能体和产业落地热点。",
|
||
support_refs=["https://source-a.example/report"],
|
||
interpretation_branch_id="b1",
|
||
relevance_to_user_goal="partial",
|
||
claim_kind="substantive_fact",
|
||
confidence="medium",
|
||
),
|
||
PMFlowAIAgentClaim(
|
||
claim_type="external_source_evidence",
|
||
claim_text="另一公开来源显示AI主题会议议程正在聚焦智能体和产业落地热点。",
|
||
support_refs=["https://source-b.example/report"],
|
||
interpretation_branch_id="b1",
|
||
relevance_to_user_goal="partial",
|
||
claim_kind="substantive_fact",
|
||
confidence="medium",
|
||
),
|
||
]
|
||
|
||
result = _enforce_critic_contract(
|
||
PMFlowAIAgentCriticResult(
|
||
ready_to_answer=True,
|
||
should_continue=False,
|
||
retry_would_change_state=False,
|
||
recover_or_continue="compose_answer",
|
||
next_step_hint="compose_answer",
|
||
),
|
||
workspace=workspace,
|
||
observation=PMFlowAIAgentObservation(
|
||
source="inspect_source_detail",
|
||
observation_type="external_source_document",
|
||
summary="已读取公开来源正文。",
|
||
confidence="medium",
|
||
relevance_to_goal="high",
|
||
),
|
||
)
|
||
|
||
assert result.ready_to_answer is False
|
||
assert result.should_continue is True
|
||
assert any("研究记录" in item for item in result.missing_state)
|
||
|
||
|
||
def test_external_source_document_extracts_clean_fact_from_noisy_page_shell() -> None:
|
||
observation = PMFlowAIAgentObservation(
|
||
source="inspect_source_detail",
|
||
observation_type="external_source_document",
|
||
summary="已读取公开来源正文。",
|
||
facts=[
|
||
{
|
||
"type": "external_source_text",
|
||
"title": "Enterprise Collaboration Summit",
|
||
"url": "https://example.com/summit",
|
||
"text": (
|
||
"Subscribe Sign In Newsletter Share Privacy Terms Advertisement Contact Us "
|
||
"Enterprise Collaboration Summit 2026 reports 1,200 enterprise attendees and says sponsors "
|
||
"increased budgets by 38% as workflow automation moved into mainstream conference programs. "
|
||
"Read More Premium Site Map Copyright"
|
||
),
|
||
}
|
||
],
|
||
sources=[{"label": "Enterprise Collaboration Summit", "url": "https://example.com/summit"}],
|
||
confidence="medium",
|
||
relevance_to_goal="high",
|
||
)
|
||
workspace = PMFlowAIAgentWorkspace(
|
||
current_goal="最近企业协作峰会市场怎么样",
|
||
goal_frame=PMFlowAIAgentGoalFrame(
|
||
user_goal="判断企业协作峰会市场最近怎么样",
|
||
interpretation_branches=[
|
||
{
|
||
"branch_id": "b1",
|
||
"meaning": "企业协作峰会和活动市场趋势",
|
||
"user_language_prior": "high",
|
||
"anchor_terms": ["enterprise collaboration summit", "conference", "sponsor"],
|
||
},
|
||
{
|
||
"branch_id": "b2",
|
||
"meaning": "企业协作软件市场趋势",
|
||
"user_language_prior": "medium",
|
||
"anchor_terms": ["collaboration software"],
|
||
},
|
||
],
|
||
),
|
||
)
|
||
|
||
normalization = fallback_normalize_observation(
|
||
question="最近企业协作峰会市场怎么样",
|
||
workspace=workspace,
|
||
observation=observation,
|
||
)
|
||
apply_evidence_normalization(workspace, normalization=normalization)
|
||
|
||
assert normalization.source_quality == "usable"
|
||
assert workspace.claim_ledger
|
||
claim = workspace.claim_ledger[0]
|
||
assert claim.interpretation_branch_id == "b1"
|
||
assert "1,200 enterprise attendees" in claim.claim_text
|
||
assert "Subscribe Sign In Newsletter" not in claim.claim_text
|
||
assert "Contact Us" not in claim.claim_text
|
||
|
||
|
||
def test_operating_source_rows_use_clean_gated_fragments_instead_of_page_shell() -> None:
|
||
observations = [
|
||
{
|
||
"source_kind": "inspect_source_detail",
|
||
"observation_type": "external_source_document",
|
||
"facts": [
|
||
{
|
||
"type": "external_source_text",
|
||
"title": "Enterprise Collaboration Summit",
|
||
"url": "https://example.com/summit",
|
||
"text": (
|
||
"Subscribe Sign In Newsletter Privacy Terms Advertisement "
|
||
"The 2026 enterprise collaboration summit drew 1,200 attendees and reported 38% sponsor budget growth."
|
||
),
|
||
}
|
||
],
|
||
}
|
||
]
|
||
|
||
rows = _external_source_rows(observations)
|
||
|
||
assert len(rows) == 1
|
||
assert "1,200 attendees" in rows[0]["summary"]
|
||
assert "Subscribe Sign In" not in rows[0]["summary"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_evidence_normalizer_timeboxes_slow_llm_and_falls_back(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
monkeypatch.setenv("PMFLOW_AI_AGENT_EVIDENCE_NORMALIZER_TIMEOUT_SECONDS", "0.1")
|
||
|
||
class SlowGenerator:
|
||
async def generate(self, *_args, **_kwargs):
|
||
await asyncio.sleep(1)
|
||
return "{}"
|
||
|
||
observation = PMFlowAIAgentObservation(
|
||
source="external_search",
|
||
observation_type="external_public_evidence",
|
||
summary="已查询外部公开来源。",
|
||
facts=[
|
||
{
|
||
"type": "external_evidence",
|
||
"title": "AI Meeting Assistants 2026",
|
||
"summary": "This page may contain related market material.",
|
||
"url": "https://example.com/ai-meeting-assistants",
|
||
}
|
||
],
|
||
sources=[{"label": "AI Meeting Assistants 2026", "url": "https://example.com/ai-meeting-assistants"}],
|
||
confidence="medium",
|
||
relevance_to_goal="high",
|
||
)
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议行业怎么样")
|
||
|
||
normalization = await PMFlowAIAgentEvidenceNormalizer(
|
||
db=SimpleNamespace(execute=lambda *_args, **_kwargs: None),
|
||
tenant_id=12,
|
||
generator=SlowGenerator(),
|
||
).normalize(question="最近AI会议行业怎么样", workspace=workspace, observation=observation)
|
||
|
||
assert normalization.source_quality == "noisy"
|
||
assert normalization.usable_claims == []
|
||
assert normalization.boundaries
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_evidence_normalizer_uses_deterministic_extraction_when_llm_only_returns_gaps(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
observation = PMFlowAIAgentObservation(
|
||
source="inspect_source_detail",
|
||
observation_type="external_source_document",
|
||
summary="已读取公开来源正文。",
|
||
facts=[
|
||
{
|
||
"type": "external_source_text",
|
||
"title": "智能协作会议系统市场观察",
|
||
"url": "https://example.com/collaboration-meeting-market",
|
||
"text": (
|
||
"2025年智能会议系统市场规模达到186亿元,具备AI能力的产品占比超过45%。"
|
||
"报告预计2027年AI会议系统市占率将突破75%,会议场景正在从单点纪要转向协作工作流。"
|
||
),
|
||
}
|
||
],
|
||
sources=[{"label": "智能协作会议系统市场观察", "url": "https://example.com/collaboration-meeting-market"}],
|
||
confidence="medium",
|
||
relevance_to_goal="high",
|
||
)
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="最近智能会议市场怎么样")
|
||
monkeypatch.setattr(PMFlowAIAgentEvidenceNormalizer, "_render_prompt", _fake_agent_prompt_renderer("PROMPT_PMFLOW_AI_AGENT_EVIDENCE_NORMALIZER"))
|
||
generator = _SequenceGenerator(
|
||
[
|
||
"""
|
||
{
|
||
"source_quality": "noisy",
|
||
"gaps": ["模型没有抽出可用事实"]
|
||
}
|
||
"""
|
||
]
|
||
)
|
||
|
||
normalization = await PMFlowAIAgentEvidenceNormalizer(
|
||
db=SimpleNamespace(execute=lambda *_args, **_kwargs: None),
|
||
tenant_id=12,
|
||
generator=generator,
|
||
).normalize(question="最近智能会议市场怎么样", workspace=workspace, observation=observation)
|
||
apply_evidence_normalization(workspace, normalization=normalization)
|
||
|
||
assert normalization.source_quality == "usable"
|
||
assert workspace.claim_ledger
|
||
assert "186亿元" in workspace.claim_ledger[0].claim_text
|
||
assert "模型没有抽出可用事实" in normalization.gaps
|
||
|
||
|
||
def test_attention_queue_normalization_keeps_action_candidates() -> None:
|
||
observation = PMFlowAIAgentObservation(
|
||
source="inspect_attention_queue",
|
||
observation_type="attention_queue",
|
||
summary="已读取 1 条客户安全注意力队列候选。",
|
||
facts=[
|
||
{
|
||
"type": "action_candidate",
|
||
"title": "审查任务 194 的 PR 结果",
|
||
"summary": "任务 194 当前 PR 已提交,需要确认验收状态。",
|
||
"target_ref": "project:26",
|
||
"target_title": "儿童项目",
|
||
"href": "/execution-bridge?projectId=26&taskId=194",
|
||
"reason": "执行状态 pr_open,下一步是人工审查。",
|
||
"completion_criteria": "PR 审查完成,验收状态更新。",
|
||
"safety": "prepare_only",
|
||
"requires_confirmation": True,
|
||
},
|
||
{
|
||
"type": "object_affordance",
|
||
"object_ref": "project:26",
|
||
"title": "儿童项目",
|
||
"action_label": "审查任务 194 的 PR 结果",
|
||
"href": "/execution-bridge?projectId=26&taskId=194",
|
||
},
|
||
],
|
||
sources=[{"label": "PMFlow 注意力队列"}],
|
||
confidence="high",
|
||
relevance_to_goal="high",
|
||
)
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="儿童那个项目下一步我要做什么呢")
|
||
normalization = fallback_normalize_observation(workspace=workspace, observation=observation)
|
||
apply_evidence_normalization(workspace, normalization=normalization)
|
||
|
||
assert normalization.source_quality == "usable"
|
||
assert workspace.action_candidate_ledger
|
||
candidate = workspace.action_candidate_ledger[0]
|
||
assert candidate.title == "审查任务 194 的 PR 结果"
|
||
assert candidate.href == "/execution-bridge?projectId=26&taskId=194"
|
||
assert "PR 审查完成" in candidate.completion_criteria
|
||
assert workspace.affordance_ledger[0].object_ref == "project:26"
|
||
|
||
|
||
def test_terminal_answer_uses_action_candidate_without_generic_industry_fallback() -> None:
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="儿童那个项目下一步我要做什么呢")
|
||
workspace.claim_ledger = [
|
||
PMFlowAIAgentClaim(
|
||
claim_type="project_execution_package",
|
||
claim_text="儿童项目当前选中任务 194,执行状态是 PR 已提交。",
|
||
customer_value="支撑项目下一步判断。",
|
||
confidence="high",
|
||
)
|
||
]
|
||
workspace.action_candidate_ledger = [
|
||
PMFlowAIAgentActionCandidateLedgerEntry(
|
||
title="审查任务 194 的 PR 结果",
|
||
target_ref="project:26",
|
||
target_title="儿童项目",
|
||
href="/execution-bridge?projectId=26&taskId=194",
|
||
reason="执行状态 pr_open,下一步需要确认交付与验收。",
|
||
completion_criteria="PR 审查完成,验收状态更新。",
|
||
safety="prepare_only",
|
||
confidence="high",
|
||
)
|
||
]
|
||
terminal_state = build_agent_terminal_answer_state(
|
||
question="儿童那个项目下一步我要做什么呢",
|
||
workspace=workspace,
|
||
proposal=PMFlowAIAgentStepProposal(action="answer"),
|
||
observation=None,
|
||
critic_result=PMFlowAIAgentCriticResult(ready_to_answer=True, should_continue=False),
|
||
)
|
||
answer = render_agent_terminal_state_fallback(terminal_state)
|
||
|
||
assert "审查任务 194 的 PR 结果" in answer
|
||
assert "PR 审查完成" in answer
|
||
assert "行业信号" not in answer
|
||
assert "受控初判" not in answer
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_project_execution_package_preserves_selected_task_action_detail() -> None:
|
||
registry = PMFlowAIAgentToolRegistry(db=object(), tenant_id=12)
|
||
package = await registry._build_project_execution_package_payload(
|
||
project_id=26,
|
||
project_ref="project:26",
|
||
project_title="儿童项目",
|
||
execution_payload={
|
||
"execution": {"taskId": 194, "status": "pr_open", "testSummary": {"status": "passed"}},
|
||
"activeDevTasks": [{"id": 194, "title": "修复儿童项目首页登录链路", "status": "in_review"}],
|
||
"acceptance": {"status": "pending_review"},
|
||
"releaseGate": {"status": "blocked"},
|
||
"qualityContract": {"summary": {"releaseBlockingMissing": 1}},
|
||
},
|
||
truth_payload={"selectedTaskId": 194},
|
||
)
|
||
|
||
# Use the public observation projection shape that the normalizer consumes.
|
||
from app.services.pmflow_ai_agent_runtime.agent_tool_registry import _execution_package_facts
|
||
|
||
projected = _execution_package_facts(package)
|
||
selected = next(item for item in projected if item.get("type") == "selected_task")
|
||
action = next(item for item in projected if item.get("type") == "action_candidate")
|
||
|
||
assert selected["task_id"] == "194"
|
||
assert selected["title"] == "修复儿童项目首页登录链路"
|
||
assert action["target_ref"] == "project:26"
|
||
assert action["href"] == "/execution-bridge?projectId=26&taskId=194"
|
||
assert "发布阻塞级缺失证据" in action["completion_criteria"]
|
||
assert "补齐 1 个发布阻塞级证据" in action["title"]
|
||
|
||
|
||
def test_external_source_detail_rejects_404_and_navigation_noise() -> None:
|
||
observation = PMFlowAIAgentObservation(
|
||
source="inspect_source_detail",
|
||
observation_type="external_source_document",
|
||
summary="已读取公开来源正文。",
|
||
facts=[
|
||
{
|
||
"type": "external_source_text",
|
||
"title": "404 页面不存在",
|
||
"url": "https://example.com/missing",
|
||
"text": "404 页面不存在 登录 注册 隐私政策 用户协议 Copyright 网站地图",
|
||
}
|
||
],
|
||
sources=[{"label": "404 页面不存在", "url": "https://example.com/missing"}],
|
||
confidence="medium",
|
||
relevance_to_goal="high",
|
||
)
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="我这个项目和宝宝树对比怎么样")
|
||
normalization = fallback_normalize_observation(workspace=workspace, observation=observation)
|
||
|
||
assert normalization.source_quality == "noisy"
|
||
assert normalization.usable_claims == []
|
||
assert normalization.boundaries
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_answer_runtime_returns_chinese_partial_answer_when_safe_claims_exist() -> None:
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI行业怎么样")
|
||
workspace.claim_ledger = [
|
||
PMFlowAIAgentClaim(
|
||
claim_type="market_investment_trend",
|
||
claim_text="Global AI investment is projected to reach $300 billion by 2026.",
|
||
support_refs=["Morgan Stanley"],
|
||
confidence="medium",
|
||
),
|
||
PMFlowAIAgentClaim(
|
||
claim_type="model_performance_trend",
|
||
claim_text="Open-weight models are narrowing the performance gap with proprietary models.",
|
||
support_refs=["LLM Stats"],
|
||
confidence="medium",
|
||
),
|
||
]
|
||
workspace.boundary_ledger = [
|
||
PMFlowAIAgentBoundary(
|
||
boundary_type="freshness_gap",
|
||
blocked_claim="缺少 2026 年 5 月之后的实时材料。",
|
||
customer_safe_reason="这轮公开材料还没有覆盖 2026 年 5 月之后的变化。",
|
||
allowed_alternative="继续补充最新公开来源后,再把判断更新成完整结论。",
|
||
)
|
||
]
|
||
|
||
result = await PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()).run(
|
||
question="最近AI行业怎么样",
|
||
workspace=workspace,
|
||
proposal=PMFlowAIAgentStepProposal(action="stop_blocked", rationale="停止"),
|
||
observation=None,
|
||
critic_result=PMFlowAIAgentCriticResult(
|
||
ready_to_answer=False,
|
||
should_continue=False,
|
||
retry_would_change_state=False,
|
||
reason="已有部分 claim,但缺少实时材料。",
|
||
),
|
||
)
|
||
|
||
assert result.terminal_state.terminal_status == "partially_answerable"
|
||
assert result.deliverable_packet.status == "partial"
|
||
assert result.deliverable_packet.deliverable_kind == "decision_packet"
|
||
assert result.quality_findings == []
|
||
assert "这轮已有材料能支撑一部分判断" in result.answer
|
||
assert "Global AI investment" not in result.answer
|
||
assert "Open-weight models" not in result.answer
|
||
assert any("\u4e00" <= ch <= "\u9fff" for ch in result.answer)
|
||
assert "Global AI investment" not in str(result.deliverable_packet.sections)
|
||
assert "Open-weight models" not in str(result.deliverable_packet.sections)
|
||
|
||
|
||
def test_agent_answer_critic_allows_longer_customer_safe_blocked_answer() -> None:
|
||
terminal_state = PMFlowAIAgentTerminalAnswerState(
|
||
user_goal="最近AI会议行业怎么样",
|
||
terminal_status="waiting_for_evidence",
|
||
boundaries=[
|
||
PMFlowAIAgentBoundary(
|
||
boundary_type="source_gap",
|
||
blocked_claim="缺少会议行业垂直公开来源。",
|
||
customer_safe_reason="这轮查到的材料偏向泛 AI 行业,不能直接说明 AI 会议行业最近的发展。",
|
||
allowed_alternative="继续换用会议技术、活动平台和标准组织来源补证据。",
|
||
)
|
||
],
|
||
answer_intent_plan=PMFlowAIAgentAnswerIntentPlan(lead_boundary_id="boundary_missing", detail_level="brief"),
|
||
)
|
||
answer = (
|
||
"目前还不能可靠判断最近 AI 会议行业怎么样。已经查到的材料更多是在讲泛 AI 趋势、语音平台或宏观产业变化,"
|
||
"没有足够覆盖会议与活动技术场景,例如虚拟/混合会议平台的新功能、实时翻译在会场中的使用、智能议程生成、"
|
||
"线下和线上融合的落地进展,也缺少最近三个月内可追溯的垂直来源。"
|
||
"所以这轮更稳妥的结论是:不能把现有材料当作行业判断依据。下一步应该换查 PCMA、MPI、Zoom、Teams、Webex、"
|
||
"Airmeet、Run The World、IEEE 或 ISO/IEC 这类会议技术、平台公告和标准组织来源,再用发布时间和具体事件交叉确认。"
|
||
)
|
||
|
||
assert critique_agent_customer_answer(terminal_state=terminal_state, answer=answer) == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_state_critic_respects_recovery_veto_when_more_reading_would_change_state() -> None:
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议行业怎么样")
|
||
workspace.goal_frame = PMFlowAIAgentGoalFrame(
|
||
user_goal="了解最近AI会议行业怎么样",
|
||
desired_output="给出方向性判断并说明边界。",
|
||
answer_policy="directional_answer",
|
||
source_policy="credible_public_sources",
|
||
freshness_policy="prefer_recent_not_strict",
|
||
missing_evidence_policy="answer_with_boundaries",
|
||
)
|
||
workspace.claim_ledger = [
|
||
PMFlowAIAgentClaim(
|
||
claim_type="vendor_product_update",
|
||
claim_text="公开来源显示会议平台正在把 AI 会议能力从摘要扩展到工作流和后续执行。",
|
||
customer_value="支撑方向性行业判断。",
|
||
support_refs=["公开来源"],
|
||
confidence="medium",
|
||
)
|
||
]
|
||
workspace.gap_ledger = [
|
||
# The remaining gap should become a boundary, not a hard blocker.
|
||
PMFlowAIAgentGapLedgerEntry(
|
||
gap_type="market_size_gap",
|
||
description="缺少可用于判断精确市场规模和份额的独立报告。",
|
||
blocking=True,
|
||
user_actionable=False,
|
||
)
|
||
]
|
||
generator = _SequenceGenerator(
|
||
[
|
||
"""
|
||
{
|
||
"ready_to_answer": false,
|
||
"should_continue": true,
|
||
"needs_user_input": false,
|
||
"retry_would_change_state": true,
|
||
"state_delta": {"remaining_gaps": ["缺少完整报告"]},
|
||
"findings": ["还没覆盖全部维度"],
|
||
"missing_state": ["完整行业报告"],
|
||
"recover_or_continue": "continue_with_tool",
|
||
"next_step_hint": "select_next_read_tool",
|
||
"reason": "仍缺少完整证据。"
|
||
}
|
||
"""
|
||
]
|
||
)
|
||
|
||
result = await PMFlowAIAgentStateCritic(db=SimpleNamespace(execute=lambda *_args, **_kwargs: None), tenant_id=12, generator=generator).review(
|
||
question="最近AI会议行业怎么样",
|
||
workspace=workspace,
|
||
proposal=PMFlowAIAgentStepProposal(action="tool", tool_name="external_search"),
|
||
observation=PMFlowAIAgentObservation(
|
||
source="external_search",
|
||
observation_type="external_public_evidence",
|
||
summary="已查询外部公开来源。",
|
||
confidence="medium",
|
||
relevance_to_goal="high",
|
||
),
|
||
)
|
||
|
||
assert result.ready_to_answer is False
|
||
assert result.should_continue is True
|
||
assert result.retry_would_change_state is True
|
||
assert result.recover_or_continue == "continue_with_tool"
|
||
assert result.next_step_hint == "select_next_read_tool"
|
||
assert any("缺少可用于判断精确市场规模和份额的独立报告" in item for item in result.state_delta["remaining_gaps"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_state_critic_does_not_answer_from_source_candidate_only(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议行业怎么样")
|
||
workspace.goal_frame = PMFlowAIAgentGoalFrame(
|
||
user_goal="了解最近AI会议行业怎么样",
|
||
desired_output="给出方向性判断并说明边界。",
|
||
answer_policy="directional_answer",
|
||
source_policy="credible_public_sources",
|
||
freshness_policy="prefer_recent_not_strict",
|
||
missing_evidence_policy="answer_with_boundaries",
|
||
)
|
||
workspace.claim_ledger = [
|
||
PMFlowAIAgentClaim(
|
||
claim_type="external_source_candidate",
|
||
claim_text="公开来源《AI Meeting Assistants 2026》提供了与本轮目标相关的外部材料,但仍需要结合其它来源交叉验证后再下强结论。",
|
||
customer_value="确认本轮有可追溯外部材料可进入后续抽取和交叉验证。",
|
||
support_refs=["https://example.com/ai-meeting-assistants"],
|
||
confidence="medium",
|
||
)
|
||
]
|
||
generator = _SequenceGenerator(
|
||
[
|
||
"""
|
||
{
|
||
"ready_to_answer": false,
|
||
"should_continue": true,
|
||
"needs_user_input": false,
|
||
"retry_would_change_state": true,
|
||
"state_delta": {"remaining_gaps": ["还没有业务事实 claim"]},
|
||
"findings": ["只有候选来源"],
|
||
"missing_state": ["需要读取正文或换来源抽取业务事实"],
|
||
"recover_or_continue": "continue_with_tool",
|
||
"next_step_hint": "select_next_read_tool",
|
||
"reason": "候选来源不能支撑最终回答。"
|
||
}
|
||
"""
|
||
]
|
||
)
|
||
monkeypatch.setattr(PMFlowAIAgentStateCritic, "_render_prompt", _fake_agent_prompt_renderer("PROMPT_PMFLOW_AI_AGENT_STATE_CRITIC"))
|
||
|
||
result = await PMFlowAIAgentStateCritic(db=SimpleNamespace(execute=lambda *_args, **_kwargs: None), tenant_id=12, generator=generator).review(
|
||
question="最近AI会议行业怎么样",
|
||
workspace=workspace,
|
||
proposal=PMFlowAIAgentStepProposal(action="tool", tool_name="external_search"),
|
||
observation=PMFlowAIAgentObservation(
|
||
source="external_search",
|
||
observation_type="external_public_evidence",
|
||
summary="已查询外部公开来源。",
|
||
confidence="medium",
|
||
relevance_to_goal="high",
|
||
),
|
||
)
|
||
|
||
assert result.ready_to_answer is False
|
||
assert result.should_continue is True
|
||
assert result.recover_or_continue == "continue_with_tool"
|
||
assert result.next_step_hint == "select_next_read_tool"
|
||
assert any("业务事实" in item or "读取正文" in item for item in result.missing_state)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_state_critic_vetoes_raw_page_fragment_claim(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="做一轮公开市场研究")
|
||
workspace.goal_frame = PMFlowAIAgentGoalFrame(
|
||
user_goal="做一轮公开市场研究",
|
||
desired_output="给出方向性判断并说明边界。",
|
||
answer_policy="directional_answer",
|
||
source_policy="credible_public_sources",
|
||
freshness_policy="prefer_recent_not_strict",
|
||
missing_evidence_policy="answer_with_boundaries",
|
||
)
|
||
workspace.claim_ledger = [
|
||
PMFlowAIAgentClaim(
|
||
claim_type="external_source_evidence",
|
||
claim_text="Forbes Councils Paid Program Subscribe Sign In Newsletter Share This Article Contact Us Privacy Terms Copyright Advertisement",
|
||
customer_value="支撑公开研究。",
|
||
support_refs=["https://example.com/noisy"],
|
||
confidence="medium",
|
||
)
|
||
]
|
||
generator = _SequenceGenerator(
|
||
[
|
||
"""
|
||
{
|
||
"ready_to_answer": true,
|
||
"should_continue": false,
|
||
"needs_user_input": false,
|
||
"retry_would_change_state": false,
|
||
"state_delta": {},
|
||
"findings": ["已有材料"],
|
||
"missing_state": [],
|
||
"recover_or_continue": "compose_answer",
|
||
"next_step_hint": "compose_answer",
|
||
"reason": "模型误判为可回答。"
|
||
}
|
||
"""
|
||
]
|
||
)
|
||
monkeypatch.setattr(PMFlowAIAgentStateCritic, "_render_prompt", _fake_agent_prompt_renderer("PROMPT_PMFLOW_AI_AGENT_STATE_CRITIC"))
|
||
|
||
result = await PMFlowAIAgentStateCritic(db=SimpleNamespace(execute=lambda *_args, **_kwargs: None), tenant_id=12, generator=generator).review(
|
||
question="做一轮公开市场研究",
|
||
workspace=workspace,
|
||
proposal=PMFlowAIAgentStepProposal(action="tool", tool_name="inspect_source_detail"),
|
||
observation=PMFlowAIAgentObservation(
|
||
source="inspect_source_detail",
|
||
observation_type="external_source_document",
|
||
summary="已读取公开来源正文。",
|
||
confidence="medium",
|
||
relevance_to_goal="high",
|
||
),
|
||
)
|
||
|
||
assert result.ready_to_answer is False
|
||
assert result.should_continue is False
|
||
assert result.recover_or_continue == "stop_blocked"
|
||
assert any("Claim Ledger" in item for item in result.missing_state)
|
||
|
||
|
||
def test_goal_frame_context_sanitizer_drops_prior_blocked_research_hypotheses() -> None:
|
||
safe = _goal_frame_safe_conversation_context(
|
||
{
|
||
"session_id": 654,
|
||
"last_subject": "最近AI会议行业怎么样",
|
||
"context_capsules": [
|
||
{
|
||
"capsule_type": "pmflow_agent_run",
|
||
"status": "blocked",
|
||
"research_goal": "理解最近三个月内AI会议行业的整体发展状况",
|
||
"agent_workspace": {"current_goal": "围绕 NeurIPS/ICML 搜索"},
|
||
},
|
||
{
|
||
"capsule_type": "external_research",
|
||
"topic": "AI conferences 2026",
|
||
"research_goal": "学术会议行业",
|
||
},
|
||
{
|
||
"capsule_type": "pmflow_ai_object_ledger",
|
||
"current_focus_object": {
|
||
"object_id": "project:1",
|
||
"object_type": "project",
|
||
"display_name": "童析一学龄前儿童行为评估助手",
|
||
"binding_strength": "session_object_ledger",
|
||
},
|
||
},
|
||
],
|
||
},
|
||
question="最近AI会议市场怎么样",
|
||
)
|
||
|
||
rendered = str(safe)
|
||
assert safe["current_user_question"] == "最近AI会议市场怎么样"
|
||
assert "research_goal" not in rendered
|
||
assert "NeurIPS" not in rendered
|
||
assert "external_research" not in rendered
|
||
assert "pmflow_agent_run" not in rendered
|
||
assert safe["context_capsules"][0]["current_focus_object"]["display_name"] == "童析一学龄前儿童行为评估助手"
|
||
|
||
|
||
def test_goal_frame_repairs_compact_modifier_phrase_with_context_product_branch() -> None:
|
||
frame = PMFlowAIAgentGoalFrame(
|
||
user_goal="理解最近XR教育市场怎么样",
|
||
desired_output="给出方向性判断。",
|
||
interpretation_branches=[
|
||
{
|
||
"branch_id": "b_academic",
|
||
"meaning": "围绕 XR 主题举办的教育活动和内容组织生态",
|
||
"confidence": "high",
|
||
"user_language_prior": "high",
|
||
},
|
||
{
|
||
"branch_id": "b_event",
|
||
"meaning": "商业 XR 教育论坛和展会的活动市场",
|
||
"confidence": "medium",
|
||
"user_language_prior": "medium",
|
||
},
|
||
],
|
||
selected_interpretation="围绕 XR 主题举办的教育活动和内容组织生态",
|
||
)
|
||
|
||
repaired = _ensure_compound_phrase_interpretation_branches(frame=frame, question="最近XR教育市场怎么样")
|
||
|
||
assert repaired.interpretation_branches[0]["branch_id"] == "b_context_product_ecosystem"
|
||
assert repaired.interpretation_branches[0]["user_language_prior"] == "high"
|
||
assert "产品、服务和工具生态" in repaired.interpretation_branches[0]["meaning"]
|
||
assert any("XR教育" in item for item in repaired.interpretation_branches[0]["anchor_terms"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_step_planner_expands_external_search_across_interpretation_branches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议行业怎么样")
|
||
workspace.goal_frame = PMFlowAIAgentGoalFrame(
|
||
user_goal="了解最近AI会议行业怎么样",
|
||
desired_output="给出方向性判断并说明边界。",
|
||
interpretation_branches=[
|
||
{
|
||
"branch_id": "b_academic",
|
||
"meaning": "围绕 AI 主题举办的学术会议和投稿组织生态",
|
||
"confidence": "medium",
|
||
"user_language_prior": "medium",
|
||
},
|
||
{
|
||
"branch_id": "b_product",
|
||
"meaning": "由 AI 驱动或改造会议场景的产品、服务和工具生态",
|
||
"confidence": "medium",
|
||
"user_language_prior": "high",
|
||
},
|
||
],
|
||
selected_interpretation="围绕 AI 主题举办的学术会议和投稿组织生态",
|
||
answer_policy="directional_answer",
|
||
source_policy="credible_public_sources",
|
||
freshness_policy="prefer_recent_not_strict",
|
||
missing_evidence_policy="answer_with_boundaries",
|
||
)
|
||
generator = _SequenceGenerator(
|
||
[
|
||
"""
|
||
{
|
||
"action": "tool",
|
||
"tool_name": "external_search",
|
||
"tool_input": {
|
||
"search_queries": ["NeurIPS 2026 conference updates submission"],
|
||
"freshness_required": false
|
||
},
|
||
"rationale": "读取学术会议证据。",
|
||
"expected_observation": "公开来源。",
|
||
"stop_condition": "有证据。",
|
||
"risks_if_answer_now": ["证据不足"],
|
||
"confidence": "high"
|
||
}
|
||
"""
|
||
]
|
||
)
|
||
monkeypatch.setattr(PMFlowAIAgentStepPlanner, "_render_prompt", _fake_agent_prompt_renderer("PROMPT_PMFLOW_AI_AGENT_STEP_PLANNER"))
|
||
|
||
proposal = await PMFlowAIAgentStepPlanner(db=SimpleNamespace(execute=lambda *_args, **_kwargs: None), tenant_id=12, generator=generator).plan(
|
||
question="最近AI会议行业怎么样",
|
||
workspace=workspace,
|
||
conversation_context={},
|
||
tenant_project_context={},
|
||
)
|
||
|
||
queries = proposal.tool_input["search_queries"]
|
||
assert "NeurIPS 2026 conference updates submission" in queries
|
||
assert any("产品、服务和工具生态" in query for query in queries)
|
||
assert any(item.get("branch_id") == "b_product" for item in proposal.tool_input["branch_searches"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_state_critic_does_not_answer_when_high_prior_branch_is_uncovered() -> None:
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议行业怎么样")
|
||
workspace.goal_frame = PMFlowAIAgentGoalFrame(
|
||
user_goal="了解最近AI会议行业怎么样",
|
||
desired_output="给出方向性判断并说明边界。",
|
||
interpretation_branches=[
|
||
{
|
||
"branch_id": "b_academic",
|
||
"meaning": "围绕 AI 主题举办的学术会议和投稿组织生态",
|
||
"confidence": "medium",
|
||
"user_language_prior": "medium",
|
||
},
|
||
{
|
||
"branch_id": "b_product",
|
||
"meaning": "由 AI 驱动或改造会议场景的产品、服务和工具生态",
|
||
"confidence": "medium",
|
||
"user_language_prior": "high",
|
||
},
|
||
],
|
||
answer_policy="directional_answer",
|
||
source_policy="credible_public_sources",
|
||
freshness_policy="prefer_recent_not_strict",
|
||
missing_evidence_policy="answer_with_boundaries",
|
||
)
|
||
workspace.claim_ledger = [
|
||
PMFlowAIAgentClaim(
|
||
claim_type="conference_scope_expansion",
|
||
claim_text="NeurIPS 2026 征稿范围覆盖多个跨学科方向。",
|
||
customer_value="支撑学术会议解释分支。",
|
||
support_refs=["https://neurips.cc/Conferences/2026/CallForPapers"],
|
||
interpretation_branch_id="b_academic",
|
||
relevance_to_user_goal="partial",
|
||
claim_kind="substantive_fact",
|
||
confidence="high",
|
||
)
|
||
]
|
||
generator = _SequenceGenerator(
|
||
[
|
||
"""
|
||
{
|
||
"ready_to_answer": true,
|
||
"should_continue": false,
|
||
"needs_user_input": false,
|
||
"retry_would_change_state": false,
|
||
"state_delta": {"remaining_gaps": []},
|
||
"findings": ["已有 NeurIPS claim"],
|
||
"missing_state": [],
|
||
"recover_or_continue": "compose_answer",
|
||
"next_step_hint": "compose_answer",
|
||
"reason": "已有局部证据。"
|
||
}
|
||
"""
|
||
]
|
||
)
|
||
|
||
result = await PMFlowAIAgentStateCritic(db=object(), tenant_id=12, generator=generator).review(
|
||
question="最近AI会议行业怎么样",
|
||
workspace=workspace,
|
||
proposal=PMFlowAIAgentStepProposal(action="tool", tool_name="external_search"),
|
||
observation=PMFlowAIAgentObservation(
|
||
source="external_search",
|
||
observation_type="external_public_evidence",
|
||
summary="已查询外部公开来源。",
|
||
confidence="medium",
|
||
relevance_to_goal="high",
|
||
),
|
||
)
|
||
|
||
assert result.ready_to_answer is False
|
||
assert result.should_continue is True
|
||
assert result.recover_or_continue == "continue_with_tool"
|
||
assert result.next_step_hint == "select_next_read_tool"
|
||
assert result.retry_would_change_state is True
|
||
assert "产品、服务和工具生态" in result.missing_state[0]
|
||
|
||
|
||
def test_agent_answer_critic_rejects_mechanical_fallback_answer() -> None:
|
||
terminal_state = PMFlowAIAgentTerminalAnswerState(
|
||
user_goal="最近AI会议行业怎么样",
|
||
terminal_status="partially_answerable",
|
||
claims=[
|
||
PMFlowAIAgentClaim(
|
||
claim_type="trend",
|
||
claim_text="会议 AI 产品正在从纪要转向执行工作流。",
|
||
customer_value="支撑方向性判断。",
|
||
confidence="medium",
|
||
)
|
||
],
|
||
)
|
||
|
||
findings = critique_agent_customer_answer(
|
||
terminal_state=terminal_state,
|
||
answer="这轮已有材料能支撑一部分判断:可展示事实已有公开材料支撑,置信度较高;来源包括neurips.cc、openreview.net。。但还有缺口。",
|
||
)
|
||
|
||
assert "mechanical_fallback_answer" in findings
|
||
|
||
|
||
def test_agent_terminal_state_waits_for_evidence_when_critic_vetoes_even_with_claims() -> None:
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议市场怎么样")
|
||
workspace.claim_ledger = [
|
||
PMFlowAIAgentClaim(
|
||
claim_type="external_source_evidence",
|
||
claim_text="某 HR webinar 提到了 AI 管理趋势。",
|
||
customer_value="只能说明一个偏窄材料。",
|
||
support_refs=["https://example.com/hr-webinar"],
|
||
interpretation_branch_id="b_hr",
|
||
relevance_to_user_goal="partial",
|
||
claim_kind="substantive_fact",
|
||
confidence="medium",
|
||
)
|
||
]
|
||
workspace.boundary_ledger = [
|
||
PMFlowAIAgentBoundary(
|
||
boundary_type="source_gap",
|
||
blocked_claim="缺少 AI 会议活动市场主分支证据。",
|
||
customer_safe_reason="现有材料没有覆盖用户真正问的 AI 会议市场。",
|
||
allowed_alternative="继续读取更贴近会议活动市场的公开来源。",
|
||
)
|
||
]
|
||
|
||
terminal_state = build_agent_terminal_answer_state(
|
||
question="最近AI会议市场怎么样",
|
||
workspace=workspace,
|
||
proposal=PMFlowAIAgentStepProposal(action="tool", tool_name="external_search"),
|
||
observation=None,
|
||
critic_result=PMFlowAIAgentCriticResult(
|
||
ready_to_answer=False,
|
||
should_continue=True,
|
||
retry_would_change_state=True,
|
||
state_delta={"remaining_gaps": ["主分支证据不足"]},
|
||
),
|
||
)
|
||
|
||
assert terminal_state.terminal_status == "waiting_for_evidence"
|
||
assert terminal_state.answer_intent_plan.lead_claim_id == ""
|
||
assert terminal_state.answer_intent_plan.lead_boundary_id
|
||
|
||
|
||
def test_recovery_query_diversifies_when_existing_claim_is_off_branch() -> None:
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议市场怎么样")
|
||
workspace.goal_frame = PMFlowAIAgentGoalFrame(
|
||
user_goal="最近AI会议市场怎么样",
|
||
desired_output="判断 AI 会议活动市场趋势。",
|
||
evidence_requirements=["参会规模", "赞助需求", "会议活动供给"],
|
||
interpretation_branches=[
|
||
{
|
||
"branch_id": "b_activity",
|
||
"meaning": "AI 会议活动市场和峰会生态",
|
||
"user_language_prior": "high",
|
||
"anchor_terms": ["AI conference", "summit", "attendance", "sponsorship"],
|
||
},
|
||
{
|
||
"branch_id": "b_hr",
|
||
"meaning": "HR 领域 AI webinar",
|
||
"user_language_prior": "low",
|
||
"anchor_terms": ["HR", "talent acquisition"],
|
||
},
|
||
],
|
||
)
|
||
workspace.claim_ledger = [
|
||
PMFlowAIAgentClaim(
|
||
claim_type="external_source_evidence",
|
||
claim_text="HR webinar 讨论了 AI 对人才招聘的影响。",
|
||
interpretation_branch_id="b_hr",
|
||
relevance_to_user_goal="partial",
|
||
claim_kind="substantive_fact",
|
||
confidence="medium",
|
||
)
|
||
]
|
||
|
||
proposal = _recovery_proposal_from_critic(
|
||
question="最近AI会议市场怎么样",
|
||
workspace=workspace,
|
||
latest_observation=PMFlowAIAgentObservation(
|
||
source="inspect_source_detail",
|
||
observation_type="external_source_document",
|
||
summary="已读取偏题来源正文。",
|
||
confidence="medium",
|
||
relevance_to_goal="medium",
|
||
),
|
||
critic_result=PMFlowAIAgentCriticResult(
|
||
ready_to_answer=False,
|
||
should_continue=True,
|
||
retry_would_change_state=True,
|
||
state_delta={"remaining_gaps": ["主分支 AI 会议活动市场缺少参会和赞助证据"]},
|
||
missing_state=["AI 会议活动市场主分支未覆盖"],
|
||
),
|
||
tool_call_count=2,
|
||
run_budget=SimpleNamespace(max_tool_calls=8),
|
||
)
|
||
|
||
assert proposal is not None
|
||
assert proposal.tool_name == "external_search"
|
||
queries = proposal.tool_input["search_queries"]
|
||
assert len(queries) >= 2
|
||
assert any("AI 会议活动市场和峰会生态" in query for query in queries)
|
||
assert any("参会规模" in query or "赞助需求" in query for query in queries)
|
||
assert proposal.tool_input["result_limit"] == 8
|
||
|
||
|
||
def test_operating_claim_projection_ignores_claims_when_terminal_state_waits_for_evidence() -> None:
|
||
workspace = {
|
||
"claim_ledger": [
|
||
{
|
||
"claim_type": "external_source_evidence",
|
||
"claim_text": "HR webinar 讨论了 AI 对人才招聘的影响。",
|
||
"confidence": "medium",
|
||
}
|
||
]
|
||
}
|
||
deliverable = {
|
||
"sections": [
|
||
{
|
||
"section_kind": "claims",
|
||
"items": [{"summary": "HR webinar 讨论了 AI 对人才招聘的影响。"}],
|
||
}
|
||
]
|
||
}
|
||
|
||
claims = _claim_texts(workspace, deliverable, terminal_state={"terminal_status": "waiting_for_evidence"})
|
||
|
||
assert claims == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_external_planner_defaults_to_non_strict_freshness_gate() -> None:
|
||
planner = _AgentRuntimeExternalPlanner(
|
||
query="最近AI会议行业怎么样",
|
||
tool_input={"search_queries": ["AI meeting assistant industry trends"]},
|
||
)
|
||
|
||
plan = await planner.build_plan(question="最近AI会议行业怎么样")
|
||
|
||
assert plan.freshness_required is False
|
||
assert plan.search_queries == ["AI meeting assistant industry trends"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_external_planner_can_request_broader_bounded_result_limit() -> None:
|
||
planner = _AgentRuntimeExternalPlanner(
|
||
query="开放行业研究",
|
||
tool_input={
|
||
"search_queries": [f"query {index}" for index in range(8)],
|
||
"result_limit": 10,
|
||
},
|
||
)
|
||
|
||
plan = await planner.build_plan(question="开放行业研究")
|
||
|
||
assert len(plan.search_queries) == 8
|
||
assert plan.result_limit == 10
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_answer_runtime_revises_bad_draft_before_terminal_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议行业怎么样")
|
||
workspace.boundary_ledger = [
|
||
PMFlowAIAgentBoundary(
|
||
boundary_type="source_gap",
|
||
blocked_claim="缺少会议行业垂直公开来源。",
|
||
customer_safe_reason="这轮查到的材料偏向泛 AI 行业,不能直接说明 AI 会议行业最近的发展。",
|
||
allowed_alternative="继续换用会议技术、活动平台和标准组织来源补证据。",
|
||
)
|
||
]
|
||
generator = _SequenceGenerator(
|
||
[
|
||
"Refine search using terms like 'AI conference platform update' or 'hybrid meeting AI feature launch'.",
|
||
"目前还不能可靠判断最近 AI 会议行业怎么样。已查到的材料偏向泛 AI 行业,不能直接说明会议与活动技术场景的最近变化;下一步需要换用会议技术、活动平台和标准组织来源补证据。",
|
||
]
|
||
)
|
||
monkeypatch.setattr(PMFlowAIAgentAnswerRuntime, "_render_prompt", _fake_agent_prompt_renderer("PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER"))
|
||
monkeypatch.setattr(PMFlowAIAgentAnswerRuntime, "_render_reviser_prompt", _fake_agent_prompt_renderer("PROMPT_PMFLOW_AI_AGENT_ANSWER_REVISER"))
|
||
|
||
result = await PMFlowAIAgentAnswerRuntime(db=SimpleNamespace(execute=lambda *_args, **_kwargs: None), tenant_id=12, generator=generator).run(
|
||
question="最近AI会议行业怎么样",
|
||
workspace=workspace,
|
||
proposal=PMFlowAIAgentStepProposal(action="stop_blocked", rationale="停止"),
|
||
observation=None,
|
||
critic_result=PMFlowAIAgentCriticResult(
|
||
ready_to_answer=False,
|
||
should_continue=True,
|
||
retry_would_change_state=False,
|
||
reason="claim_ledger 为空,需要换会议行业垂直来源。",
|
||
),
|
||
)
|
||
|
||
assert generator.calls == 2
|
||
assert result.answer_runtime_packet.draft.source == "llm_reviser"
|
||
assert result.quality_findings == []
|
||
assert "目前还不能可靠判断最近 AI 会议行业怎么样" in result.answer
|
||
assert "Refine search" not in result.answer
|
||
|
||
|
||
def test_agent_deliverable_actions_do_not_expose_planner_rationale() -> None:
|
||
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI行业怎么样")
|
||
workspace.claim_ledger = [
|
||
PMFlowAIAgentClaim(
|
||
claim_type="market_trend",
|
||
claim_text="公开材料显示市场投入仍在增加。",
|
||
support_refs=["公开来源"],
|
||
confidence="medium",
|
||
)
|
||
]
|
||
terminal_state = build_agent_terminal_answer_state(
|
||
question="最近AI行业怎么样",
|
||
workspace=workspace,
|
||
proposal=PMFlowAIAgentStepProposal(
|
||
action="tool",
|
||
tool_name="external_search",
|
||
rationale="User goal requires recent, authoritative, external public information on AI industry status.",
|
||
),
|
||
observation=None,
|
||
critic_result=PMFlowAIAgentCriticResult(
|
||
ready_to_answer=False,
|
||
should_continue=False,
|
||
retry_would_change_state=False,
|
||
reason="已有部分 claim。",
|
||
),
|
||
)
|
||
|
||
result_text = render_agent_terminal_state_fallback(terminal_state)
|
||
|
||
assert terminal_state.terminal_status == "partially_answerable"
|
||
assert "User goal requires" not in result_text
|
||
assert "external_search" not in result_text
|
||
assert all("User goal requires" not in item for item in terminal_state.next_possible_actions)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_runtime_v2_stops_repeated_tool_without_state_delta() -> None:
|
||
payload = await PMFlowAIAgentRunService(
|
||
object(),
|
||
tenant_id=12,
|
||
planner=_SequencePlanner(["inspect_space_collections", "inspect_space_collections"]),
|
||
tool_registry=_FakeToolRegistry(),
|
||
critic=_SequenceCritic(
|
||
[
|
||
PMFlowAIAgentCriticResult(
|
||
ready_to_answer=False,
|
||
should_continue=True,
|
||
state_delta={},
|
||
reason="还要继续。",
|
||
),
|
||
PMFlowAIAgentCriticResult(
|
||
ready_to_answer=False,
|
||
should_continue=True,
|
||
state_delta={},
|
||
reason="不应该执行到第二次 critic。",
|
||
),
|
||
]
|
||
),
|
||
answer_runtime=PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()),
|
||
).run_turn(
|
||
request=RAGAskRequest(question="现在有什么项目"),
|
||
current_user=SimpleNamespace(id=7, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
|
||
started_at=None,
|
||
)
|
||
|
||
assert payload["agent_run_packet"]["stop_reason"] == "repeated_action_without_state_delta"
|
||
assert payload["deliverable_packet"]["deliverable_kind"] == "blocked_state"
|
||
assert payload["deliverable_packet"]["status"] == "blocked"
|
||
assert payload["agent_workspace"]["deliverable_state"]["deliverable_kind"] == payload["deliverable_packet"]["deliverable_kind"]
|
||
assert payload["agent_run_packet"]["iterations"] == 2
|
||
assert payload["agent_run_packet"]["tool_call_count"] == 1
|
||
assert payload["agent_run_packet"]["event_log"][-2]["stop_reason"] == "repeated_action_without_state_delta"
|
||
assert [step["key"] for step in payload["answer_process_packet"]["steps"]].count("agent_tool_inspect_space_collections") == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_runtime_project_status_without_anchor_returns_world_state_scope_gap() -> None:
|
||
observation = await PMFlowAIAgentToolRegistry(db=object(), tenant_id=12).execute(
|
||
proposal=PMFlowAIAgentStepProposal(
|
||
action="tool",
|
||
tool_name="inspect_project_status",
|
||
tool_input={"query": "list_all"},
|
||
),
|
||
request=RAGAskRequest(question="现在有什么项目"),
|
||
workspace=workspace_from_conversation_context(question="现在有什么项目", conversation_context={}),
|
||
current_user=SimpleNamespace(id=38, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={
|
||
"surface_context": {
|
||
"workbench": {
|
||
"recent_projects": [
|
||
{
|
||
"id": 141,
|
||
"title": "童析—学龄前儿童行为特质AI评估助手",
|
||
"status": "active",
|
||
}
|
||
]
|
||
}
|
||
}
|
||
},
|
||
)
|
||
|
||
assert observation.observation_type == "project_world_state_missing_scope"
|
||
assert "项目数量" in observation.summary or "不能读取项目世界状态" in observation.summary
|
||
assert any(fact.get("type") == "project_item" and fact.get("project_ref") == "project:141" for fact in observation.facts)
|
||
assert not any(fact.get("type") == "project_world_state" for fact in observation.facts)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_runtime_project_status_consumes_planner_project_ref() -> None:
|
||
observation = await PMFlowAIAgentToolRegistry(db=object(), tenant_id=12).execute(
|
||
proposal=PMFlowAIAgentStepProposal(
|
||
action="tool",
|
||
tool_name="inspect_project_status",
|
||
tool_input={"query": "project:141"},
|
||
),
|
||
request=RAGAskRequest(question="儿童的那个项目你知道吗"),
|
||
workspace=workspace_from_conversation_context(question="儿童的那个项目你知道吗", conversation_context={}),
|
||
current_user=SimpleNamespace(id=38, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={
|
||
"surface_context": {
|
||
"workbench": {
|
||
"recent_projects": [
|
||
{
|
||
"id": 141,
|
||
"title": "童析—学龄前儿童行为特质AI评估助手",
|
||
"status": "active",
|
||
}
|
||
]
|
||
}
|
||
}
|
||
},
|
||
)
|
||
|
||
assert observation.observation_type == "project_world_state"
|
||
assert "童析—学龄前儿童行为特质AI评估助手" in observation.summary
|
||
assert any(fact.get("type") == "project_anchor" and fact.get("project_ref") == "project:141" for fact in observation.facts)
|
||
assert any(fact.get("type") == "project_world_state" and fact.get("project_refs") == ["project:141"] for fact in observation.facts)
|
||
assert any(fact.get("type") == "project_world_facet" for fact in observation.facts)
|
||
assert any(fact.get("type") == "agent_tool_contract" and fact.get("tool_name") == "inspect_project_operating_state" for fact in observation.facts)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_runtime_project_status_does_not_use_visible_collection_as_operating_state() -> None:
|
||
observation = await PMFlowAIAgentToolRegistry(db=object(), tenant_id=12).execute(
|
||
proposal=PMFlowAIAgentStepProposal(
|
||
action="tool",
|
||
tool_name="inspect_project_status",
|
||
tool_input={},
|
||
),
|
||
request=RAGAskRequest(question="现在有哪些项目"),
|
||
workspace=workspace_from_conversation_context(question="现在有哪些项目", conversation_context={}),
|
||
current_user=SimpleNamespace(id=38, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={
|
||
"surface_context": {
|
||
"workbench": {
|
||
"recent_projects": [
|
||
{
|
||
"id": 141,
|
||
"title": "童析—学龄前儿童行为特质AI评估助手",
|
||
"status": "active",
|
||
}
|
||
]
|
||
}
|
||
}
|
||
},
|
||
)
|
||
|
||
assert observation.observation_type == "project_world_state_missing_scope"
|
||
assert any(fact.get("type") == "project_world_state_gap" for fact in observation.facts)
|
||
assert not any(fact.get("type") == "project_world_state" for fact in observation.facts)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_tool_environment_resolves_explicit_customer_object() -> None:
|
||
observation = await PMFlowAIAgentToolRegistry(db=object(), tenant_id=12).execute(
|
||
proposal=PMFlowAIAgentStepProposal(
|
||
action="tool",
|
||
tool_name="resolve_customer_object",
|
||
tool_input={"object_ref": "project:141"},
|
||
),
|
||
request=RAGAskRequest(question="儿童的那个项目你知道吗"),
|
||
workspace=workspace_from_conversation_context(question="儿童的那个项目你知道吗", conversation_context={}),
|
||
current_user=SimpleNamespace(id=38, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={
|
||
"surface_context": {
|
||
"workbench": {
|
||
"recent_projects": [
|
||
{
|
||
"id": 141,
|
||
"title": "童析—学龄前儿童行为特质AI评估助手",
|
||
"status": "active",
|
||
}
|
||
]
|
||
}
|
||
}
|
||
},
|
||
)
|
||
|
||
assert observation.observation_type == "object_resolution"
|
||
assert any(fact.get("type") == "resolved_object" and fact.get("object_ref") == "project:141" for fact in observation.facts)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_agent_tool_environment_returns_candidates_without_auto_binding() -> None:
|
||
observation = await PMFlowAIAgentToolRegistry(db=object(), tenant_id=12).execute(
|
||
proposal=PMFlowAIAgentStepProposal(
|
||
action="tool",
|
||
tool_name="resolve_customer_object",
|
||
tool_input={},
|
||
),
|
||
request=RAGAskRequest(question="它现在怎么样"),
|
||
workspace=workspace_from_conversation_context(question="它现在怎么样", conversation_context={}),
|
||
current_user=SimpleNamespace(id=38, tenant_id=12),
|
||
conversation_context={},
|
||
tenant_project_context={
|
||
"surface_context": {
|
||
"workbench": {
|
||
"recent_projects": [
|
||
{
|
||
"id": 141,
|
||
"title": "童析—学龄前儿童行为特质AI评估助手",
|
||
"status": "active",
|
||
}
|
||
]
|
||
}
|
||
}
|
||
},
|
||
)
|
||
|
||
assert observation.observation_type == "object_candidates"
|
||
assert "尚未自动绑定单一对象" in observation.summary
|
||
assert any(fact.get("type") == "object_candidate" and fact.get("binding_strength") == "candidate" for fact in observation.facts)
|
||
|
||
|
||
def test_agent_runtime_step4_restores_workspace_from_capsule_and_updates_current_goal() -> None:
|
||
first = workspace_from_conversation_context(
|
||
question="先分析 AI 采购监督系统",
|
||
conversation_context={},
|
||
)
|
||
first.known_facts = [{"type": "external_evidence", "title": "公开材料"}]
|
||
capsules = upsert_agent_workspace_capsule(capsules=[], workspace=first)
|
||
|
||
restored = agent_workspace_from_context_capsules(capsules)
|
||
assert restored["workspace_id"] == first.workspace_id
|
||
assert restored["known_facts"][0]["title"] == "公开材料"
|
||
|
||
second = workspace_from_conversation_context(
|
||
question="继续看落地风险",
|
||
conversation_context={"context_capsules": capsules},
|
||
)
|
||
|
||
assert second.workspace_id == first.workspace_id
|
||
assert second.current_goal == "继续看落地风险"
|
||
assert "先分析 AI 采购监督系统" in second.goal_history
|
||
assert second.fact_ledger[0].fact_text == "公开材料"
|
||
next_capsules = upsert_agent_workspace_capsule(capsules=capsules, workspace=second)
|
||
next_workspace_capsule = agent_workspace_from_context_capsules(next_capsules)
|
||
assert next_workspace_capsule["fact_ledger"][0]["fact_text"] == "公开材料"
|
||
|
||
|
||
class _FakePlanner:
|
||
def __init__(self, tool_name: str) -> None:
|
||
self.tool_name = tool_name
|
||
|
||
async def plan(self, **kwargs):
|
||
return PMFlowAIAgentStepProposal(
|
||
action="tool",
|
||
tool_name=self.tool_name,
|
||
tool_input={"query": kwargs.get("question", ""), "freshness_required": True},
|
||
rationale="读取下一步所需证据。",
|
||
expected_observation="客户安全观察。",
|
||
confidence="medium",
|
||
)
|
||
|
||
|
||
class _SequencePlanner:
|
||
def __init__(self, tool_names: list[str]) -> None:
|
||
self.tool_names = list(tool_names)
|
||
self.index = 0
|
||
|
||
async def plan(self, **kwargs):
|
||
tool_name = self.tool_names[min(self.index, len(self.tool_names) - 1)]
|
||
self.index += 1
|
||
return PMFlowAIAgentStepProposal(
|
||
action="tool",
|
||
tool_name=tool_name,
|
||
tool_input={"query": kwargs.get("question", ""), "freshness_required": True},
|
||
rationale=f"执行 {tool_name} 读取证据。",
|
||
expected_observation="客户安全观察。",
|
||
confidence="medium",
|
||
)
|
||
|
||
|
||
class _FakeToolRegistry:
|
||
async def execute(self, *, proposal, **kwargs):
|
||
if proposal.tool_name == "external_search":
|
||
return PMFlowAIAgentObservation(
|
||
source="external_search",
|
||
observation_type="external_public_evidence",
|
||
summary="已查询外部公开来源,得到 1 条可用证据。",
|
||
facts=[{"type": "external_evidence", "title": "公开资料"}],
|
||
sources=[{"label": "公开来源", "url": "https://example.com/source"}],
|
||
confidence="high",
|
||
relevance_to_goal="high",
|
||
)
|
||
if proposal.tool_name == "inspect_source_detail":
|
||
return PMFlowAIAgentObservation(
|
||
source="inspect_source_detail",
|
||
observation_type="external_source_document",
|
||
summary="已读取公开来源正文。",
|
||
facts=[
|
||
{
|
||
"type": "external_source_text",
|
||
"title": "公开来源",
|
||
"url": "https://example.com/source",
|
||
"text": "公开来源正文:该方向已有公开材料支撑,市场需求和应用探索正在增加。",
|
||
}
|
||
],
|
||
sources=[{"label": "公开来源", "url": "https://example.com/source"}],
|
||
confidence="medium",
|
||
relevance_to_goal="high",
|
||
)
|
||
return PMFlowAIAgentObservation(
|
||
source="inspect_space_collections",
|
||
observation_type="space_context",
|
||
summary="已读取客户安全的空间上下文摘要。",
|
||
facts=[{"type": "surface_collection_count", "collection": "projects", "count": 1}],
|
||
confidence="medium",
|
||
relevance_to_goal="medium",
|
||
)
|
||
|
||
|
||
class _NoisyExternalToolRegistry:
|
||
async def execute(self, *, proposal, **kwargs):
|
||
if proposal.tool_name == "external_search":
|
||
return PMFlowAIAgentObservation(
|
||
source="external_search",
|
||
observation_type="external_public_evidence",
|
||
summary="已查询外部公开来源,得到 1 条候选材料。",
|
||
facts=[
|
||
{
|
||
"type": "external_evidence",
|
||
"title": "行业报告页面",
|
||
"summary": (
|
||
"行业报告页面 联系我们 订阅邮箱 020-8922-4700 news@iimedia.cn "
|
||
"  正文片段 导航 市场地位 商业趋势 网站投稿"
|
||
),
|
||
"url": "https://example.com/noisy",
|
||
}
|
||
],
|
||
sources=[{"label": "行业报告页面", "url": "https://example.com/noisy"}],
|
||
confidence="high",
|
||
relevance_to_goal="high",
|
||
)
|
||
if proposal.tool_name == "inspect_source_detail":
|
||
return PMFlowAIAgentObservation(
|
||
source="inspect_source_detail",
|
||
observation_type="external_source_document",
|
||
summary="已读取公开来源正文。",
|
||
facts=[
|
||
{
|
||
"type": "external_source_text",
|
||
"title": "行业报告页面",
|
||
"url": "https://example.com/noisy",
|
||
"text": (
|
||
"联系我们 订阅邮箱 020-8922-4700 news@iimedia.cn    "
|
||
"网站投稿 媒体合作 导航 首页 报告定制 行业白皮书"
|
||
),
|
||
}
|
||
],
|
||
sources=[{"label": "行业报告页面", "url": "https://example.com/noisy"}],
|
||
confidence="medium",
|
||
relevance_to_goal="medium",
|
||
)
|
||
return PMFlowAIAgentObservation(
|
||
source="inspect_source_detail",
|
||
observation_type="source_detail_missing_url",
|
||
summary="外部来源读取缺少可执行 URL,本轮未读取网页正文。",
|
||
facts=[{"type": "missing_external_source_url", "user_actionable": False}],
|
||
confidence="medium",
|
||
relevance_to_goal="medium",
|
||
)
|
||
|
||
|
||
class _RecoveryExternalToolRegistry:
|
||
def __init__(self) -> None:
|
||
self.inspect_calls: list[list[str]] = []
|
||
|
||
async def execute(self, *, proposal, **kwargs):
|
||
if proposal.tool_name == "external_search":
|
||
return PMFlowAIAgentObservation(
|
||
source="external_search",
|
||
observation_type="external_public_evidence",
|
||
summary="已查询外部公开来源,得到 3 条候选材料。",
|
||
facts=[
|
||
{
|
||
"type": "external_evidence",
|
||
"title": "AI market report",
|
||
"summary": "Broad artificial intelligence market size report.",
|
||
"url": "https://example.com/broad-ai-market",
|
||
},
|
||
{
|
||
"type": "external_evidence",
|
||
"title": "AI conference attendance and sponsorship 2026",
|
||
"summary": "Conference attendance, sponsorship and event market signals.",
|
||
"url": "https://example.com/ai-conference-attendance-2026",
|
||
},
|
||
{
|
||
"type": "external_evidence",
|
||
"title": "AI event technology alternatives",
|
||
"summary": "Event tech, community events and conference software alternatives.",
|
||
"url": "https://example.com/ai-event-technology",
|
||
},
|
||
],
|
||
sources=[
|
||
{"label": "AI market report", "url": "https://example.com/broad-ai-market"},
|
||
{"label": "AI conference attendance and sponsorship 2026", "url": "https://example.com/ai-conference-attendance-2026"},
|
||
{"label": "AI event technology alternatives", "url": "https://example.com/ai-event-technology"},
|
||
],
|
||
confidence="high",
|
||
relevance_to_goal="high",
|
||
)
|
||
if proposal.tool_name == "inspect_source_detail":
|
||
urls = [str(item) for item in list(proposal.tool_input.get("urls") or [])]
|
||
self.inspect_calls.append(urls)
|
||
if len(self.inspect_calls) == 1:
|
||
return PMFlowAIAgentObservation(
|
||
source="inspect_source_detail",
|
||
observation_type="source_detail_failed",
|
||
summary="第一个来源不可读。",
|
||
facts=[
|
||
{
|
||
"type": "external_source_unreadable",
|
||
"url": urls[0] if urls else "https://example.com/broad-ai-market",
|
||
"summary": "403 Forbidden",
|
||
}
|
||
],
|
||
confidence="unknown",
|
||
relevance_to_goal="medium",
|
||
)
|
||
url = urls[0] if urls else "https://example.com/ai-conference-attendance-2026"
|
||
return PMFlowAIAgentObservation(
|
||
source="inspect_source_detail",
|
||
observation_type="external_source_document",
|
||
summary="补读了未尝试来源正文。",
|
||
facts=[
|
||
{
|
||
"type": "external_source_text",
|
||
"title": "AI conference attendance and sponsorship 2026",
|
||
"url": url,
|
||
"text": "AI conferences are showing stronger attendance, sponsorship demand and event tooling needs.",
|
||
}
|
||
],
|
||
sources=[{"label": "AI conference attendance and sponsorship 2026", "url": url}],
|
||
confidence="medium",
|
||
relevance_to_goal="high",
|
||
)
|
||
return PMFlowAIAgentObservation(
|
||
source="inspect_space_collections",
|
||
observation_type="space_context",
|
||
summary="已读取空间上下文。",
|
||
facts=[],
|
||
)
|
||
|
||
|
||
class _CandidateClaimRecoveryToolRegistry:
|
||
async def execute(self, *, proposal, **kwargs):
|
||
if proposal.tool_name == "external_search":
|
||
return PMFlowAIAgentObservation(
|
||
source="external_search",
|
||
observation_type="external_public_evidence",
|
||
summary="已查询外部公开来源,得到候选材料。",
|
||
facts=[
|
||
{
|
||
"type": "external_evidence",
|
||
"title": "AI market report",
|
||
"summary": "Broad artificial intelligence market size report.",
|
||
"url": "https://example.com/broad-ai-market",
|
||
}
|
||
],
|
||
sources=[{"label": "AI market report", "url": "https://example.com/broad-ai-market"}],
|
||
confidence="high",
|
||
relevance_to_goal="medium",
|
||
)
|
||
if proposal.tool_name == "inspect_source_detail":
|
||
return PMFlowAIAgentObservation(
|
||
source="inspect_source_detail",
|
||
observation_type="external_source_document",
|
||
summary="读取到候选正文,但仍没有可入账事实。",
|
||
facts=[
|
||
{
|
||
"type": "external_source_text",
|
||
"title": "AI market report",
|
||
"url": "https://example.com/broad-ai-market",
|
||
"text": "Broad artificial intelligence market size report.",
|
||
}
|
||
],
|
||
sources=[{"label": "AI market report", "url": "https://example.com/broad-ai-market"}],
|
||
confidence="medium",
|
||
relevance_to_goal="medium",
|
||
)
|
||
return PMFlowAIAgentObservation(
|
||
source="inspect_space_collections",
|
||
observation_type="space_context",
|
||
summary="已读取空间上下文。",
|
||
facts=[],
|
||
)
|
||
|
||
|
||
class _FakeCritic:
|
||
def __init__(self, *, ready: bool) -> None:
|
||
self.ready = ready
|
||
|
||
async def review(self, **kwargs):
|
||
return PMFlowAIAgentCriticResult(
|
||
ready_to_answer=self.ready,
|
||
should_continue=not self.ready,
|
||
state_delta={"test": True},
|
||
findings=["状态已检查。"],
|
||
next_step_hint="compose_answer" if self.ready else "select_next_read_tool",
|
||
reason="状态检查完成。",
|
||
)
|
||
|
||
|
||
class _SequenceCritic:
|
||
def __init__(self, results: list[PMFlowAIAgentCriticResult]) -> None:
|
||
self.results = list(results)
|
||
self.index = 0
|
||
|
||
async def review(self, **kwargs):
|
||
result = self.results[min(self.index, len(self.results) - 1)]
|
||
self.index += 1
|
||
return result
|
||
|
||
|
||
class _SequenceGenerator:
|
||
def __init__(self, responses: list[str]) -> None:
|
||
self.responses = list(responses)
|
||
self.calls = 0
|
||
|
||
async def generate(self, *args, **kwargs) -> str:
|
||
index = min(self.calls, len(self.responses) - 1)
|
||
self.calls += 1
|
||
return self.responses[index]
|
||
|
||
|
||
class _CandidateOnlyNormalizer:
|
||
async def normalize(self, **kwargs):
|
||
observation = kwargs.get("observation")
|
||
return PMFlowAIAgentEvidenceNormalization(
|
||
source_observation_id=getattr(observation, "observation_id", None),
|
||
source_quality="noisy",
|
||
usable_claims=[
|
||
PMFlowAIAgentClaim(
|
||
claim_type="external_source_candidate",
|
||
claim_kind="source_candidate",
|
||
claim_text="候选外部材料可继续读取,但还不能作为业务事实陈述。",
|
||
allowed_to_state=True,
|
||
)
|
||
],
|
||
gaps=["needs_conference_specific_sources"],
|
||
rejected_reason="source_candidate_without_substantive_claim",
|
||
)
|
||
|
||
|
||
def _fake_agent_prompt_renderer(prompt_key: str):
|
||
async def _render(self, **kwargs):
|
||
return PromptRuntimeRenderResult(
|
||
prompt=f"{prompt_key} test prompt",
|
||
prompt_key=prompt_key,
|
||
source="unit_test",
|
||
runtime_tenant_id=12,
|
||
)
|
||
|
||
return _render
|
||
|
||
|
||
class _FakeProjectWorldStateService:
|
||
def __init__(self, *, blocked_actions: list[dict[str, object]] | None = None) -> None:
|
||
self.blocked_actions = list(blocked_actions or [])
|
||
|
||
async def build_packet(self, **kwargs):
|
||
return PMFlowAIProjectWorldStatePacket(
|
||
primary_project_ref="project:141",
|
||
project_refs=["project:141"],
|
||
status="ready",
|
||
facets=[
|
||
PMFlowAIProjectWorldFacet(
|
||
facet_key="project_identity",
|
||
display_label="项目身份与目标",
|
||
status="ready",
|
||
summary="项目身份与目标 已读取:project_title: 童析;loop_stage: command_idle",
|
||
facts=[{"type": "world_state_fact", "key": "project_title", "value": "童析"}],
|
||
evidence_refs=["project:141:project_identity"],
|
||
)
|
||
],
|
||
project_operating_evidence_packets=[
|
||
{
|
||
"packet_version": "project_operating_evidence.v1",
|
||
"project_ref": "project:141",
|
||
"project_title": "童析",
|
||
"safe_next_actions": [
|
||
{
|
||
"display_label": "先处理当前任务阻塞",
|
||
"target_ref": "project:141",
|
||
}
|
||
],
|
||
"blocked_actions": self.blocked_actions,
|
||
}
|
||
],
|
||
evidence_ledger=[
|
||
PMFlowAIEvidenceLedgerEntry(
|
||
evidence_id="evd_test",
|
||
project_ref="project:141",
|
||
facet_key="project_identity",
|
||
source_ref="project:141:project_identity",
|
||
summary="项目身份与目标 已读取。",
|
||
confidence="high",
|
||
)
|
||
],
|
||
coverage={"coverage_score": 1.0, "covered_facets": ["project_identity"], "partial_facets": [], "missing_facets": []},
|
||
)
|
||
|
||
|
||
class _FakeOpenResearchProjectWorldStateService:
|
||
async def build_packet(self, **kwargs):
|
||
return PMFlowAIProjectWorldStatePacket(
|
||
primary_project_ref="",
|
||
project_refs=[],
|
||
status="partial",
|
||
facets=[],
|
||
evidence_ledger=[
|
||
PMFlowAIEvidenceLedgerEntry(
|
||
evidence_id="evd_open_research_seed",
|
||
project_ref="space:research",
|
||
facet_key="research_scope",
|
||
source_ref="user_question",
|
||
summary="用户提出开放市场研究问题。",
|
||
confidence="medium",
|
||
)
|
||
],
|
||
coverage={
|
||
"coverage_score": 0.1,
|
||
"covered_facets": [],
|
||
"partial_facets": [],
|
||
"missing_facets": ["external_research_evidence"],
|
||
},
|
||
remaining_gaps=["external_public_sources_need_agent_reading"],
|
||
)
|
||
|
||
|
||
class _FakeDB:
|
||
def __init__(self) -> None:
|
||
self.items = []
|
||
self._next_id = 1
|
||
|
||
def add(self, item) -> None:
|
||
self.items.append(item)
|
||
|
||
async def flush(self) -> None:
|
||
for item in self.items:
|
||
if getattr(item, "id", None) is None:
|
||
item.id = self._next_id
|
||
self._next_id += 1
|
||
|
||
|
||
class _RecordingAgentOSToolRegistry:
|
||
def __init__(self) -> None:
|
||
self.calls: list[dict[str, object]] = []
|
||
|
||
async def execute(self, *, proposal, **kwargs):
|
||
self.calls.append({"tool_name": proposal.tool_name, "tool_input": dict(proposal.tool_input or {})})
|
||
if proposal.tool_name == "inspect_project_operating_state":
|
||
return PMFlowAIAgentObservation(
|
||
source="inspect_project_operating_state",
|
||
observation_type="project_world_state",
|
||
summary="已读取项目世界状态。",
|
||
facts=[{"type": "project_world_state", "project_refs": ["project:141"], "status": "ready"}],
|
||
confidence="high",
|
||
relevance_to_goal="high",
|
||
)
|
||
return PMFlowAIAgentObservation(
|
||
source="inspect_space_collections",
|
||
observation_type="space_context",
|
||
summary="已读取当前空间集合。",
|
||
facts=[{"type": "surface_collection_count", "collection": "projects", "count": 1}],
|
||
confidence="medium",
|
||
relevance_to_goal="medium",
|
||
)
|
||
|
||
|
||
class _FakeDeliveryRuntime:
|
||
async def run_turn(self, **kwargs):
|
||
return {
|
||
"answer": "项目当前需要先收口核心验证,再准备可确认的下一步。",
|
||
"reply_text": "项目当前需要先收口核心验证,再准备可确认的下一步。",
|
||
"processing_stage": "agent_runtime",
|
||
"answer_mode": "agent_runtime",
|
||
"grounding_status": "grounded",
|
||
"answer_canvas": {
|
||
"action_candidates": [
|
||
{
|
||
"title": "准备本周项目推进计划",
|
||
"summary": "进入项目工作台准备本周推进计划。",
|
||
"href": "/pmflow-os?projectId=141",
|
||
"action_type": "suggest",
|
||
"requires_user_confirm": False,
|
||
}
|
||
],
|
||
"next_actions": [],
|
||
},
|
||
"deliverable_packet": {
|
||
"summary": "项目当前需要先收口核心验证,再准备可确认的下一步。",
|
||
"blocked_reasons": ["真实写入项目记忆前需要用户确认。"],
|
||
},
|
||
"terminal_answer_state": {
|
||
"claims": [
|
||
{
|
||
"claim_text": "项目当前需要先收口核心验证。",
|
||
"confidence": "medium",
|
||
}
|
||
]
|
||
},
|
||
"customer_interaction_context_packet": {
|
||
"current_focus_object": {
|
||
"object_ref": "project:141",
|
||
"object_type": "project",
|
||
"object_id": 141,
|
||
"title": "童析—学龄前儿童行为特质AI评估助手",
|
||
}
|
||
},
|
||
"agent_run_packet": {
|
||
"run_id": "arun_test",
|
||
"observations": [{"observation_id": "obs_test"}],
|
||
},
|
||
"answer_process_packet": {
|
||
"packet_version": "customer_answer_process.v1",
|
||
"steps": [{"key": "agent_answer_runtime", "label": "生成回答", "status": "completed"}],
|
||
"total_duration_ms": 10,
|
||
},
|
||
}
|
||
|
||
|
||
class _FakeOpenResearchDeliveryRuntime:
|
||
async def run_turn(self, **kwargs):
|
||
answer = "这轮先进入研究补证,不能把候选来源包装成市场结论。"
|
||
return {
|
||
"answer": answer,
|
||
"reply_text": answer,
|
||
"processing_stage": "agent_runtime",
|
||
"answer_mode": "agent_runtime",
|
||
"grounding_status": "partial",
|
||
"agent_os_observations": [
|
||
{
|
||
"observation_id": "obs_external_search_1",
|
||
"source_kind": "external_search",
|
||
"facts": [
|
||
{
|
||
"type": "external_evidence",
|
||
"title": "AI events market candidate",
|
||
"url": "https://example.com/ai-events",
|
||
"summary": "公开来源候选显示 AI 会议活动和赞助需求需要继续交叉验证。",
|
||
},
|
||
{
|
||
"type": "external_evidence",
|
||
"title": "AI event tools candidate",
|
||
"url": "https://example.com/ai-event-tools",
|
||
"summary": "公开来源候选显示 AI 会务工具和活动运营工具正在出现。",
|
||
},
|
||
],
|
||
}
|
||
],
|
||
"agent_workspace": {
|
||
"claim_ledger": [
|
||
{
|
||
"claim_text": "AI 会议市场判断需要同时覆盖活动需求、会务工具和商业化信号。",
|
||
"confidence": "medium",
|
||
}
|
||
],
|
||
"gap_ledger": [
|
||
{
|
||
"summary": "还缺少近期参会规模、赞助收入和工具竞品的交叉来源。",
|
||
}
|
||
],
|
||
},
|
||
"deliverable_packet": {
|
||
"deliverable_kind": "research_note",
|
||
"summary": answer,
|
||
"sections": [
|
||
{
|
||
"section_kind": "gaps",
|
||
"items": [{"summary": "需要继续补三类来源后再形成机会结论。"}],
|
||
}
|
||
],
|
||
},
|
||
"answer_process_packet": {
|
||
"packet_version": "customer_answer_process.v1",
|
||
"steps": [{"key": "agent_answer_runtime", "label": "生成回答", "status": "completed"}],
|
||
"total_duration_ms": 10,
|
||
},
|
||
}
|
||
|
||
|
||
class _FakeNestedResearchDeliveryRuntime:
|
||
async def run_turn(self, **kwargs):
|
||
answer = "内部 Agent OS 已读取公开来源,但还没形成足够市场结论。"
|
||
observations = [
|
||
{
|
||
"observation_id": "obs_nested_search_1",
|
||
"source": "external_search",
|
||
"observation_type": "external_public_evidence",
|
||
"facts": [
|
||
{
|
||
"type": "external_evidence",
|
||
"title": "AI conference attendance candidate",
|
||
"url": "https://example.com/ai-conference-attendance",
|
||
"summary": "AI conference attendance and sponsor demand candidate.",
|
||
},
|
||
{
|
||
"type": "external_evidence",
|
||
"title": "AI event tools candidate",
|
||
"url": "https://example.com/ai-event-tools",
|
||
"summary": "AI event technology and meeting software candidate.",
|
||
},
|
||
],
|
||
}
|
||
]
|
||
return {
|
||
"answer": answer,
|
||
"reply_text": answer,
|
||
"processing_stage": "agent_runtime",
|
||
"answer_mode": "agent_runtime",
|
||
"grounding_status": "partial",
|
||
"answer_canvas": {"direct_answer": "旧 inner answer 不应该覆盖 operating answer。"},
|
||
"agent_run_packet": {
|
||
"run_id": "arun_nested",
|
||
"observations": observations,
|
||
},
|
||
"agent_workspace": {
|
||
"claim_ledger": [
|
||
{
|
||
"claim_text": "AI 会议市场判断需要先分开活动需求、会务工具和商业化信号。",
|
||
"confidence": "medium",
|
||
}
|
||
],
|
||
"gap_ledger": [{"summary": "还缺少赞助、参会规模和工具竞品的交叉来源。"}],
|
||
},
|
||
"deliverable_packet": {
|
||
"deliverable_kind": "research_note",
|
||
"sections": [{"section_kind": "gaps", "items": [{"summary": "需要继续补三类来源。"}]}],
|
||
},
|
||
"answer_process_packet": {
|
||
"packet_version": "customer_answer_process.v1",
|
||
"steps": [{"key": "agent_answer_runtime", "label": "生成回答", "status": "completed"}],
|
||
"total_duration_ms": 10,
|
||
},
|
||
}
|
||
|
||
|
||
class _RecordingEventSinkDeliveryRuntime(_FakeDeliveryRuntime):
|
||
def __init__(self) -> None:
|
||
self.saw_event_sink = False
|
||
|
||
async def run_turn(self, **kwargs):
|
||
event_sink = kwargs.get("event_sink")
|
||
self.saw_event_sink = callable(event_sink)
|
||
if callable(event_sink):
|
||
result = event_sink({"type": "step.completed", "step": {"key": "agent_answer_runtime", "label": "生成回答", "status": "completed"}})
|
||
if inspect.isawaitable(result):
|
||
await result
|
||
return await super().run_turn(**kwargs)
|
||
|
||
|
||
class _FakeDirectDeliveryRuntime:
|
||
async def run_turn(self, **kwargs):
|
||
answer = "我是 PMFlow AI,可以帮你查看空间、项目和下一步,但不会替你直接写入或执行。"
|
||
return {
|
||
"answer": answer,
|
||
"reply_text": answer,
|
||
"processing_stage": "agent_runtime",
|
||
"answer_mode": "agent_runtime",
|
||
"grounding_status": "grounded",
|
||
"answer_canvas": {"direct_answer": answer, "next_actions": []},
|
||
"deliverable_packet": {
|
||
"deliverable_kind": "direct_answer",
|
||
"summary": answer,
|
||
"next_actions": [],
|
||
},
|
||
"customer_evidence_packet": {"answer_type": "direct_answer"},
|
||
"terminal_answer_state": {
|
||
"terminal_status": "answerable",
|
||
"claims": [{"claim_text": answer, "confidence": "medium"}],
|
||
},
|
||
"agent_run_packet": {
|
||
"run_id": "arun_direct",
|
||
"steps": [{"key": "agent_answer_runtime", "label": "生成回答", "status": "completed"}],
|
||
"observations": [],
|
||
},
|
||
"answer_process_packet": {
|
||
"packet_version": "customer_answer_process.v1",
|
||
"steps": [{"key": "agent_answer_runtime", "label": "生成回答", "status": "completed"}],
|
||
"total_duration_ms": 10,
|
||
},
|
||
}
|