import inspect from types import SimpleNamespace import pytest import app.services.pmflow_ai_turn_runtime_service as runtime_module from app.api import rag_ask_contract from app.api.space_api import _request_with_interaction_submission from app.schemas.context_workspace import normalize_research_workspace_payload from app.schemas.pmflow_ai_turn_runtime import ( PMFlowAIEvidenceFinding, PMFlowAITurnEvidenceState, PMFlowAIRuntimeAction, PMFlowAITurnDelta, PMFlowAITurnDeltaAnswer, ) from app.schemas.rag import RAGAskRequest from app.services.customer_answer_presentation_service import CustomerAnswerPresentationService from app.services.external_query_service.schemas import ExternalEvidenceItem, ExternalQueryPlan from app.services.pmflow_ai_turn_runtime_service import ( PMFlowAITurnRuntimeService, _build_generic_clarification_payload, _build_thread_workspace_clarification_payload, _render_open_judgement_answer_from_turn_state, _workspace_payload_from_turn_state, build_turn_presentation_contract_from_runtime_payload, ) from app.services.pmflow_ai_evidence_tool_registry import ( PMFlowAIEvidenceToolRegistry, _build_turn_external_queries, _filter_fresh_external_evidence, _filter_relevant_external_evidence, ) from app.services.pmflow_ai_turn_state_reviser import PMFlowAITurnStateReviser def _service() -> PMFlowAITurnRuntimeService: return PMFlowAITurnRuntimeService(object(), tenant_id=1) class _PromptResult: def __init__(self, *, prompt_key: str, prompt: str) -> None: self.prompt_key = prompt_key self.prompt = prompt def routing_meta(self) -> dict: return { "prompt_key": self.prompt_key, "prompt_release_id": 9001, "prompt_source": "active_release", } class _FakeLLMService: calls: list[dict] = [] async def generate(self, prompt: str, **kwargs): self.calls.append({"prompt": prompt, **kwargs}) if kwargs.get("function_name") == "pmflow_ai_turn_goal_state_assessor": return '{"normalized_goal":"回答当前问题","answer_shape":"general_answer","operation_gaps":[]}' return "运营中心答案" def test_turn_runtime_llm_prompts_are_not_inline_in_service_methods() -> None: goal_source = inspect.getsource(PMFlowAITurnRuntimeService._infer_turn_hypothesis) answer_source = inspect.getsource(PMFlowAITurnRuntimeService._compose_state_answer) assert "你是 PMFlow 的 TurnState 目标评估器" not in goal_source assert "你是 PMFlow 的受控业务 Agent Runtime 的最终答案合成器" not in answer_source assert "TURN_GOAL_STATE_ASSESSOR_PROMPT_KEY" in goal_source assert "TURN_STATE_ANSWER_SYNTHESIZER_PROMPT_KEY" in answer_source @pytest.mark.asyncio async def test_turn_goal_assessor_uses_prompt_runtime_and_logs_prompt_attribution(monkeypatch) -> None: render_calls = [] async def fake_render(self, **kwargs): render_calls.append(kwargs) return _PromptResult(prompt_key=kwargs["prompt_key"], prompt=f"managed prompt: {kwargs['prompt_key']}") _FakeLLMService.calls = [] monkeypatch.setattr(runtime_module.PromptRuntimeService, "render", fake_render) monkeypatch.setattr(runtime_module, "LLMService", _FakeLLMService) result = await PMFlowAITurnRuntimeService(object(), tenant_id=12)._infer_turn_hypothesis( request=RAGAskRequest(question="现在应该做什么"), current_user=SimpleNamespace(id=1, tenant_id=12), tenant_project_context={}, conversation_context={}, ) assert render_calls[0]["prompt_key"] == runtime_module.TURN_GOAL_STATE_ASSESSOR_PROMPT_KEY assert result["hypothesis_origin"] == "turn_state_goal_assessor" routing_meta = _FakeLLMService.calls[0]["routing_meta"] assert routing_meta["prompt_key"] == runtime_module.TURN_GOAL_STATE_ASSESSOR_PROMPT_KEY assert routing_meta["prompt_release_id"] == 9001 assert routing_meta["prompt_source"] == "active_release" assert routing_meta["scene_contract"] == "turn_state_goal_assessment" @pytest.mark.asyncio async def test_turn_answer_synthesizer_uses_prompt_runtime_and_logs_prompt_attribution(monkeypatch) -> None: render_calls = [] async def fake_render(self, **kwargs): render_calls.append(kwargs) return _PromptResult(prompt_key=kwargs["prompt_key"], prompt=f"managed prompt: {kwargs['prompt_key']}") _FakeLLMService.calls = [] monkeypatch.setattr(runtime_module.PromptRuntimeService, "render", fake_render) monkeypatch.setattr(runtime_module, "LLMService", _FakeLLMService) service = PMFlowAITurnRuntimeService(object(), tenant_id=12) state = service.build_initial_turn_state( request=RAGAskRequest(question="项目现在怎么样"), route_plan={}, turn_hypothesis={"normalized_goal": "判断项目状态", "answer_shape": "status_answer"}, ) state.evidence_state = PMFlowAITurnEvidenceState( known_facts=[PMFlowAIEvidenceFinding(fact="项目当前处于推进中。", source="项目状态")] ) answer = await service._compose_state_answer(request=RAGAskRequest(question="项目现在怎么样"), turn_state=state) assert answer == "运营中心答案" assert render_calls[0]["prompt_key"] == runtime_module.TURN_STATE_ANSWER_SYNTHESIZER_PROMPT_KEY routing_meta = _FakeLLMService.calls[0]["routing_meta"] assert routing_meta["prompt_key"] == runtime_module.TURN_STATE_ANSWER_SYNTHESIZER_PROMPT_KEY assert routing_meta["prompt_release_id"] == 9001 assert routing_meta["prompt_source"] == "active_release" assert routing_meta["scene_contract"] == "state_based_answer_synthesis" def test_turn_runtime_hypothesis_controls_external_action_not_route_plan() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="我想判断一个新方向甲是否值得做"), route_plan={ "legacy_policy": "internal_only", "legacy_shape": "project_status_assessment", }, turn_hypothesis={ "normalized_goal": "判断一个新项目方向是否值得做", "desired_outcome": "给出机会、边界、MVP、风险和下一步", "answer_shape": "strategic_judgement", "decision_type": "market_opportunity", "subject": "新方向甲", "subject_terms": ["direction-alpha"], "evidence_needs": ["external_fresh", "synthesize"], "freshness_required": True, "can_answer_with_assumptions": True, "primary_object": {"binding_strength": "none"}, }, ) decision = _service().assess_sufficiency(state) assert state.answer_state.answer_shape == "strategic_judgement" assert state.sufficiency_state.needs_external_evidence is True assert decision.next_action_type == "external_search" assert [item.action_type for item in state.action_state.candidate_actions] == [ "external_search", "synthesize_answer", ] assert state.route_plan_input["route_hypothesis_only"] is True def test_turn_runtime_repairs_external_trend_frame_before_surface_context_can_win() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest( question="帮我搜搜AI漫剧的行业趋势", surface_context={ "space_runtime_context": { "workbench": { "projects": [ {"id": 141, "title": "童析", "object_ref": "project:141", "status": "active"} ] } } }, ), route_plan={}, turn_hypothesis={ "normalized_goal": "获取AI漫剧行业的宏观趋势信息", "desired_outcome": "提供结构化、时效性强的AI漫剧行业发展趋势分析", "answer_shape": "strategic_judgement", "goal_frames": [ { "frame_id": "industry_trend_research", "operation_kind": "list_collection", "target_scope": {"scope_type": "unspecified", "binding_strength": "none", "source": "goal_frame"}, "target_object_requirement": "none", "answer_shape": "strategic_judgement", "confidence": 0.95, } ], "operation_gaps": [ { "key": "evidence_source_authority", "gap_type": "system_evidence_gap", "blocking": False, "user_actionable": False, "system_action_hint": "fetch latest industry reports from public sources", "reason": "无预置权威行业趋势数据库,需实时聚合多源证据。", } ], "can_answer_with_assumptions": False, "primary_object": {"binding_strength": "none"}, }, ) decision = _service().assess_sufficiency(state) assert state.selected_frame is not None assert state.selected_frame.operation_kind == "judge_direction" assert state.selected_frame.source_policy == "external" assert state.selected_frame.target_scope.scope_type == "external_public" assert decision.next_action_type == "external_search" assert "inspect_surface_context" not in [item.action_type for item in state.action_state.candidate_actions] assert state.object_state.context_objects[0].title == "童析" def test_turn_runtime_hypothesis_controls_internal_project_action() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="童析项目现在怎么样"), route_plan={ "legacy_policy": "external_only", "legacy_shape": "external_summary_with_sources", }, turn_hypothesis={ "normalized_goal": "判断项目当前状态", "desired_outcome": "给出状态、依据、缺口和下一步", "answer_shape": "status_answer", "decision_type": "project_status", "subject": "童析", "evidence_needs": ["internal_projects", "synthesize"], "primary_object": { "object_type": "project", "object_ref": "project:141", "title": "童析", "binding_strength": "explicit", }, }, ) action_types = [item.action_type for item in state.action_state.candidate_actions] assert "inspect_project_status" in action_types assert "external_search" not in action_types assert state.answer_state.answer_shape == "status_answer" assert state.object_state.primary_object is not None assert state.object_state.primary_object.object_ref == "project:141" def test_turn_runtime_hypothesis_requires_user_input_when_blocking() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="帮我处理一下"), route_plan={}, turn_hypothesis={ "normalized_goal": "执行一个未明确的处理动作", "desired_outcome": "确认对象和动作边界", "answer_shape": "clarification", "decision_type": "execution_guidance", "needs_user_input": True, "can_answer_with_assumptions": False, "evidence_needs": ["clarification"], }, ) decision = _service().assess_sufficiency(state) assert decision.must_ask_user is True assert decision.next_action_type == "ask_clarification" assert state.answer_state.answer_shape == "clarification" def test_turn_runtime_open_idea_can_answer_with_assumptions() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="我想判断一个新方向乙是否值得做"), route_plan={}, turn_hypothesis={ "normalized_goal": "判断一个新项目方向是否值得做", "desired_outcome": "给出方向判断", "answer_shape": "strategic_judgement", "decision_type": "market_opportunity", "subject": "新方向乙", "evidence_needs": ["external_fresh", "synthesize"], "freshness_required": True, "needs_user_input": True, "can_answer_with_assumptions": True, }, ) gap_keys = [item.key for item in state.evidence_state.evidence_gaps] assert state.sufficiency_state.can_answer_with_assumptions is True assert state.sufficiency_state.needs_user_input is False assert "external_fresh" in gap_keys assert state.answer_state.answer_shape == "strategic_judgement" def test_packet_f_raw_needs_user_input_does_not_block_low_risk_collection_probe() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="现在有啥项目"), route_plan={}, turn_hypothesis={ "normalized_goal": "列出当前空间项目", "needs_user_input": True, "can_answer_with_assumptions": False, "goal_frames": [ { "frame_id": "project-inventory", "operation_kind": "list_collection", "target_scope": {"scope_type": "current_space", "binding_strength": "surface"}, "target_collection": {"object_type": "project", "required": True}, "target_object_requirement": "none", "answer_shape": "fact_list", } ], "operation_gaps": [ { "key": "current_space_projects", "gap_type": "system_evidence_gap", "user_actionable": False, "system_action_hint": "inspect_surface_context", "reason": "需要读取当前空间项目集合。", } ], }, ) decision = _service().assess_sufficiency(state) assert decision.must_ask_user is False assert decision.next_action_type == "inspect_surface_context" assert state.sufficiency_state.needs_user_input is False def test_packet_m_can_answer_does_not_skip_unresolved_system_probe() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="现在有哪些项目"), route_plan={}, turn_hypothesis={ "normalized_goal": "列出当前空间项目", "answer_shape": "fact_list", "can_answer_with_assumptions": True, "goal_frames": [ { "frame_id": "project-inventory", "operation_kind": "list_collection", "target_scope": {"scope_type": "current_space", "binding_strength": "surface"}, "target_collection": {"object_type": "project", "required": True}, "target_object_requirement": "none", "answer_shape": "fact_list", } ], "operation_gaps": [ { "key": "current_space_projects", "gap_type": "system_evidence_gap", "blocking": True, "user_actionable": False, "system_action_hint": "inspect_surface_context", "reason": "需要读取当前空间项目集合。", } ], }, ) state.sufficiency_state.can_answer_now = True decision = _service().assess_sufficiency(state) assert decision.can_answer_now is False assert decision.next_action_type == "inspect_surface_context" def test_packet_m_performed_probe_can_proceed_to_answer() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="现在有哪些项目"), route_plan={}, turn_hypothesis={ "normalized_goal": "列出当前空间项目", "answer_shape": "fact_list", "can_answer_with_assumptions": True, "goal_frames": [ { "frame_id": "project-inventory", "operation_kind": "list_collection", "target_scope": {"scope_type": "current_space", "binding_strength": "surface"}, "target_collection": {"object_type": "project", "required": True}, "target_object_requirement": "none", "answer_shape": "fact_list", } ], "operation_gaps": [ { "key": "current_space_projects", "gap_type": "system_evidence_gap", "blocking": True, "user_actionable": False, "system_action_hint": "inspect_surface_context", "reason": "需要读取当前空间项目集合。", } ], }, ) state.action_state.performed_actions.append( PMFlowAIRuntimeAction( action_id="inspect_surface_context", action_type="inspect_surface_context", status="performed", ) ) state.sufficiency_state.can_answer_now = True decision = _service().assess_sufficiency(state) assert decision.can_answer_now is True assert decision.next_action_type is None def test_packet_n_new_project_workspace_uses_structured_update_not_previous_focus() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="做一个AI混剪工具,面向信息投流团队降本"), route_plan={}, conversation_context={ "object_ledger": { "current_focus_object": {"object_type": "project", "object_ref": "project:141", "title": "童析"} } }, turn_hypothesis={ "normalized_goal": "评估AI混剪工具项目方向", "answer_shape": "strategic_judgement", "primary_object": {"binding_strength": "none"}, "object_binding_decision": { "decision": "create_new_focus", "rejected_object_ref": "project:141", "new_focus_title": "AI混剪工具", }, "thread_workspace_update": { "should_create_workspace": True, "workspace_kind": "new_project_ideation", "topic": "AI混剪工具", "active_goal": "判断是否值得立项", "collected_inputs": { "target_customer": "信息投流团队", "problem": "大量员工混剪视频导致人工成本高", }, "pending_questions": ["素材来源和审核规则是否明确"], "evidence_requirements": ["业务流程", "降本空间", "合规边界"], }, }, ) workspace = state.thread_workspace assert workspace is not None assert workspace.workspace_kind == "new_project_ideation" assert workspace.topic == "AI混剪工具" assert workspace.collected_inputs["target_customer"] == "信息投流团队" assert state.object_state.primary_object is not None assert state.object_state.primary_object.object_type == "new_idea" assert state.object_state.primary_object.object_ref == "" def test_packet_n_existing_workspace_merges_slot_memory_and_submission_answers() -> None: active_workspace = { "workspace_version": "research_workspace.v1", "workspace_id": "tw_video_mix", "workspace_kind": "new_project_ideation", "topic": "AI混剪工具", "primary_object_title": "AI混剪工具", "research_goal": "判断是否值得立项", "collected_inputs": {"problem": "人工混剪成本高"}, "open_questions": ["目标客户是谁"], "pmflow_ai_thread_workspace": { "packet_version": "pmflow_ai_thread_workspace.v1", "workspace_id": "tw_video_mix", "workspace_kind": "new_project_ideation", "topic": "AI混剪工具", "primary_object": { "object_type": "new_idea", "object_ref": "", "title": "AI混剪工具", "binding_strength": "active_workspace_strong", }, "active_goal": "判断是否值得立项", "collected_inputs": {"problem": "人工混剪成本高"}, "pending_questions": ["目标客户是谁"], "evidence_requirements": ["业务流程"], "forbidden_substitution": True, "status": "active", }, } state = _service().build_initial_turn_state( request=RAGAskRequest( question="目标客户是信息投流团队", surface_context={ "pmflow_ai_turn_delta": PMFlowAITurnDelta( source="interaction_submission", answers=[ PMFlowAITurnDeltaAnswer( question_id="target_customer", question_label="目标客户", display_value="信息投流团队", ) ], supplement_text="素材主要来自已有广告视频。", ).model_dump(mode="json") }, ), route_plan={}, conversation_context={"active_workspace": active_workspace}, turn_hypothesis={ "normalized_goal": "继续补充AI混剪工具项目判断信息", "answer_shape": "strategic_judgement", "thread_workspace_update": { "should_continue_workspace": True, "workspace_kind": "new_project_ideation", "collected_inputs": {"delivery_surface": "SaaS工具"}, "pending_questions": ["审核规则是否明确"], }, }, ) workspace = state.thread_workspace payload = _workspace_payload_from_turn_state(state) normalized = normalize_research_workspace_payload(payload) assert workspace is not None assert workspace.collected_inputs["problem"] == "人工混剪成本高" assert workspace.collected_inputs["delivery_surface"] == "SaaS工具" assert workspace.collected_inputs["target_customer"] == "信息投流团队" assert workspace.collected_inputs["latest_supplement"] == "素材主要来自已有广告视频。" assert normalized["collected_inputs"]["target_customer"] == "信息投流团队" assert normalized["pmflow_ai_thread_workspace"]["collected_inputs"]["problem"] == "人工混剪成本高" def test_packet_o_open_judgement_answers_from_workspace_without_evidence() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="做一个AI混剪工具,面向信息投流团队降本,怎么样"), route_plan={}, turn_hypothesis={ "normalized_goal": "判断AI混剪工具是否值得做", "answer_shape": "strategic_judgement", "can_answer_with_assumptions": True, "object_binding_decision": {"decision": "create_new_focus", "new_focus_title": "AI混剪工具"}, "thread_workspace_update": { "should_create_workspace": True, "workspace_kind": "new_project_ideation", "topic": "AI混剪工具", "collected_inputs": { "目标用户": "信息投流团队", "核心问题": "大量员工混剪视频导致人工成本高", }, "pending_questions": ["素材来源是否稳定", "审核规则是否明确"], }, "goal_frames": [ { "frame_id": "new-idea-judgement", "operation_kind": "judge_direction", "target_scope": {"scope_type": "unspecified", "binding_strength": "none"}, "target_object_requirement": "none", "answer_shape": "strategic_judgement", } ], "operation_gaps": [ { "key": "business_context", "gap_type": "business_assumption_gap", "blocking": False, "reason": "需要验证真实流程和审核标准。", } ], }, ) answer = _render_open_judgement_answer_from_turn_state( question="做一个AI混剪工具,面向信息投流团队降本,怎么样", turn_state=state, ) assert "我的初步判断:AI混剪工具可以继续看" in answer assert "目标用户:信息投流团队" in answer assert "素材来源是否稳定" in answer assert "我还没有拿到足够证据" not in answer assert "不能用当前空间上下文" not in answer def test_packet_p_thread_workspace_clarification_batches_pending_questions() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="我想跟你聊个新项目"), route_plan={}, turn_hypothesis={ "normalized_goal": "继续收敛新项目方向", "answer_shape": "clarification", "needs_user_input": True, "can_answer_with_assumptions": False, "object_binding_decision": {"decision": "create_new_focus", "new_focus_title": "AI混剪工具"}, "thread_workspace_update": { "should_create_workspace": True, "workspace_kind": "new_project_ideation", "topic": "AI混剪工具", "pending_questions": ["目标用户是谁", "现在人工流程怎么走", "什么结果算成功"], }, "operation_gaps": [ { "key": "target_user", "gap_type": "user_clarification_gap", "blocking": True, "user_actionable": True, "customer_question": "目标用户是谁", } ], }, ) payload = _build_thread_workspace_clarification_payload(state) questions = payload["interaction_request"]["questions"] assert payload["interaction_request"]["presentation"] == "stepper" assert [item["label"] for item in questions] == ["目标用户是谁", "现在人工流程怎么走", "什么结果算成功"] assert payload["interaction_request"]["title"] == "补齐这个方向的关键判断条件" def test_packet_p_generic_clarification_batches_user_actionable_gaps() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="帮我处理一下"), route_plan={}, turn_hypothesis={ "normalized_goal": "执行一个未明确的处理动作", "answer_shape": "clarification", "needs_user_input": True, "can_answer_with_assumptions": False, "operation_gaps": [ { "key": "object", "gap_type": "user_clarification_gap", "blocking": True, "user_actionable": True, "customer_question": "要处理哪个对象?", }, { "key": "action", "gap_type": "user_clarification_gap", "blocking": True, "user_actionable": True, "customer_question": "你希望我执行什么动作?", }, ], }, ) payload = _build_generic_clarification_payload(state) questions = payload["interaction_request"]["questions"] assert payload["interaction_request"]["title"] == "还需要确认这些点" assert [item["label"] for item in questions] == ["要处理哪个对象?", "你希望我执行什么动作?"] @pytest.mark.asyncio async def test_packet_g_collection_observation_reframes_fact_list_goal() -> None: service = _service() state = service.build_initial_turn_state( request=RAGAskRequest( question="现在有啥项目", surface_context={ "space_runtime_context": { "workbench": { "projects": [{"id": 141, "object_ref": "project:141", "title": "童析", "status": "进行中"}] } } }, ), route_plan={}, turn_hypothesis={ "normalized_goal": "列出当前空间项目", "answer_shape": "fact_list", "goal_frames": [ { "frame_id": "misframed-general", "operation_kind": "answer_general", "target_scope": {"scope_type": "current_space", "binding_strength": "surface"}, "target_object_requirement": "none", "answer_shape": "fact_list", } ], "operation_gaps": [ { "key": "current_space_projects", "gap_type": "system_evidence_gap", "system_action_hint": "inspect_surface_context", "reason": "需要读取当前空间项目集合。", } ], "can_answer_with_assumptions": True, }, ) payload = await PMFlowAIEvidenceToolRegistry(object(), tenant_id=1).run( action_type="inspect_surface_context", request=RAGAskRequest( question="现在有啥项目", surface_context={ "space_runtime_context": { "workbench": { "projects": [{"id": 141, "object_ref": "project:141", "title": "童析", "status": "进行中"}] } } }, ), current_user=object(), turn_state=state, ) reviser = PMFlowAITurnStateReviser() observation = reviser.observe_payload(turn_state=state, action_type="inspect_surface_context", payload=payload) reviser.revise_from_observation(turn_state=state, observation=observation) assert state.selected_frame is not None assert state.selected_frame.operation_kind == "list_collection" assert state.sufficiency_state.can_answer_now is True assert state.customer_packet_draft["operation_kind"] == "list_collection" @pytest.mark.asyncio async def test_packet_h_collection_answer_renders_list_and_blocks_followup_card() -> None: service = _service() state = service.build_initial_turn_state( request=RAGAskRequest(question="现在有啥项目"), route_plan={}, turn_hypothesis={ "normalized_goal": "列出当前空间项目", "goal_frames": [ { "frame_id": "project-inventory", "operation_kind": "list_collection", "target_scope": {"scope_type": "current_space", "binding_strength": "surface"}, "target_collection": {"object_type": "project", "required": True}, "target_object_requirement": "none", "answer_shape": "fact_list", } ], "operation_gaps": [], "can_answer_with_assumptions": True, }, ) state.customer_packet_draft = { "operation_kind": "list_collection", "object_type": "project", "count": 1, "items": [{"object_type": "project", "title": "项目:童析,状态:进行中。", "summary": "项目:童析,状态:进行中。"}], } state.sufficiency_state.can_answer_now = True payload = await service._run_state_answer_synthesis( request=RAGAskRequest(question="现在有啥项目"), current_user=SimpleNamespace(id=9, tenant_id=1), route_plan={}, turn_state=state, presentation_service=CustomerAnswerPresentationService(object(), tenant_id=1), started_at=0, ) verification_failure = service._verify_runtime_payload( turn_state=state, payload={ "_runtime_payload_kind": "final_answer", "answer": "已有集合答案。", "interaction_request": {"mode": "blocking", "questions": [{"id": "scope"}]}, }, ) assert "童析" in payload["answer"] assert "interaction_request" not in payload assert verification_failure is not None assert verification_failure.failure_type == "interaction_not_user_actionable" def test_visible_project_does_not_become_primary_without_strong_binding() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest( question="最近有没有好的项目推荐", surface_context={ "space_runtime_context": { "workbench": { "projects": [ {"id": 141, "title": "童析", "object_ref": "project:141", "status": "active"} ] } } }, ), route_plan={ "object_binding": { "target_object": {"object_ref": "project:141", "title": "童析"}, "binding_strength": "visible_context", } }, turn_hypothesis={ "normalized_goal": "推荐值得继续看的新项目方向", "desired_outcome": "给出多个方向和优先级", "answer_shape": "strategic_judgement", "decision_type": "market_opportunity", "subject": "", "evidence_needs": ["external_fresh", "synthesize"], "freshness_required": True, "primary_object": {"binding_strength": "none"}, "can_answer_with_assumptions": True, }, ) assert state.object_state.primary_object is not None assert state.object_state.primary_object.binding_strength == "none" assert state.object_state.primary_object.object_ref == "" assert [item.title for item in state.object_state.context_objects] == ["童析"] assert state.action_state.next_best_action is not None assert state.action_state.next_best_action.action_type == "external_search" def test_turn_runtime_multi_task_keeps_composite_shape_and_internal_action() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="这个项目 MVP 还差什么、风险在哪里、我今天先做什么"), route_plan={}, turn_hypothesis={ "normalized_goal": "同时判断项目缺口、风险和今日优先级", "desired_outcome": "保留多个目标并合并回答", "answer_shape": "composite_answer", "decision_type": "project_status", "evidence_needs": ["internal_projects", "synthesize"], "primary_object": { "object_type": "project", "object_ref": "project:141", "title": "当前项目", "binding_strength": "current_page", }, }, ) assert state.answer_state.answer_shape == "composite_answer" assert state.action_state.next_best_action is not None assert state.action_state.next_best_action.action_type == "inspect_project_status" def test_turn_runtime_maps_project_lookup_and_status_to_registry_tools() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="这个项目现在怎么样,相关项目有哪些"), route_plan={}, turn_hypothesis={ "normalized_goal": "解析项目对象并读取项目状态", "answer_shape": "composite_answer", "decision_type": "project_status", "evidence_needs": ["project_lookup", "project_status", "synthesize"], "primary_object": { "object_type": "project", "object_ref": "project:141", "title": "当前项目", "binding_strength": "current_page", }, }, ) action_types = [item.action_type for item in state.action_state.candidate_actions] assert action_types[0] == "inspect_project_status" assert "resolve_project_objects" not in action_types[:1] assert "external_search" not in action_types @pytest.mark.asyncio async def test_internal_project_action_returns_evidence_not_final_answer() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest( question="这个项目现在怎么样", surface_context={ "space_runtime_context": { "workbench": { "projects": [ {"id": 141, "object_ref": "project:141", "title": "当前项目", "status": "active"} ] } } }, ), route_plan={}, turn_hypothesis={ "normalized_goal": "判断项目当前状态", "answer_shape": "status_answer", "decision_type": "project_status", "evidence_needs": ["internal_projects", "synthesize"], "primary_object": { "object_type": "project", "object_ref": "project:141", "title": "当前项目", "binding_strength": "current_page", }, }, ) payload = await PMFlowAIEvidenceToolRegistry(object(), tenant_id=1).run( action_type="inspect_internal_projects", request=RAGAskRequest( question="这个项目现在怎么样", surface_context={ "space_runtime_context": { "workbench": { "projects": [ {"id": 141, "object_ref": "project:141", "title": "当前项目", "status": "active"} ] } } }, ), current_user=object(), turn_state=state, ) assert payload is not None assert payload["_runtime_payload_kind"] == "evidence" assert "answer" not in payload assert payload["runtime_evidence_tool_result"]["findings"][0]["source"] == "PMFlow 项目上下文" @pytest.mark.asyncio async def test_project_lookup_tool_returns_evidence_not_final_answer() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest( question="有没有跟儿童 AI 评估相关的项目", surface_context={ "space_runtime_context": { "workbench": { "projects": [ {"id": 141, "object_ref": "project:141", "title": "儿童 AI 评估助手", "status": "active"} ] } } }, ), route_plan={}, turn_hypothesis={ "normalized_goal": "查找相关内部项目", "answer_shape": "fact_list", "decision_type": "object_lookup", "subject": "儿童 AI 评估", "subject_terms": ["儿童", "AI", "评估"], "evidence_needs": ["project_lookup", "synthesize"], "primary_object": {"binding_strength": "none"}, }, ) payload = await PMFlowAIEvidenceToolRegistry(object(), tenant_id=1).run( action_type="resolve_project_objects", request=RAGAskRequest( question="有没有跟儿童 AI 评估相关的项目", surface_context={ "space_runtime_context": { "workbench": { "projects": [ {"id": 141, "object_ref": "project:141", "title": "儿童 AI 评估助手", "status": "active"} ] } } }, ), current_user=object(), turn_state=state, ) assert payload is not None assert payload["_runtime_payload_kind"] == "evidence" assert "answer" not in payload finding = payload["runtime_evidence_tool_result"]["findings"][0] assert finding["source"] == "PMFlow 项目对象解析" assert "儿童 AI 评估助手" in finding["fact"] @pytest.mark.asyncio async def test_project_status_tool_returns_evidence_not_final_answer() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest( question="当前项目现在怎么样", surface_context={ "space_runtime_context": { "workbench": { "projects": [ { "id": 141, "object_ref": "project:141", "title": "当前项目", "status": "in_progress", "task_counts": {"in_progress": 3, "blocked": 1}, } ] } } }, ), route_plan={}, turn_hypothesis={ "normalized_goal": "判断项目状态", "answer_shape": "status_answer", "decision_type": "project_status", "evidence_needs": ["project_status", "synthesize"], "primary_object": { "object_type": "project", "object_ref": "project:141", "title": "当前项目", "binding_strength": "current_page", }, }, ) payload = await PMFlowAIEvidenceToolRegistry(object(), tenant_id=1).run( action_type="inspect_project_status", request=RAGAskRequest( question="当前项目现在怎么样", surface_context={ "space_runtime_context": { "workbench": { "projects": [ { "id": 141, "object_ref": "project:141", "title": "当前项目", "status": "in_progress", "task_counts": {"in_progress": 3, "blocked": 1}, } ] } } }, ), current_user=object(), turn_state=state, ) assert payload is not None assert payload["_runtime_payload_kind"] == "evidence" assert "answer" not in payload finding = payload["runtime_evidence_tool_result"]["findings"][0] assert finding["source"] == "PMFlow 项目执行态证据" assert "进行中" in finding["fact"] or "in_progress" in finding["fact"] def test_turn_runtime_external_queries_are_state_driven() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="我想判断一个新方向甲是否值得做"), route_plan={}, turn_hypothesis={ "normalized_goal": "判断一个新项目方向是否值得做", "answer_shape": "strategic_judgement", "decision_type": "market_opportunity", "subject": "新方向甲", "subject_terms": ["direction-alpha"], "evidence_needs": ["external_fresh", "synthesize"], "freshness_required": True, }, ) queries = _build_turn_external_queries( question="我想判断一个新方向甲是否值得做", route_plan={}, turn_state=state, ) assert all("site:gov.cn" not in query for query in queries) assert any("新方向甲" in query or "direction-alpha" in query for query in queries) assert any("market opportunity" in query or "risk regulation" in query for query in queries) def test_turn_runtime_absorbs_tool_findings_as_state_not_source_summary() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="现在做智能库存系统还有市场吗"), route_plan={}, turn_hypothesis={ "normalized_goal": "判断新项目方向是否值得做", "answer_shape": "strategic_judgement", "decision_type": "market_opportunity", "subject": "智能库存系统", "evidence_needs": ["external_fresh", "synthesize"], "freshness_required": True, "can_answer_with_assumptions": True, }, ) payload = { "_runtime_payload_kind": "evidence", "runtime_evidence_tool_result": { "tool_name": "external_query_gateway", "action_type": "external_search", "findings": [ { "fact": "近期公开来源显示仓储库存系统仍在向实时预测和自动补货升级。", "source": "https://example.com/current-inventory-report", "implication": "市场机会不在通用库存看板,而在预测、补货和异常处理闭环。", "relevance_to_goal": "validates_market_window", "confidence": "high", } ], "sources": [{"source": "external", "display_label": "current inventory report", "target_url": "https://example.com/current-inventory-report"}], "remaining_gaps": ["还缺少目标客户预算与采购周期证据"], "confidence": "high", "summary": "吸收 1 条外部证据", }, "external_evidence_items": [ { "title": "不应重复进入 finding 的来源标题", "target_url": "https://example.com/duplicate", "snippet": "如果 tool_result 已经给 findings,这里不能再被摘要式吸收。", } ], } updated = _service().absorb_evidence( turn_state=state, action=PMFlowAIRuntimeAction( action_id="external_search", action_type="external_search", reason="读取外部证据", tool_name="external_query_gateway", ), payload=payload, started_at=0, ) assert [item.fact for item in updated.evidence_state.known_facts] == [ "近期公开来源显示仓储库存系统仍在向实时预测和自动补货升级。" ] assert "不应重复进入 finding 的来源标题" not in updated.evidence_state.known_facts[0].fact assert updated.judgement_state.opportunity.startswith("市场机会不在通用库存看板") assert "还缺少目标客户预算与采购周期证据" in updated.evidence_state.stale_or_missing assert updated.evidence_state.evidence_quality == "high" def test_turn_runtime_does_not_absorb_final_answer_back_as_evidence() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="这个项目怎么样"), route_plan={}, turn_hypothesis={ "normalized_goal": "判断项目状态", "answer_shape": "status_answer", "decision_type": "project_status", "evidence_needs": ["internal_projects", "synthesize"], }, ) updated = _service().absorb_evidence( turn_state=state, action=PMFlowAIRuntimeAction( action_id="synthesize_answer", action_type="synthesize_answer", reason="生成最终回答", tool_name="turn_state_answer_synthesizer", ), payload={ "_runtime_payload_kind": "final_answer", "answer": "这是最终回答,不能反向作为证据。", "customer_evidence_packet": { "facts": [ { "display_summary": "最终回答里的展示事实不能重新进入 evidence_state。", "confidence": "high", } ] }, }, started_at=0, ) assert updated.completed is True assert updated.evidence_state.known_facts == [] def test_interaction_submission_keeps_structured_delta_without_intent_derivation() -> None: request = RAGAskRequest( question="确认并继续", surface_context={ "interaction_submission": { "answers": [ { "question_id": "source_scope", "question_label": "这次希望系统按哪类信息继续?", "value": "external_public", "labels": ["外部公开信息"], }, { "question_id": "idea_direction", "question_label": "如果你已经有明确对象,请补充项目、想法或事项名称。", "value": "补充一个新方向丙", "labels": [], }, ], } }, ) updated = _request_with_interaction_submission(request) delta = updated.surface_context["pmflow_ai_turn_delta"] assert "结构化补充" in updated.question assert "补充一个新方向丙" in updated.question assert "primary_subject" not in delta assert "evidence_mode" not in delta assert "intent_hint" not in delta def test_space_runtime_sanitize_does_not_infer_clarification_from_legacy_route_plan() -> None: payload = { "answer": "可以先按当前证据回答。", "reply_text": "可以先按当前证据回答。", "pmflow_ai_route_plan": {"needs_clarification": True, "clarification_question": "旧字段里的问题"}, "customer_evidence_packet": {"answer_type": "strategic_judgement", "facts": [], "source_summary": []}, } cleaned = rag_ask_contract.sanitize_customer_rag_payload(payload, apply_presentation_contract=False) assert "interaction_request" not in cleaned assert "turn_presentation_contract" not in cleaned def test_customer_sanitize_default_does_not_build_interaction_from_legacy_route_plan() -> None: payload = { "answer": "可以先按当前证据回答。", "reply_text": "可以先按当前证据回答。", "pmflow_ai_route_plan": {"needs_clarification": True, "clarification_question": "旧字段里的问题"}, "customer_evidence_packet": {"answer_type": "strategic_judgement", "facts": [], "source_summary": []}, } cleaned = rag_ask_contract.sanitize_customer_rag_payload(payload) assert "interaction_request" not in cleaned assert cleaned["turn_presentation_contract"]["turn_state"] == "answer_ready" assert cleaned["turn_presentation_contract"]["primary_surface"] == "answer_canvas" def test_packet_ac_process_timing_preserves_runtime_step_contract() -> None: packet = { "packet_version": "customer_answer_process.v1", "steps": [ {"key": "understand_goal", "label": "识别目标", "status": "completed", "blocking": False, "duration_ms": 12}, {"key": "inspect_project_status", "label": "读取项目状态", "status": "completed", "blocking": False}, {"key": "state_answer", "label": "生成项目回答", "status": "completed", "blocking": False}, ], } patched = rag_ask_contract.attach_answer_process_timing( packet, total_duration_ms=1800, route_key="turn_runtime_state_answer", ) assert patched["total_duration_ms"] == 1800 assert patched["steps"][0]["duration_ms"] == 12 assert patched["steps"][0]["blocking"] is False assert patched["steps"][1]["blocking"] is False assert patched["steps"][2]["blocking"] is False assert patched["steps"][2]["duration_ms"] == 1800 def test_space_turn_presentation_contract_comes_from_runtime_payload() -> None: answer_contract = build_turn_presentation_contract_from_runtime_payload(payload={"answer": "已有回答"}) interaction_contract = build_turn_presentation_contract_from_runtime_payload( payload={ "answer": "先确认", "interaction_request": {"mode": "blocking", "questions": [{"id": "scope"}]}, } ) selection_contract = build_turn_presentation_contract_from_runtime_payload( payload={ "answer": "先选一个", "interaction_request": { "mode": "blocking", "questions": [{"id": "scope", "input_type": "single_choice", "options": [{"id": "a", "label": "A"}]}], }, } ) legacy_route_contract = build_turn_presentation_contract_from_runtime_payload( payload={"interaction_request": {"mode": "blocking", "questions": [{"id": "scope"}]}} ) assert answer_contract["turn_state"] == "answer_ready" assert answer_contract["primary_surface"] == "answer_canvas" assert answer_contract["source"] == "turn_runtime" assert interaction_contract["turn_state"] == "input_required" assert interaction_contract["primary_surface"] == "interaction_request" assert selection_contract["turn_state"] == "selection_required" assert legacy_route_contract["turn_state"] == "input_required" def test_space_turn_presentation_contract_ignores_legacy_route_plan_clarification() -> None: contract = build_turn_presentation_contract_from_runtime_payload( payload={ "answer": "已有可展示回答。", "pmflow_ai_route_plan": {"needs_clarification": True, "clarification_question": "旧字段追问"}, "customer_evidence_packet": {"answer_type": "strategic_judgement"}, } ) assert contract["turn_state"] == "answer_ready" assert contract["primary_surface"] == "answer_canvas" assert contract["interaction_request_can_show"] is False def test_freshness_filter_drops_stale_market_evidence_per_item() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="现在有没有值得看的新方向"), route_plan={}, turn_hypothesis={ "normalized_goal": "推荐值得继续看的新项目方向", "answer_shape": "strategic_judgement", "decision_type": "market_opportunity", "evidence_needs": ["external_fresh"], "freshness_required": True, }, ) plan = ExternalQueryPlan( query_kind="market", answer_contract="summary_with_sources", freshness_required=True, topic="新项目方向推荐", search_queries=["2026 startup opportunities"], ) filtered = _filter_fresh_external_evidence( evidence=[ ExternalEvidenceItem( source_type="url", title="过期行业材料", source_label="过期行业材料", target_url="https://example.com/2023-report", snippet="这份 2023 材料只描述旧年份情况。", ), ExternalEvidenceItem( source_type="url", title="2026 AI Agent Enterprise Workflow ROI Report", source_label="2026 AI Agent Enterprise Workflow ROI Report", target_url="https://example.com/2026-agent-report", snippet="2026 report shows enterprises are moving agentic workflows from pilots to production.", ), ], plan=plan, turn_state=state, ) assert [item.title for item in filtered] == ["2026 AI Agent Enterprise Workflow ROI Report"] assert any("stale_external_evidence" in item for item in state.evidence_state.stale_or_missing) def test_evidence_relevance_gate_uses_state_subject_not_scene_keyword() -> None: state = _service().build_initial_turn_state( request=RAGAskRequest(question="我想判断一个新方向"), route_plan={}, turn_hypothesis={ "normalized_goal": "判断新方向", "answer_shape": "strategic_judgement", "decision_type": "market_opportunity", "subject": "方向丁", "subject_terms": ["direction-delta", "assistant"], "evidence_needs": ["external_fresh"], }, ) accepted = _filter_relevant_external_evidence( evidence=[ ExternalEvidenceItem( title="PMFlow - AI Product Backlog Tool", target_url="https://www.pmflow.ai/", snippet="PMFlow helps product teams generate backlogs.", ), ExternalEvidenceItem( title="Direction Delta Assistant Market Report", target_url="https://example.com/direction-delta-assistant", snippet="Direction delta assistant tools focus on workflow guidance and risk disclosure.", ), ], turn_state=state, ) assert [item.title for item in accepted] == ["Direction Delta Assistant Market Report"]