from __future__ import annotations from types import SimpleNamespace from app.services.pmflow_ai_context_memory_service import ( _merge_memory, _memory_write_gate, _resolve_memory_conflict, _scope_refs, ) from app.services.pmflow_ai_context_pack_service import ( PMFlowAIContextPackBuilder, context_pack_memory_claim_rows, context_pack_retrieval_critic_packet, evaluate_context_replay_case, ) def _row(**kwargs): base = { "id": 11, "summary": "", "durable_facts": [], "open_loops": [], "decisions": [], "preferences": [], "object_refs": [], "confidence": "medium", "confidence_score": 0.6, "status": "active", "invalidation_reason": "", } base.update(kwargs) return SimpleNamespace(**base) def test_memory_write_gate_skips_duplicate_and_merges_new_delta() -> None: existing = _row( summary="用户要求 PMFlow AI 方案不要靠硬编码关键词和场景枚举。", durable_facts=[{"summary": "上下文管理要走统一 Context OS。"}], ) duplicate = { "summary": "用户要求 PMFlow AI 方案不要靠硬编码关键词和场景枚举。", "durable_facts": [{"summary": "上下文管理要走统一 Context OS。"}], "confidence": "medium", } duplicate_gate = _memory_write_gate(memory=duplicate, existing=existing) assert duplicate_gate["action"] == "skip" incoming = { "summary": "新增要求:长期记忆需要写入门禁、合并策略和回放评测。", "durable_facts": [{"summary": "新增 golden replay 覆盖记住、忘记、覆盖和预算。"}], "confidence": "high", } gate = _memory_write_gate(memory=incoming, existing=existing) assert gate["action"] == "update" assert gate["status"] == "active" merged = _merge_memory(existing=existing, incoming=incoming) assert "硬编码关键词" in merged["summary"] assert "长期记忆需要写入门禁" in merged["summary"] assert any("golden replay" in str(item) for item in merged["durable_facts"]) def test_memory_write_gate_invalidates_existing_without_keyword_scene_rules() -> None: existing = _row(summary="当前方向是 AI 采购平台,从发票自动比对切入。") incoming = { "summary": "用户明确推翻当前方向,要求旧方向不再作为后续判断依据。", "status": "invalidated", "invalidation_reason": "用户在最新对话中推翻了当前方向。", "confidence": "high", } gate = _memory_write_gate(memory=incoming, existing=existing) assert gate["action"] == "update" assert gate["status"] == "invalidated" assert gate["supersedes_memory_id"] == 11 assert "推翻" in gate["invalidation_reason"] assert gate["conflict_resolution"]["resolution"] == "invalidate" def test_memory_conflict_resolver_keeps_distinct_structured_delta_without_scene_keywords() -> None: existing = _row( public_memory_id="ctxmem_existing", summary="用户已确认首版需要项目执行包。", durable_facts=[{"summary": "首版需要项目执行包。"}], ) incoming = { "summary": "新增约束:执行包必须带验收清单和外部工具交接说明。", "durable_facts": [{"summary": "执行包必须带验收清单。"}], "confidence": "high", } resolution = _resolve_memory_conflict(memory=incoming, existing=existing, novelty_ratio=0.6, structured_delta=1) assert resolution["resolution"] == "keep_both" assert resolution["old_memory_id"] == "ctxmem_existing" assert resolution["structured_delta"] == 1 def test_scope_refs_do_not_mix_visible_project_when_current_project_is_explicit() -> None: refs = _scope_refs( thread=SimpleNamespace(id=3, public_thread_id="thread_abc"), current_user=SimpleNamespace(id=9), conversation_context={"conversation_scope": "space", "current_project_id": 101}, tenant_project_context={ "tenant_id": 1, "user_id": 9, "surface_context": { "visible_objects": [ {"object_type": "project", "object_id": 202, "display_name": "另一个项目"}, ] }, }, ) assert refs["thread"] == ["thread:thread_abc"] assert refs["user"] == ["user:9"] assert refs["project"] == ["project:101"] assert "project:202" not in refs["project"] def test_context_retrieval_critic_flags_low_overlap_memory_and_missing_thread_context() -> None: context_pack = PMFlowAIContextPackBuilder(budget_chars=9000).build( user_question="继续展开采购审批助手的 MVP 和执行包", thread_context_pack={}, conversation_context={"conversation_scope": "space"}, tenant_project_context={"tenant_id": 1, "user_id": 2, "surface_context": {}}, memory_context_pack={ "packet_version": "pmflow_ai_layered_memory_pack.v1", "scope_refs": {"user": ["user:2"]}, "memories": [ { "memory_id": "ctxmem_weather", "memory_scope": "user", "scope_ref": "user:2", "summary": "用户曾询问广州天气和线下安排。", "confidence_score": 0.8, } ], }, ) critic = context_pack_retrieval_critic_packet(context_pack, user_question="继续展开采购审批助手的 MVP 和执行包") assert any(item["kind"] == "no_same_thread_context" for item in critic["gaps"]) assert any(item["kind"] == "memory_present_but_low_question_overlap" for item in critic["risks"]) def test_context_budget_keeps_thread_and_memory_before_large_surface() -> None: thread_pack = { "packet_version": "pmflow_ai_thread_context_pack.v1", "recent_messages": [ {"message_id": "m1", "role": "assistant", "message_index": 2, "content": "前文确认 MVP 切口是发票自动比对。"} ], "relevant_messages": [ {"message_id": "m2", "role": "assistant", "message_index": 4, "content": "采购平台后续要补供应商匹配和合同分析。"} ], } memory_pack = { "packet_version": "pmflow_ai_layered_memory_pack.v1", "scope_refs": {"thread": ["thread:abc"]}, "memories": [ { "memory_id": "ctxmem_1", "memory_scope": "thread", "scope_ref": "thread:abc", "summary": "用户要求后续输出执行包,并避免硬编码关键词方案。", "confidence_score": 0.9, } ], } context_pack = PMFlowAIContextPackBuilder(budget_chars=8200).build( user_question="继续上面采购平台并拆执行包", thread_context_pack=thread_pack, conversation_context={"conversation_scope": "space"}, tenant_project_context={ "tenant_id": 1, "user_id": 2, "surface_context": { "space_runtime_context": { "space_operating_packet": {"large": "无关可见面板 " * 2000}, } }, }, memory_context_pack=memory_pack, ) replay = evaluate_context_replay_case( context_pack=context_pack, expected_evidence=["发票自动比对", "避免硬编码关键词方案"], ) assert replay["passed"] is True assert context_pack_memory_claim_rows(context_pack) statuses = {item["section"]: item["status"] for item in context_pack["budget_report"]["sections"]} assert statuses["thread_context"] in {"included", "compressed"} assert statuses["memory_context"] in {"included", "compressed"}