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, }, }