1392 lines
57 KiB
Python
1392 lines
57 KiB
Python
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"]
|