M3a:息壤三级记忆域——归真触发提取管线/白名单明忆/重忆确认/正影版本链/删除墓碑/双通道召回注入,退役旧记忆-抽取-反思-大周天-演化管线(迁移 004,验收 7 用例)
This commit is contained in:
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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 部分路径。"""
|
||||
|
||||
@@ -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
|
||||
@@ -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 引用供调试断言使用
|
||||
Reference in new issue
Block a user