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
|
||||
Reference in new issue
Block a user