PMFlow/backend/tests/test_pmflow_ai_context_memo...

193 lines
7.7 KiB
Python

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