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,106 +0,0 @@
"""演化候选审核 API 测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.session import AsyncSessionLocal
from app.main import app
from app.models.big_cycle import BigCycle
from app.models.evolution import EvolutionCandidate
from app.repositories.json_utils import dumps
from app.services.characters import get_active_character_model
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
async def _seed_cycle_and_candidate(character_id: str, **overrides) -> tuple[str, str]:
base = {
"type": "procedural",
"source": "big_cycle",
"level": "L2",
"scope": "task_scope",
"title": "确认假设",
"proposal": "技术问题先确认前提",
"delta_json": dumps({"title": "确认假设", "content": "先确认前提"}),
"evidence_ids_json": "[]",
"confidence": 0.8,
"benefit_score": 0.7,
"risk_score": 0.2,
"drift_score": 0.0,
"auto_apply_allowed": False,
"status": "pending",
}
base.update(overrides)
async with AsyncSessionLocal() as session:
cycle = BigCycle(
character_id=character_id,
trigger="test",
status="completed",
memory_count_at_trigger=20,
)
session.add(cycle)
await session.flush()
candidate = EvolutionCandidate(character_id=character_id, cycle_id=cycle.id, **base)
session.add(candidate)
await session.commit()
return candidate.id, cycle.id
@pytest.mark.asyncio
async def test_list_evolution_candidates(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
await _seed_cycle_and_candidate(character.id)
await _seed_cycle_and_candidate(character.id, status="applied")
resp = await client.get(f"/api/v1/characters/{character.id}/evolution-candidates")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
pending = await client.get(
f"/api/v1/characters/{character.id}/evolution-candidates", params={"status": "pending"}
)
assert pending.json()["total"] == 1
@pytest.mark.asyncio
async def test_approve_pending_candidate_applies(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cand_id, _ = await _seed_cycle_and_candidate(character.id, type="procedural")
resp = await client.post(f"/api/v1/evolution-candidates/{cand_id}/approve")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "applied"
assert data["resulting_target_id"] is not None
@pytest.mark.asyncio
async def test_reject_pending_candidate(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cand_id, _ = await _seed_cycle_and_candidate(character.id)
resp = await client.post(f"/api/v1/evolution-candidates/{cand_id}/reject")
assert resp.status_code == 200
assert resp.json()["status"] == "rejected"
@pytest.mark.asyncio
async def test_approve_not_implemented_type_returns_error(client: AsyncClient) -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cand_id, _ = await _seed_cycle_and_candidate(character.id, type="tool_strategy", level="L2")
resp = await client.post(f"/api/v1/evolution-candidates/{cand_id}/approve")
assert resp.status_code in (400, 409, 501)
assert resp.json()["error"]["code"] == "not_implemented"
-176
View File
@@ -1,176 +0,0 @@
"""资料导入候选抽取测试(5b,异步后台 + mock ai_provider)。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.application.imports import drain_pending_extractions
from app.db.session import AsyncSessionLocal
from app.main import app
from app.models.user import User
from app.services.auth import create_access_token
async def _get_character_id(client: AsyncClient) -> str:
"""通过已认证的 API 获取当前用户活跃角色 ID。"""
resp = await client.get("/api/v1/characters/active")
assert resp.status_code == 200
return resp.json()["id"]
class _FakeChatResult:
def __init__(self, content: str) -> None:
self.content = content
_PROFILE_RESPONSE = (
'[{"field":"background","value":"来自北京,从事设计工作",'
'"confidence":0.9,"reason":"资料明确提及","evidence":[0,1]}]'
)
_MEMORY_RESPONSE = (
'[{"type":"preference","title":"喜欢简洁回答","content":"用户偏好简洁直接的回答",'
'"importance":0.8,"sensitivity":"low","reason":"多次提及","evidence":[0]}]'
)
_PERSONALITY_RESPONSE = (
'[{"field":"expression_style","change":"温和且有条理","reason":"资料体现","evidence":[1]}]'
)
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
async def fake_chat(messages, *, model_hint, **_kwargs):
if model_hint == "memory-extraction":
return _FakeChatResult(_MEMORY_RESPONSE)
system_text = str(messages[0].content) if messages else ""
if "人格" in system_text:
return _FakeChatResult(_PERSONALITY_RESPONSE)
return _FakeChatResult(_PROFILE_RESPONSE)
class _MockMessage:
def __init__(self, content: str):
self.content = content
class _MockManager:
async def chat(self, messages, model_hint=None):
return await fake_chat(messages, model_hint=model_hint)
def to_message_list(self, system, _messages):
return [_MockMessage(s) for s in system]
monkeypatch.setattr("app.application.imports._get_provider_manager", lambda: _MockManager())
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test", headers=auth_headers
) as http_client:
yield http_client
async def _create_import(client: AsyncClient, character_id: str) -> str:
resp = await client.post(
f"/api/v1/characters/{character_id}/imports",
json={
"material_type": "background_story",
"content": "我叫小魂,来自北京。\n\n我从事设计工作,喜欢简洁直接的回答。",
},
)
return resp.json()["id"]
@pytest.mark.asyncio
async def test_extract_profile_candidates(client: AsyncClient) -> None:
character_id = await _get_character_id(client)
import_id = await _create_import(client, character_id)
triggered = await client.post(
f"/api/v1/characters/{character_id}/imports/{import_id}/profile-candidates"
)
assert triggered.status_code == 200
assert triggered.json()["status"] == "extracting_profile"
await drain_pending_extractions()
candidates = (
await client.get(
f"/api/v1/characters/{character_id}/imports/{import_id}/profile-candidates"
)
).json()
assert len(candidates) == 1
assert candidates[0]["field"] == "background"
assert candidates[0]["status"] == "pending"
assert len(candidates[0]["evidence_ids"]) == 2 # 映射到 import_items.id
job = (await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}")).json()
assert job["status"] == "reviewing"
@pytest.mark.asyncio
async def test_extract_memory_candidates(client: AsyncClient) -> None:
character_id = await _get_character_id(client)
import_id = await _create_import(client, character_id)
await client.post(f"/api/v1/characters/{character_id}/imports/{import_id}/memory-candidates")
await drain_pending_extractions()
candidates = (
await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}/memory-candidates")
).json()
assert len(candidates) == 1
assert candidates[0]["type"] == "preference"
assert candidates[0]["title"] == "喜欢简洁回答"
@pytest.mark.asyncio
async def test_extract_personality_proposals(client: AsyncClient) -> None:
character_id = await _get_character_id(client)
import_id = await _create_import(client, character_id)
await client.post(
f"/api/v1/characters/{character_id}/imports/{import_id}/personality-proposals"
)
await drain_pending_extractions()
# 人格建议落入 personality_proposals(pending);无独立列表端点,以 job 进入 reviewing 验证
job = (await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}")).json()
assert job["status"] == "reviewing"
@pytest.mark.asyncio
async def test_extraction_failure_marks_job_failed(monkeypatch: pytest.MonkeyPatch) -> None:
async def failing_chat(*_args, **_kwargs):
raise RuntimeError("模型不可用")
class _MockMessage:
def __init__(self, content: str):
self.content = content
class _FailingManager:
async def chat(self, messages, model_hint=None):
return await failing_chat(messages, model_hint=model_hint)
def to_message_list(self, system, _messages):
return [_MockMessage(s) for s in system]
monkeypatch.setattr("app.application.imports._get_provider_manager", lambda: _FailingManager())
transport = ASGITransport(app=app)
async with AsyncSessionLocal() as session:
result = await session.execute(select(User).where(User.is_admin.is_(True)).limit(1))
admin = result.scalar_one_or_none()
assert admin is not None
token = create_access_token(subject=admin.id, extra={"owner_scope": admin.id})
headers = {"Authorization": f"Bearer {token}"}
async with AsyncClient(transport=transport, base_url="http://test", headers=headers) as client:
character_id = await _get_character_id(client)
import_id = await _create_import(client, character_id)
await client.post(
f"/api/v1/characters/{character_id}/imports/{import_id}/profile-candidates"
)
await drain_pending_extractions()
job = (await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}")).json()
assert job["status"] == "failed"
assert job["error_code"] == "extraction_failed"
-142
View File
@@ -1,142 +0,0 @@
"""资料导入候选审核测试(5c)。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.application.imports import drain_pending_extractions
from app.main import app
async def _get_character_id(client: AsyncClient) -> str:
"""通过已认证的 API 获取当前用户活跃角色 ID。"""
resp = await client.get("/api/v1/characters/active")
assert resp.status_code == 200
return resp.json()["id"]
class _FakeChatResult:
def __init__(self, content: str) -> None:
self.content = content
_PROFILE_RESPONSE = (
'[{"field":"background","value":"来自北京,从事设计工作",'
'"confidence":0.9,"reason":"资料提及","evidence":[0]}]'
)
_MEMORY_RESPONSE = (
'[{"type":"preference","title":"喜欢简洁回答","content":"用户偏好简洁直接的回答",'
'"importance":0.8,"sensitivity":"low","reason":"多次提及","evidence":[0]}]'
)
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
async def fake_chat(*_args, model_hint, **_kwargs):
if model_hint == "memory-extraction":
return _FakeChatResult(_MEMORY_RESPONSE)
return _FakeChatResult(_PROFILE_RESPONSE)
class _MockMessage:
def __init__(self, content: str):
self.content = content
class _MockManager:
async def chat(self, _messages, model_hint=None):
return await fake_chat(None, model_hint=model_hint)
def to_message_list(self, system, _messages):
return [_MockMessage(s) for s in system]
monkeypatch.setattr("app.application.imports._get_provider_manager", lambda: _MockManager())
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test", headers=auth_headers
) as http_client:
yield http_client
async def _setup(client: AsyncClient, kind: str) -> tuple[str, str, str]:
character_id = await _get_character_id(client)
import_id = (
await client.post(
f"/api/v1/characters/{character_id}/imports",
json={"material_type": "background_story", "content": "我叫小魂,来自北京。"},
)
).json()["id"]
await client.post(f"/api/v1/characters/{character_id}/imports/{import_id}/{kind}")
await drain_pending_extractions()
candidates = (
await client.get(f"/api/v1/characters/{character_id}/imports/{import_id}/{kind}")
).json()
return character_id, import_id, candidates[0]["id"]
@pytest.mark.asyncio
async def test_batch_review_profile_approve_writes_profile(client: AsyncClient) -> None:
character_id, _import_id, candidate_id = await _setup(client, "profile-candidates")
result = await client.post(
f"/api/v1/characters/{character_id}/profile-candidates/batch-review",
json={"decisions": [{"candidate_id": candidate_id, "decision": "approve"}]},
)
assert result.status_code == 200
assert result.json()["applied"] == 1
character = (await client.get(f"/api/v1/characters/{character_id}")).json()
assert character["profile"]["background"] == "来自北京,从事设计工作"
@pytest.mark.asyncio
async def test_batch_review_memory_approve_creates_memory(client: AsyncClient) -> None:
character_id, _import_id, candidate_id = await _setup(client, "memory-candidates")
result = await client.post(
f"/api/v1/characters/{character_id}/memory-candidates/batch-review",
json={"decisions": [{"candidate_id": candidate_id, "decision": "approve"}]},
)
assert result.json()["applied"] == 1
memories = (await client.get("/api/v1/memory")).json()
assert memories["total"] >= 1
assert any(m["title"] == "喜欢简洁回答" for m in memories["items"])
@pytest.mark.asyncio
async def test_batch_review_reject_marks_candidate(client: AsyncClient) -> None:
character_id, import_id, candidate_id = await _setup(client, "profile-candidates")
result = await client.post(
f"/api/v1/characters/{character_id}/profile-candidates/batch-review",
json={"decisions": [{"candidate_id": candidate_id, "decision": "reject"}]},
)
assert result.json()["rejected"] == 1
rejected = (
await client.get(
f"/api/v1/characters/{character_id}/imports/{import_id}/profile-candidates",
params={"status": "rejected"},
)
).json()
assert any(c["id"] == candidate_id for c in rejected)
@pytest.mark.asyncio
async def test_batch_review_skips_non_pending(client: AsyncClient) -> None:
character_id, _import_id, candidate_id = await _setup(client, "profile-candidates")
# 先 approve
await client.post(
f"/api/v1/characters/{character_id}/profile-candidates/batch-review",
json={"decisions": [{"candidate_id": candidate_id, "decision": "approve"}]},
)
# 再审同一候选(已 applied)→ skipped
result = await client.post(
f"/api/v1/characters/{character_id}/profile-candidates/batch-review",
json={"decisions": [{"candidate_id": candidate_id, "decision": "approve"}]},
)
assert result.json()["skipped"] == 1
assert result.json()["applied"] == 0
@@ -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
@@ -1,110 +0,0 @@
"""大周天记忆/世界抽取纯函数测试。"""
from app.modules.extraction.extract import (
MemoryCandidateDraft,
WorldEntityDraft,
parse_extraction,
)
def test_parse_standard_object() -> None:
raw = (
'{"memories":[{"type":"preference","title":"喜欢简洁","content":"用户偏好简洁",'
'"importance":0.8,"sensitivity":"low","confidence":0.7,"reason":"多次提及"}],'
'"entities":[{"type":"project","name":"项目X","attributes":{"status":"active"},'
'"state_type":"inference","confidence":0.6}]}'
)
memories, entities = parse_extraction(raw)
assert len(memories) == 1
assert memories[0].title == "喜欢简洁"
assert memories[0].type == "preference"
assert memories[0].importance == 0.8
assert len(entities) == 1
assert entities[0].name == "项目X"
assert entities[0].type == "project"
assert entities[0].attributes == {"status": "active"}
assert entities[0].state_type == "inference"
def test_parse_markdown_block() -> None:
raw = '```json\n{"memories":[],"entities":[]}\n```'
memories, entities = parse_extraction(raw)
assert memories == []
assert entities == []
def test_parse_text_mixed() -> None:
raw = '抽取结果:\n{"memories":[{"title":"t","content":"c"}],"entities":[]}\n以上'
memories, entities = parse_extraction(raw)
assert len(memories) == 1
assert entities == []
def test_parse_invalid_returns_empty() -> None:
assert parse_extraction("不是JSON") == ([], [])
assert parse_extraction("[]") == ([], []) # 数组不是对象
def test_missing_keys_returns_empty_lists() -> None:
memories, entities = parse_extraction('{"memories":[]}')
assert memories == []
assert entities == []
def test_skip_memory_missing_title_or_content() -> None:
raw = '{"memories":[{"title":"t"},{"content":"c"},{"title":"t","content":"c"}]}'
memories, _ = parse_extraction(raw)
assert len(memories) == 1
def test_skip_entity_missing_name() -> None:
raw = '{"entities":[{"type":"project"},{"name":"有名字"}]}'
_, entities = parse_extraction(raw)
assert len(entities) == 1
assert entities[0].name == "有名字"
def test_memory_safe_defaults() -> None:
raw = '{"memories":[{"title":"t","content":"c"}]}'
m = parse_extraction(raw)[0][0]
assert m.sensitivity == "low"
assert m.type == "other"
assert m.confidence == 0.5
assert m.importance == 0.5
def test_entity_safe_defaults() -> None:
raw = '{"entities":[{"name":"n"}]}'
e = parse_extraction(raw)[1][0]
assert e.state_type == "inference" # 非 fact,降权
assert e.type == "concept"
assert e.confidence == 0.5
assert e.attributes == {}
def test_clamp_values() -> None:
raw = (
'{"memories":[{"title":"t","content":"c","importance":1.5,"confidence":-0.2}],'
'"entities":[{"name":"n","confidence":2.0}]}'
)
memories, entities = parse_extraction(raw)
assert memories[0].importance == 1.0
assert memories[0].confidence == 0.0
assert entities[0].confidence == 1.0
def test_invalid_sensitivity_defaults() -> None:
raw = '{"memories":[{"title":"t","content":"c","sensitivity":"unknown"}]}'
assert parse_extraction(raw)[0][0].sensitivity == "low"
def test_invalid_state_type_defaults() -> None:
raw = '{"entities":[{"name":"n","state_type":"certain"}]}'
assert parse_extraction(raw)[1][0].state_type == "inference"
def test_draft_types() -> None:
raw = '{"memories":[{"title":"t","content":"c"}],"entities":[{"name":"n"}]}'
memories, entities = parse_extraction(raw)
assert isinstance(memories[0], MemoryCandidateDraft)
assert isinstance(entities[0], WorldEntityDraft)
@@ -1,34 +0,0 @@
"""大周天记忆/世界抽取 prompt 构建纯函数测试。"""
from app.modules.big_cycle.prompts import EvaluationSnapshot
from app.modules.extraction.prompts import build_extraction_messages
def _snapshot() -> EvaluationSnapshot:
return EvaluationSnapshot(
character_id="character_x",
memory_count=15,
recent_memories=[("用户偏好简洁", "用户偏好简洁直接的回答")],
personality_summary="身份:魂镜",
world_entity_count=4,
conclusion="近期对话涉及项目X,存在背景缺口",
)
def test_messages_return_system_user_pair() -> None:
system, user = build_extraction_messages(_snapshot())
assert isinstance(system, str) and isinstance(user, str)
assert system and user
def test_system_describes_memories_and_entities_structure() -> None:
system, _ = build_extraction_messages(_snapshot())
for token in ("memories", "entities", "state_type", "sensitivity", "importance"):
assert token in system
def test_user_includes_snapshot_signals() -> None:
_, user = build_extraction_messages(_snapshot())
assert "用户偏好简洁" in user
assert "身份:魂镜" in user
assert "15" in user
@@ -1,66 +0,0 @@
"""小周天每轮反思抽取纯函数测试。"""
from app.modules.extraction.extract import MemoryCandidateDraft
from app.modules.reflection.extract import parse_reflection
def test_parse_standard() -> None:
raw = (
'{"memories":[{"type":"preference","title":"喜欢简洁","content":"用户偏好简洁",'
'"importance":0.8,"sensitivity":"low","confidence":0.7,"reason":"多次提及"}],'
'"reflection":"本轮应更主动确认用户意图。"}'
)
memories, reflection = parse_reflection(raw)
assert len(memories) == 1
assert memories[0].title == "喜欢简洁"
assert isinstance(memories[0], MemoryCandidateDraft)
assert reflection == "本轮应更主动确认用户意图。"
def test_parse_markdown_block() -> None:
raw = '```json\n{"memories":[],"reflection":"反思文本"}\n```'
memories, reflection = parse_reflection(raw)
assert memories == []
assert reflection == "反思文本"
def test_parse_invalid_returns_empty() -> None:
assert parse_reflection("不是JSON") == ([], "")
assert parse_reflection("[]") == ([], "")
def test_missing_reflection_returns_empty_string() -> None:
memories, reflection = parse_reflection('{"memories":[]}')
assert memories == []
assert reflection == ""
def test_missing_memories_returns_empty_list() -> None:
memories, reflection = parse_reflection('{"reflection":"只有反思"}')
assert memories == []
assert reflection == "只有反思"
def test_memory_safe_defaults() -> None:
raw = '{"memories":[{"title":"t","content":"c"}],"reflection":"r"}'
m = parse_reflection(raw)[0][0]
assert m.sensitivity == "low"
assert m.confidence == 0.5
assert m.importance == 0.5
def test_skip_memory_missing_title_or_content() -> None:
raw = (
'{"memories":[{"title":"t"},{"content":"c"},{"title":"t","content":"c"}],"reflection":"r"}'
)
assert len(parse_reflection(raw)[0]) == 1
def test_clamp_values() -> None:
raw = '{"memories":[{"title":"t","content":"c","importance":1.5}],"reflection":"r"}'
assert parse_reflection(raw)[0][0].importance == 1.0
def test_reflection_strips_whitespace() -> None:
raw = '{"memories":[],"reflection":" 带空格的反思 "}'
assert parse_reflection(raw)[1] == "带空格的反思"
@@ -1,21 +0,0 @@
"""小周天反思 prompt 构建纯函数测试。"""
from app.modules.reflection.prompts import build_reflection_messages
def test_messages_return_system_user_pair() -> None:
system, user = build_reflection_messages("你好", "你好,我是魂镜")
assert isinstance(system, str) and isinstance(user, str)
assert system and user
def test_system_describes_memories_and_reflection() -> None:
system, _ = build_reflection_messages("u", "a")
for token in ("memories", "reflection", "importance", "sensitivity"):
assert token in system
def test_user_includes_dialogue() -> None:
_, user = build_reflection_messages("用户说的话", "助手的回复")
assert "用户说的话" in user
assert "助手的回复" in user
@@ -1,120 +0,0 @@
"""演化候选派生 applier 测试。"""
import pytest
from app.db.session import AsyncSessionLocal
from app.models.big_cycle import BigCycle
from app.models.evolution import EvolutionCandidate
from app.models.memory import Memory
from app.repositories import evolution as evolution_repo
from app.repositories import personality as personality_repo
from app.repositories.json_utils import dumps
from app.services import evolution as evolution_service
from app.services.characters import get_active_character_model
from app.services.personality import create_default_personality
async def _setup(session) -> tuple:
character = await get_active_character_model(session)
if await personality_repo.get_latest_personality(session, character.id) is None:
await create_default_personality(session, character.id)
await session.flush()
cycle = BigCycle(
character_id=character.id,
trigger="test",
status="completed",
memory_count_at_trigger=20,
)
session.add(cycle)
await session.flush()
return character, cycle
def _candidate(character_id: str, cycle_id: str, **overrides) -> EvolutionCandidate:
base = {
"character_id": character_id,
"cycle_id": cycle_id,
"type": "personality",
"source": "big_cycle",
"level": "L4",
"scope": "character_scope",
"title": "t",
"proposal": "p",
"delta_json": "{}",
"evidence_ids_json": "[]",
"confidence": 0.5,
"benefit_score": 0.5,
"risk_score": 0.5,
"drift_score": 0.0,
"auto_apply_allowed": False,
"status": "pending",
}
base.update(overrides)
return EvolutionCandidate(**base)
@pytest.mark.asyncio
async def test_apply_personality_candidate_derives_version() -> None:
async with AsyncSessionLocal() as session:
character, cycle = await _setup(session)
base_version = await personality_repo.get_latest_personality(session, character.id)
candidate = _candidate(
character.id,
cycle.id,
type="personality",
delta_json=dumps({"expression_style": "更简洁、更主动"}),
title="表达风格演化",
)
await evolution_repo.add_candidates(session, [candidate])
await session.flush()
target_id = await evolution_service.apply_evolution_candidate(session, candidate)
await session.commit()
assert target_id is not None
async with AsyncSessionLocal() as session:
new_version = await personality_repo.get_personality(session, target_id)
character2 = await get_active_character_model(session)
assert new_version is not None
assert new_version.version == base_version.version + 1
assert new_version.created_by == "big_cycle"
assert "更简洁" in new_version.expression_style
assert character2.current_personality_version_id == target_id
@pytest.mark.asyncio
async def test_apply_procedural_candidate_creates_memory() -> None:
async with AsyncSessionLocal() as session:
character, cycle = await _setup(session)
candidate = _candidate(
character.id,
cycle.id,
type="procedural",
level="L2",
delta_json=dumps({"title": "先确认关键假设", "content": "技术问题先确认前提再作答"}),
)
await evolution_repo.add_candidates(session, [candidate])
await session.flush()
target_id = await evolution_service.apply_evolution_candidate(session, candidate)
await session.commit()
assert target_id is not None
async with AsyncSessionLocal() as session:
mem = await session.get(Memory, target_id)
assert mem is not None
assert mem.type == "procedural"
assert mem.title == "先确认关键假设"
assert mem.source == "big_cycle"
@pytest.mark.asyncio
async def test_apply_unimplemented_type_returns_none() -> None:
async with AsyncSessionLocal() as session:
character, cycle = await _setup(session)
candidate = _candidate(character.id, cycle.id, type="tool_strategy", level="L2")
await evolution_repo.add_candidates(session, [candidate])
await session.flush()
result = await evolution_service.apply_evolution_candidate(session, candidate)
assert result is None
@@ -1,156 +0,0 @@
"""大周天抽取候选审核服务测试(记忆候选 + 世界实体候选)。"""
import pytest
from sqlalchemy import select
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.models.big_cycle import BigCycle
from app.models.candidates import MemoryCandidate
from app.models.memory import Memory
from app.models.world import CharacterWorldEntity
from app.services import extraction as extraction_service
from app.services.characters import get_active_character_model
async def _seed_cycle(session, character_id: str) -> str:
cycle = BigCycle(
character_id=character_id,
trigger="test",
status="completed",
memory_count_at_trigger=20,
)
session.add(cycle)
await session.flush()
return cycle.id
async def _seed_memory_candidate(
session, character_id: str, cycle_id: str, status: str = "pending"
) -> str:
cand = MemoryCandidate(
character_id=character_id,
cycle_id=cycle_id,
type="preference",
title="喜欢简洁",
content="用户偏好简洁直接的回答",
confidence=0.7,
importance=0.8,
sensitivity="low",
status=status,
)
session.add(cand)
await session.flush()
return cand.id
async def _seed_world_entity(session, character_id: str, status: str = "pending") -> str:
e = CharacterWorldEntity(
character_id=character_id,
type="project",
name="项目X",
state_type="inference",
confidence=0.6,
status=status,
)
session.add(e)
await session.flush()
return e.id
@pytest.mark.asyncio
async def test_approve_memory_candidate_derives_memory() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cycle_id = await _seed_cycle(session, character.id)
cid = await _seed_memory_candidate(session, character.id, cycle_id)
await session.commit()
resp = await extraction_service.approve_memory_proposal(cid)
assert resp.status == "applied"
async with AsyncSessionLocal() as session:
mem = (
await session.execute(
select(Memory).where(
Memory.character_id == character.id,
Memory.title == "喜欢简洁",
)
)
).scalar_one_or_none()
assert mem is not None
assert mem.source == "big_cycle"
@pytest.mark.asyncio
async def test_reject_memory_candidate() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cycle_id = await _seed_cycle(session, character.id)
cid = await _seed_memory_candidate(session, character.id, cycle_id)
await session.commit()
resp = await extraction_service.reject_memory_proposal(cid)
assert resp.status == "rejected"
@pytest.mark.asyncio
async def test_approve_non_pending_memory_conflicts() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cycle_id = await _seed_cycle(session, character.id)
cid = await _seed_memory_candidate(session, character.id, cycle_id, status="applied")
await session.commit()
with pytest.raises(AppError):
await extraction_service.approve_memory_proposal(cid)
@pytest.mark.asyncio
async def test_approve_world_entity_confirms() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
eid = await _seed_world_entity(session, character.id)
await session.commit()
resp = await extraction_service.approve_world_entity(eid)
assert resp.status == "confirmed"
@pytest.mark.asyncio
async def test_reject_world_entity() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
eid = await _seed_world_entity(session, character.id)
await session.commit()
resp = await extraction_service.reject_world_entity(eid)
assert resp.status == "rejected"
@pytest.mark.asyncio
async def test_list_memory_proposals_source_cycle() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cycle_id = await _seed_cycle(session, character.id)
await _seed_memory_candidate(session, character.id, cycle_id)
# 导入来源候选(import_job_id 非空)
session.add(
MemoryCandidate(
character_id=character.id,
import_job_id="import_test",
type="user",
title="导入记忆",
content="来自导入",
confidence=0.5,
importance=0.5,
sensitivity="low",
status="pending",
)
)
await session.commit()
page = await extraction_service.list_memory_proposals(character_id=character.id, source="cycle")
assert page.total == 1
assert page.items[0].title == "喜欢简洁"
assert page.items[0].cycle_id == cycle_id
-41
View File
@@ -214,47 +214,6 @@ async def test_world_entities_crud(client: AsyncClient) -> None:
assert gone.status_code in (200, 404)
@pytest.mark.asyncio
async def test_memory_list_with_filters(client: AsyncClient) -> None:
"""记忆列表支持类型/来源过滤——覆盖 services/memory.list_memory_page 路径。"""
m1 = await client.post(
"/api/v1/memory",
json={
"type": "observation",
"title": "观测记忆",
"content": "一条观测记忆",
"source": "observation",
},
)
assert m1.status_code == 200
m2 = await client.post(
"/api/v1/memory",
json={
"type": "reflection",
"title": "反思记忆",
"content": "一条反思记忆",
"source": "reflection",
},
)
assert m2.status_code == 200
# 按类型过滤
obs = await client.get("/api/v1/memory?type=observation")
assert obs.status_code == 200
assert all(item["type"] == "observation" for item in obs.json()["items"])
# 按来源过滤
ref = await client.get("/api/v1/memory?source=reflection")
assert ref.status_code == 200
assert all(item["source"] == "reflection" for item in ref.json()["items"])
# 总量 >= 2
all_mem = await client.get("/api/v1/memory")
assert all_mem.status_code == 200
assert all_mem.json()["total"] >= 2
@pytest.mark.asyncio
async def test_safety_policy_crud(client: AsyncClient) -> None:
"""安全策略 CRUD——覆盖 services/governance 部分路径。"""
-114
View File
@@ -1,114 +0,0 @@
"""记忆上下文组装测试。"""
from datetime import UTC, datetime, timedelta
from app.models.memory import Memory
from app.modules.memory.context import (
MemoryRetrievalResult,
build_memory_context,
)
def _make_memory(
*,
importance: float = 0.5,
hours_ago: float = 1.0,
title: str = "测试记忆",
content: str = "这是一条测试记忆内容。",
) -> Memory:
return Memory(
id=f"mem_{title}",
character_id="char_1",
type="observation",
title=title,
content=content,
importance=importance,
created_at=datetime.now(UTC) - timedelta(hours=hours_ago),
source="test",
confidence=1.0,
sensitivity="low",
evidence_ids_json="[]",
status="active",
)
def test_empty_memories_returns_none() -> None:
result = MemoryRetrievalResult(memories=[])
assert build_memory_context(result) is None
def test_single_memory_returns_text() -> None:
mem = _make_memory()
result = MemoryRetrievalResult(memories=[mem])
text = build_memory_context(result)
assert text is not None
assert "相关记忆:" in text
assert "测试记忆" in text
def test_high_importance_memory_comes_first() -> None:
low = _make_memory(importance=0.3, title="不重要的")
high = _make_memory(importance=0.9, title="重要的")
result = MemoryRetrievalResult(memories=[low, high])
text = build_memory_context(result)
assert text is not None
important_pos = text.index("重要的")
trivial_pos = text.index("不重要的")
assert important_pos < trivial_pos, "重要性高的应该排前面"
def test_memories_are_sorted_by_importance_descending() -> None:
"""传入任意顺序,输出始终重要性降序。"""
a = _make_memory(importance=0.5, title="中")
b = _make_memory(importance=0.1, title="低")
c = _make_memory(importance=0.9, title="高")
result = MemoryRetrievalResult(memories=[a, b, c])
text = build_memory_context(result)
assert text is not None
positions = {title: text.index(title) for title in ["高", "中", "低"]}
assert positions["高"] < positions["中"]
assert positions["中"] < positions["低"]
def test_importance_label_appears() -> None:
high = _make_memory(importance=0.9, title="重要记忆", content="重要内容")
medium = _make_memory(importance=0.5, title="普通记忆", content="普通内容")
low = _make_memory(importance=0.2, title="低优先级", content="低优先级内容")
result = MemoryRetrievalResult(memories=[high, medium, low])
text = build_memory_context(result)
assert text is not None
assert "★重要" in text
assert "●一般" in text
assert "○参考" in text
def test_time_labels() -> None:
just_now = _make_memory(title="刚刚发生", hours_ago=0.1)
hours_ago = _make_memory(title="几小时前", hours_ago=5)
days_ago = _make_memory(title="几天前", hours_ago=72)
result = MemoryRetrievalResult(memories=[just_now, hours_ago, days_ago])
text = build_memory_context(result)
assert text is not None
assert "刚刚" in text
assert "5小时前" in text or "几小时前" in text
assert "3天前" in text
def test_truncation_respects_max_length() -> None:
content = "A" * 500
mem = _make_memory(content=content, title="一长内容")
result = MemoryRetrievalResult(memories=[mem] * 20)
text = build_memory_context(result, max_length=500)
assert text is not None
assert len(text) <= 550 # max_length + header + padding
assert "已截断" in text
def test_large_content_not_exceeding_max_length() -> None:
"""大量记忆时,内容不应超过 max_length 太多。"""
mems = [_make_memory(content="B" * 200, title=f"记忆{i}", importance=0.5) for i in range(50)]
result = MemoryRetrievalResult(memories=mems)
text = build_memory_context(result, max_length=1000)
assert text is not None
assert len(text) <= 1200 # 1000 + header + some padding
+257
View File
@@ -0,0 +1,257 @@
"""息壤(M3)测试:对应 rewrite-plan M3 六条验收。
1. 未归真旁枝自动入忆率 = 0(只有归真/手动入忆产生候选);
2. 重忆未确认写入被拒(HJ-1201 语义:sensitive→pending,仅 confirm 后 active);
3. deny 两次退出检索;
4. 正影后旧版停止普通召回(superseded),内容不改写;
5. 删除后全链路停用(墓碑无原文、检索/注入不出现);
6. 召回带来源可展开(source_refs 指向归真记录);归真触发实录与提取候选。
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncGenerator
import httpx
import pytest
from httpx import ASGITransport, AsyncClient
from app.db.session import AsyncSessionLocal
from app.domains.jingsheng import bind_event_store
from app.kernel import Kernel, KernelEventLog
from app.services.event_store import DbEventStore
from app.shenshi_client import EchoShenshi
@pytest.fixture
async def client(
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
) -> AsyncGenerator[AsyncClient, None]:
from app.main import app
event_store = DbEventStore(AsyncSessionLocal)
bind_event_store(event_store)
app.state.kernel = Kernel(
shenshi=EchoShenshi(),
session_factory=AsyncSessionLocal,
event_log=KernelEventLog(event_store),
timeout_seconds=10.0,
)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
yield c
app.state.kernel = None
async def _tree_with_answer(client: AsyncClient, title: str) -> tuple[str, str, str, str]:
"""建树→问→答→返回 (tree_id, q, a, character_id)。"""
embryo = (
await client.post("/api/v1/embryo-drafts", json={"source": "original", "name": f"忆灵{title}"})
).json()
character_id = (
(await client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")).json()["character_id"]
)
tree = (
await client.post("/api/v1/trees", json={"character_id": character_id, "title": title})
).json()
q = (await client.post(f"/api/v1/trees/{tree['id']}/nodes", json={"content": "你记得什么"})).json()
spawned = (await client.post(f"/api/v1/nodes/{q['id']}/answers", json={})).json()
for _ in range(300):
status = (await client.get(f"/api/v1/runs/{spawned['run_id']}")).json()["status"]
if status in ("completed", "interrupted", "failed"):
break
await asyncio.sleep(0.02)
return tree["id"], q["id"], spawned["node_id"], character_id
async def _memories(client: AsyncClient, character_id: str) -> list[dict]:
return (await client.get(f"/api/v1/characters/{character_id}/memories")).json()
async def _canonize(client: AsyncClient, tree_id: str, endpoint: str, scope: str = "reality") -> dict:
resp = await client.post(
f"/api/v1/trees/{tree_id}/canonize",
json={"endpoint_node_id": endpoint, "canonical_scope": scope},
)
assert resp.status_code == 200, resp.text
return resp.json()
async def _wait_candidate(client: AsyncClient, character_id: str, timeout_s: float = 5.0) -> list[dict]:
"""等待归真后的异步提取落库。"""
for _ in range(int(timeout_s / 0.05)):
items = await _memories(client, character_id)
if items:
return items
await asyncio.sleep(0.05)
return []
# ── 验收 1:未归真不入忆 ──────────────────────────────────────────────
async def test_uncanonized_branch_never_memorized(client: AsyncClient) -> None:
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "旁支树")
# 未归真:多次另答也不产生任何记忆
for _ in range(2):
spawned = (await client.post(f"/api/v1/nodes/{_q}/answers", json={})).json()
for _ in range(200):
if (await client.get(f"/api/v1/runs/{spawned['run_id']}")).json()["status"] in (
"completed",
"interrupted",
):
break
await asyncio.sleep(0.02)
await asyncio.sleep(0.2)
assert await _memories(client, character_id) == [] # 自动入忆率 = 0
# ── 验收 6+管线:归真→实录+候选;重敏 pending ──────────────────────────────────────────────
async def test_canonization_creates_factual_record_and_candidates(client: AsyncClient) -> None:
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "归真树")
await _canonize(client, tree_id, answer_id)
# 实录(同步落库)
records = (await client.get(f"/api/v1/characters/{character_id}/factual-records")).json()
assert len(records) == 1
assert records[0]["evidence"]["path_nodes"]
# 候选(异步提取;EchoShenshi 无 JSON → 确定性回落单条 episodic 候选)
items = await _wait_candidate(client, character_id)
assert len(items) == 1
memory = items[0]
assert memory["tier"] == "explicit"
assert memory["source"] == "self_inferred"
assert memory["status"] in ("candidate", "active")
# 召回透明:来源指向归真记录,可展开
assert memory["source_refs"]
# ── 验收 2:重忆未确认不入 active ──────────────────────────────────────────────
async def test_sensitive_requires_confirmation(client: AsyncClient) -> None:
from app.domains.xirang import service as xirang_service
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "重忆树")
await _canonize(client, tree_id, answer_id)
await _wait_candidate(client, character_id)
# 直接放置一条重忆候选(提取管线同路径)
placed = await xirang_service.place_candidates(
character_id=character_id,
candidates=[{"content": "用户的健康隐私:对花粉过敏", "type": "persona", "sensitive": True}],
source_refs=["canon_x"],
source="self_inferred",
)
sensitive = next(m for m in placed if m.tier == "sensitive")
assert sensitive.status == "pending" # 未确认绝不 active
# 检索不召回 pending
recalled = await xirang_service.recall_for_generation(character_id, "花粉 过敏")
assert all(m.id != sensitive.id for m in recalled)
# confirm 后进入检索
resp = await client.post(f"/api/v1/memories/{sensitive.id}/confirm")
assert resp.status_code == 200 and resp.json()["status"] == "active"
recalled = await xirang_service.recall_for_generation(character_id, "花粉 过敏")
assert any(m.id == sensitive.id for m in recalled)
# ── 验收 3+5:deny/删除全链路停用 ──────────────────────────────────────────────
async def test_deny_and_delete_exit_everywhere(client: AsyncClient) -> None:
from app.domains.xirang import service as xirang_service
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "删除树")
await _canonize(client, tree_id, answer_id)
items = await _wait_candidate(client, character_id)
memory_id = items[0]["id"]
denied = await client.post(f"/api/v1/memories/{memory_id}/deny")
assert denied.status_code == 200 and denied.json()["exited_retrieval"] is True
# 墓碑:列表可见状态但无原文;检索不出现
listed = await _memories(client, character_id)
tomb = next(m for m in listed if m["id"] == memory_id)
assert tomb["status"] == "tombstoned" and tomb["content"] == ""
recalled = await xirang_service.recall_for_generation(character_id, "共同经历")
assert all(m.id != memory_id for m in recalled)
# ── 验收 4:正影旧版停召、内容不改写 ──────────────────────────────────────────────
async def test_correction_supersedes_old_version(client: AsyncClient) -> None:
from app.domains.xirang import service as xirang_service
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "正影树")
await _canonize(client, tree_id, answer_id)
items = await _wait_candidate(client, character_id)
memory_id = items[0]["id"]
corrected = await client.post(
f"/api/v1/memories/{memory_id}/correct", json={"new_content": "正影后的准确版本"}
)
assert corrected.status_code == 200
new_id = corrected.json()["id"]
assert corrected.json()["prev_version_id"] == memory_id
# 旧版 superseded 且不再被召回;新版生效;旧内容保留在行内(不改写历史)
recalled = await xirang_service.recall_for_generation(character_id, "正影")
ids = [m.id for m in recalled]
assert new_id in ids and memory_id not in ids
async with AsyncSessionLocal() as session:
from app.models.xirang import Memory
old = await session.get(Memory, memory_id)
assert old.status == "superseded"
assert "共同经历" in old.content # 原文未被改写
# ── 手动入忆:必须标注类型 ──────────────────────────────────────────────
async def test_manual_memorize_requires_label(client: AsyncClient) -> None:
tree_id, q, answer_id, _ = await _tree_with_answer(client, "手记树")
bad = await client.post(f"/api/v1/nodes/{q}/memorize", json={"label": "unknown"})
assert bad.status_code == 422 or bad.status_code == 400
ok = await client.post(f"/api/v1/nodes/{q}/memorize", json={"label": "insight"})
assert ok.status_code == 201
body = ok.json()
assert body["type"] == "fact_candidate" # 未归真内容只作事实候选并显式标注
assert body["content"].startswith("[insight]")
# ── 注入:获准记忆进入投影 prompt ──────────────────────────────────────────────
async def test_recalled_memory_injected_into_prompt(client: AsyncClient) -> None:
tree_id, _q, answer_id, character_id = await _tree_with_answer(client, "注入树")
await _canonize(client, tree_id, answer_id)
await _wait_candidate(client, character_id)
# 新问节触发另答 → llm.requested 的 prompt 含【获准记忆】段
await client.post(f"/api/v1/trees/{tree_id}/adopt", json={"node_id": answer_id})
q2 = (
await client.post(f"/api/v1/trees/{tree_id}/nodes", json={"content": "再谈谈"})
).json()
spawned = (await client.post(f"/api/v1/nodes/{q2['id']}/answers", json={})).json()
for _ in range(300):
if (await client.get(f"/api/v1/runs/{spawned['run_id']}")).json()["status"] in (
"completed",
"interrupted",
):
break
await asyncio.sleep(0.02)
events = (await client.get(f"/api/v1/runs/{spawned['run_id']}/events")).json()
requested = next(e for e in events if e["type"] == "llm.requested")
prompt = requested["payload"]["request"]["messages"][-1]["content"]
assert "【获准记忆(来源可查)】" in prompt
_ = json # 保留 json 引用供调试断言使用