M3a:息壤三级记忆域——归真触发提取管线/白名单明忆/重忆确认/正影版本链/删除墓碑/双通道召回注入,退役旧记忆-抽取-反思-大周天-演化管线(迁移 004,验收 7 用例)

This commit is contained in:
chenxin committed 2026-08-27 09:16:32 +08:00
1 parent 2a640a901b
commit 9c98c2f85e
69 files changed
+1193 -6033

No files matched your search

@@ -1,64 +0,0 @@
"""大周天循环骨架测试(触发 + 阶段化执行)。"""
import pytest
from app.application.big_cycle import drain_pending_big_cycles, maybe_trigger_big_cycle
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.repositories import big_cycle as big_cycle_repo
from app.services.characters import get_active_character_model
async def _seed_memories(character_id: str, count: int) -> None:
async with AsyncSessionLocal() as session:
for i in range(count):
session.add(
Memory(
character_id=character_id,
type="user",
title=f"记忆{i}",
content=f"内容{i}",
)
)
await session.commit()
@pytest.mark.asyncio
async def test_big_cycle_runs_when_threshold_met() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_memories(character.id, 20)
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
assert cycle_id is not None
await drain_pending_big_cycles()
async with AsyncSessionLocal() as session:
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
assert cycle is not None
assert cycle.status == "completed"
assert cycle.memory_count_at_trigger >= 20
@pytest.mark.asyncio
async def test_big_cycle_skips_below_threshold() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
# 默认人物 0 记忆,低于阈值
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
assert cycle_id is None
@pytest.mark.asyncio
async def test_big_cycle_respects_min_interval() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_memories(character.id, 20)
first = await maybe_trigger_big_cycle(character.id, trigger="test")
assert first is not None
await drain_pending_big_cycles()
# 间隔窗口内再次触发 → 跳过
second = await maybe_trigger_big_cycle(character.id, trigger="test")
assert second is None
@@ -1,118 +0,0 @@
"""大周天内驱力生成测试(_phase_evaluation 子步骤 _generate_drive)。"""
import pytest
from app.application.big_cycle import drain_pending_big_cycles, maybe_trigger_big_cycle
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.repositories import drive as drive_repo
from app.services.characters import get_active_character_model
_DRIVE_RESPONSE = (
'[{"question":"用户为何频繁提到项目X","source":"memory_gap",'
'"expected_value":0.7,"urgency":0.5,"risk_level":"L2",'
'"related_entities":["项目X"],'
'"subgoals":[{"title":"询问项目X目标","description":"主动询问用户",'
'"expected_information_gain":0.6,"interruption_cost":0.3,"risk_level":"L4"}]}]'
)
async def _seed_memories(character_id: str, count: int) -> None:
async with AsyncSessionLocal() as session:
for i in range(count):
session.add(
Memory(
character_id=character_id,
type="user",
title=f"记忆{i}",
content=f"内容{i}",
)
)
await session.commit()
@pytest.fixture
def mock_drive_chat(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_chat(*_args, **_kwargs):
return _DRIVE_RESPONSE
monkeypatch.setattr("app.application.big_cycle._call_model", fake_chat)
@pytest.mark.asyncio
async def test_big_cycle_generates_curiosity_and_subgoal(
mock_drive_chat, # noqa: ARG001 - pytest fixture 靠 monkeypatch 副作用生效
) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_memories(character.id, 20)
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
assert cycle_id is not None
await drain_pending_big_cycles()
async with AsyncSessionLocal() as session:
curiosities, c_total = await drive_repo.list_curiosities(session, character.id)
subgoals, s_total = await drive_repo.list_subgoals(session, character.id)
assert c_total == 1
assert curiosities[0].question == "用户为何频繁提到项目X"
assert curiosities[0].source == "memory_gap"
assert curiosities[0].cycle_id == cycle_id
assert s_total == 1
assert subgoals[0].status == "candidate"
assert subgoals[0].curiosity_question_id == curiosities[0].id
assert subgoals[0].title == "询问项目X目标"
@pytest.mark.asyncio
async def test_big_cycle_auto_approves_low_risk_subgoal(
mock_drive_chat, # noqa: ARG001
) -> None:
"""低风险次生目标(L0-L2)应在大周天中自动通过并生成 Goal。"""
from sqlalchemy import select
from app.models.governance import Goal
# 使用低风险的子目标响应
_low_risk_response = (
'[{"question":"还有哪些未知领域","source":"world_uncertainty",'
'"expected_value":0.6,"urgency":0.4,"risk_level":"L1",'
'"subgoals":[{"title":"探索未知领域","description":"自动探索",'
'"expected_information_gain":0.5,"interruption_cost":0.2,"risk_level":"L1"}]}]'
)
async def fake_low_risk(*_args, **_kwargs):
return _low_risk_response
import app.application.big_cycle as bc
original = bc._call_model
bc._call_model = fake_low_risk
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_memories(character.id, 20)
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
assert cycle_id is not None
await drain_pending_big_cycles()
bc._call_model = original
async with AsyncSessionLocal() as session:
subgoals, s_total = await drive_repo.list_subgoals(session, character.id)
assert s_total == 1
assert subgoals[0].status in ("approved", "auto_execute_pending"), (
f"预期 approved 或 auto_execute_pending,实际={subgoals[0].status}"
)
# 验证自动生成了 Goal
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Goal).where(Goal.character_id == character.id)
)
goals = result.scalars().all()
matching = [g for g in goals if g.title == "探索未知领域"]
assert len(matching) == 1
assert matching[0].level == "task"
@@ -1,142 +0,0 @@
"""大周天候选生成 + 半自动应用调度测试。"""
import pytest
from app.application.big_cycle import drain_pending_big_cycles, maybe_trigger_big_cycle
from app.core.config import settings
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.repositories import big_cycle as big_cycle_repo
from app.repositories import evolution as evolution_repo
from app.services.characters import get_active_character_model
_MIXED_RESPONSE = (
"["
'{"type":"personality","level":"L4","scope":"character_scope",'
'"title":"身份演化","proposal":"调整身份表达",'
'"delta":{"identity_profile":"新的身份描述"},'
'"confidence":0.7,"benefit_score":0.6,"risk_score":0.4,'
'"auto_apply_allowed":false,"evidence":[0]},'
'{"type":"procedural","level":"L2","scope":"task_scope",'
'"title":"确认假设","proposal":"技术问题先确认前提",'
'"delta":{"title":"确认假设","content":"先确认前提再作答"},'
'"confidence":0.8,"benefit_score":0.7,"risk_score":0.2,'
'"auto_apply_allowed":true,"evidence":[0]}'
"]"
)
async def _seed_memories(character_id: str, count: int) -> None:
async with AsyncSessionLocal() as session:
for i in range(count):
session.add(
Memory(
character_id=character_id,
type="user",
title=f"记忆{i}",
content=f"内容{i}",
)
)
await session.commit()
@pytest.fixture
def mock_evolution_chat(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_chat(*_args, **_kwargs):
return _MIXED_RESPONSE
monkeypatch.setattr("app.application.big_cycle._call_model", fake_chat)
@pytest.mark.asyncio
async def test_big_cycle_generates_candidates_and_auto_applies(
mock_evolution_chat, # noqa: ARG001 - pytest fixture 靠 monkeypatch 副作用生效
) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_memories(character.id, 20)
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
assert cycle_id is not None
await drain_pending_big_cycles()
async with AsyncSessionLocal() as session:
cycle = await big_cycle_repo.get_cycle(session, cycle_id)
candidates, total = await evolution_repo.list_candidates(session, character.id)
assert cycle is not None
assert cycle.status == "completed"
assert cycle.evaluation_snapshot_json is not None
assert total == 2
personality = next(c for c in candidates if c.type == "personality")
assert personality.status == "pending" # L4 auto_apply_allowed=false
assert personality.level == "L4"
assert personality.source == "big_cycle"
assert personality.cycle_id == cycle_id
procedural = next(c for c in candidates if c.type == "procedural")
assert procedural.status == "applied" # L2 auto_apply_allowed=true
assert procedural.resulting_target_id is not None
@pytest.mark.asyncio
async def test_auto_apply_degrades_when_applier_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
response = (
'[{"type":"tool_strategy","level":"L2","scope":"task_scope",'
'"title":"工具策略","proposal":"偏好shell",'
'"delta":{},"auto_apply_allowed":true}]'
)
async def fake_chat(*_args, **_kwargs):
return response
monkeypatch.setattr("app.application.big_cycle._call_model", fake_chat)
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_memories(character.id, 20)
await maybe_trigger_big_cycle(character.id, trigger="test")
await drain_pending_big_cycles()
async with AsyncSessionLocal() as session:
candidates, _ = await evolution_repo.list_candidates(session, character.id)
assert len(candidates) == 1
cand = candidates[0]
assert cand.status == "pending" # applier 缺失,降级不自动应用
assert cand.auto_apply_allowed is True
@pytest.mark.asyncio
async def test_big_cycle_uses_configured_model_hint(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict = {}
result = type("FakeResponse", (), {"content": "[]"})()
class _FakeManager:
def to_message_list(self, *_args: object, **_kwargs: object) -> list:
return []
async def chat(self, messages: object, model_hint: str | None = None) -> object: # noqa: ARG002
captured["model_hint"] = model_hint
return result
def fake_get_provider_manager() -> _FakeManager:
return _FakeManager()
monkeypatch.setattr(
"app.application.big_cycle._get_provider_manager", fake_get_provider_manager
)
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_memories(character.id, 20)
await maybe_trigger_big_cycle(character.id, trigger="test")
await drain_pending_big_cycles()
assert captured["model_hint"] == settings.big_cycle_model
@@ -1,76 +0,0 @@
"""大周天记忆/世界抽取生成测试(_phase_memory 阶段)。
高置信度(≥0.7)低敏感度记忆候选自动落地到 memories 表;
世界实体候选(confidence=0.6)低于阈值仍留 pending。
"""
import pytest
from app.application.big_cycle import drain_pending_big_cycles, maybe_trigger_big_cycle
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.repositories import world as world_repo
from app.services.characters import get_active_character_model
_EXTRACTION_RESPONSE = (
'{"memories":[{"type":"preference","title":"喜欢简洁","content":"用户偏好简洁",'
'"importance":0.8,"sensitivity":"low","confidence":0.7}],'
'"entities":[{"type":"project","name":"项目X","attributes":{"status":"active"},'
'"state_type":"inference","confidence":0.6}]}'
)
async def _seed_memories(character_id: str, count: int) -> None:
async with AsyncSessionLocal() as session:
for i in range(count):
session.add(
Memory(
character_id=character_id,
type="user",
title=f"记忆{i}",
content=f"内容{i}",
)
)
await session.commit()
@pytest.fixture
def mock_extraction_chat(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_chat(*_args, **_kwargs):
return _EXTRACTION_RESPONSE
monkeypatch.setattr("app.application.big_cycle._call_model", fake_chat)
@pytest.mark.asyncio
async def test_big_cycle_extracts_memory_and_world(
mock_extraction_chat, # noqa: ARG001 - pytest fixture 靠 monkeypatch 副作用生效
) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_memories(character.id, 20)
cycle_id = await maybe_trigger_big_cycle(character.id, trigger="test")
assert cycle_id is not None
await drain_pending_big_cycles()
async with AsyncSessionLocal() as session:
# 高置信度(≥0.7)低敏感度记忆自动落地到 memories 表
from sqlalchemy import select
result = await session.execute(
select(Memory).where(
Memory.character_id == character.id,
Memory.source == "big_cycle",
)
)
auto_memories = result.scalars().all()
# 世界实体候选(confidence=0.6 < 0.7)仍留 pending
entities, _ = await world_repo.list_entities(session, character.id, status="pending")
assert len(auto_memories) == 1
assert auto_memories[0].title == "喜欢简洁"
assert len(entities) == 1
assert entities[0].name == "项目X"
assert entities[0].status == "pending"
assert entities[0].state_type == "inference"
@@ -1,171 +0,0 @@
"""大周天中的目标进度更新测试。
验证 _phase_goal_progress 在独立环境中按预期工作:
- 活跃的 task/session 目标进度 +0.1
- 进度上限 1.0
- 非活跃目标不受影响
- 进度达 1.0 的 task 目标状态标记为 completed
"""
import pytest
from app.application.big_cycle import _phase_goal_progress
from app.db.session import AsyncSessionLocal
from app.models.big_cycle import BigCycle
from app.models.governance import Goal
@pytest.mark.asyncio
async def test_goal_progress_increments_active_tasks() -> None:
"""task 级别活跃目标在大周天后进度增加 0.1。"""
async with AsyncSessionLocal() as session:
cycle = BigCycle(character_id="char_pg1", trigger="test")
session.add(cycle)
goal = Goal(
character_id="char_pg1",
title="测试任务",
level="task",
status="active",
progress=0.3,
)
session.add(goal)
await session.flush()
await _phase_goal_progress(session, cycle)
assert goal.progress == 0.4
@pytest.mark.asyncio
async def test_goal_progress_caps_at_one() -> None:
"""进度达到 1.0 后不再增加。"""
async with AsyncSessionLocal() as session:
cycle = BigCycle(character_id="char_pg2", trigger="test")
session.add(cycle)
goal = Goal(
character_id="char_pg2",
title="已满进度",
level="task",
status="active",
progress=1.0,
)
session.add(goal)
await session.flush()
await _phase_goal_progress(session, cycle)
# 已达上限,不再增加
assert goal.progress == 1.0
@pytest.mark.asyncio
async def test_goal_progress_skips_non_active() -> None:
"""非活跃目标不被更新。"""
async with AsyncSessionLocal() as session:
cycle = BigCycle(character_id="char_pg3", trigger="test")
session.add(cycle)
goal = Goal(
character_id="char_pg3",
title="已完成任务",
level="task",
status="completed",
progress=0.5,
)
session.add(goal)
await session.flush()
await _phase_goal_progress(session, cycle)
assert goal.progress == 0.5
@pytest.mark.asyncio
async def test_goal_progress_skips_highest_level() -> None:
"""highest 级别目标不被大周天自动更新。"""
async with AsyncSessionLocal() as session:
cycle = BigCycle(character_id="char_pg4", trigger="test")
session.add(cycle)
goal = Goal(
character_id="char_pg4",
title="最高使命",
level="highest",
type="mission",
status="active",
progress=0.5,
)
session.add(goal)
await session.flush()
await _phase_goal_progress(session, cycle)
assert goal.progress == 0.5
@pytest.mark.asyncio
async def test_goal_progress_only_updates_own_character() -> None:
"""仅更新当前 character 的目标,不影响其他角色。"""
async with AsyncSessionLocal() as session:
cycle = BigCycle(character_id="char_pg5", trigger="test")
session.add(cycle)
goal_own = Goal(
character_id="char_pg5",
title="自己任务",
level="task",
status="active",
progress=0.0,
)
goal_other = Goal(
character_id="char_other",
title="他人任务",
level="task",
status="active",
progress=0.0,
)
session.add_all([goal_own, goal_other])
await session.flush()
await _phase_goal_progress(session, cycle)
assert goal_own.progress == 0.1
assert goal_other.progress == 0.0
@pytest.mark.asyncio
async def test_goal_progress_completes_task_at_one() -> None:
"""task 级别目标进度达到 1.0 后状态变为 completed。"""
async with AsyncSessionLocal() as session:
cycle = BigCycle(character_id="char_pg6", trigger="test")
session.add(cycle)
goal = Goal(
character_id="char_pg6",
title="即将完成",
level="task",
status="active",
progress=0.95,
)
session.add(goal)
await session.flush()
await session.commit()
cycle_id = cycle.id
goal_id = goal.id
# 清理可能被前序测试设置的 event_store,避免 emit_goal_event 的独立 session 冲突
from app.main import app as _app
_app.state.__dict__.pop("event_store", None)
# 关闭原 session 后调用 _phase_goal_progress,避免 emit_goal_event
# 在 SQLite 单文件模式下产生写锁冲突时,progress 更新仍应正确提交。
async with AsyncSessionLocal() as session:
cycle_ref = await session.get(BigCycle, cycle_id)
try: # noqa: SIM105 — emit_goal_event 失败不影响 goal 更新
await _phase_goal_progress(session, cycle_ref)
except Exception:
pass
await session.commit()
async with AsyncSessionLocal() as session:
goal_ref = await session.get(Goal, goal_id)
assert goal_ref is not None
assert goal_ref.progress == 1.0
assert goal_ref.status == "completed"
@@ -1,100 +0,0 @@
"""小周天每轮记忆候选 + 反思抽取测试。
高置信度(≥0.7)低敏感度记忆候选直接落地到 memories 表而非 MemoryCandidate。
"""
import pytest
from sqlalchemy import select
from app.application.reflection import (
drain_pending_reflections,
start_reflection_after_turn,
)
from app.db.session import AsyncSessionLocal
from app.models.memory import Memory
from app.repositories import candidates as candidates_repo
from app.services.characters import get_active_character_model
_REFLECTION_RESPONSE = (
'{"memories":[{"type":"preference","title":"喜欢简洁","content":"用户偏好简洁",'
'"importance":0.8,"sensitivity":"low","confidence":0.7}],'
'"reflection":"本轮应更主动确认用户意图。"}'
)
@pytest.fixture
def mock_reflection_chat(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_chat(*_args, **_kwargs):
return _REFLECTION_RESPONSE
monkeypatch.setattr("app.application.reflection._call_model", fake_chat)
@pytest.mark.asyncio
async def test_reflection_extracts_memory_and_writes_reflection(
mock_reflection_chat, # noqa: ARG001
) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
start_reflection_after_turn(character.id, "session_test", "你好", "你好,我是魂镜。")
await drain_pending_reflections()
async with AsyncSessionLocal() as session:
# 高置信度记忆直接落地到 memories 表(非 MemoryCandidate)
auto_mem = (
await session.execute(
select(Memory).where(
Memory.character_id == character.id,
Memory.type == "preference",
Memory.source == "reflection",
)
)
).scalar_one_or_none()
# 反思文本也落地为 memories 表
ref_mem = (
await session.execute(
select(Memory).where(
Memory.character_id == character.id,
Memory.type == "reflection",
)
)
).scalar_one_or_none()
assert auto_mem is not None
assert auto_mem.title == "喜欢简洁"
assert ref_mem is not None
assert ref_mem.source == "reflection"
assert ref_mem.status == "active" # Memory 无 status 字段,用 default
assert "确认用户意图" in ref_mem.content
@pytest.mark.asyncio
async def test_reflection_without_memories_only_writes_reflection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def fake_chat(*_args, **_kwargs):
return '{"memories":[],"reflection":"只反思不抽记忆。"}'
monkeypatch.setattr("app.application.reflection._call_model", fake_chat)
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
start_reflection_after_turn(character.id, "session_test", "hi", "你好。")
await drain_pending_reflections()
async with AsyncSessionLocal() as session:
cands = await candidates_repo.list_memory_candidates(session, character.id, source="cycle")
ref_mem = (
await session.execute(
select(Memory).where(
Memory.character_id == character.id,
Memory.type == "reflection",
)
)
).scalar_one_or_none()
# 无记忆候选(空 memories 数组),只有反思
assert len(cands) == 0
assert ref_mem is not None
assert "只反思" in ref_mem.content