PMFlow/backend/tests/test_pmflow_ai_turn_runtime.py

1392 lines
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, _work_log_meta
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("external_fresh" in query or "direction-alpha" in query for query in queries)
assert all("market opportunity" not in query and "risk regulation" not 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,
wall_clock_duration_ms=1960,
route_key="turn_runtime_state_answer",
)
assert patched["total_duration_ms"] == 1800
assert patched["wall_clock_duration_ms"] == 1960
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_work_log_meta_uses_durable_wall_clock_duration() -> None:
meta = _work_log_meta(
[
{"id": "step_1", "duration_ms": 90_000},
{"id": "step_2", "duration_ms": 45_000},
],
process_packet={"total_duration_ms": 135_000, "wall_clock_duration_ms": 205_000},
)
assert meta["total_duration_ms"] == 205_000
assert meta["wall_clock_duration_ms"] == 205_000
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"]