M6:缘契、人格与世界——契约版本链、铸魂分灵、河图境界与三界分流
- 缘契域 domains/yuanqi:covenants(当前版)+ covenant_events(不可变历史);提议—问心—铭契—撤回全链;边界变更立即生效;多维关系并存,服务层拒绝 affinity/好感度单一键;浪漫维度成人资格标记位 - 道体铸魂与分灵 domains/jingsheng/soul.py:soul_forgings 确认流(锚点变更 pending→confirmed 应用并签发新魂籍版本,确认前人物锚点不变);分灵创建新人物(继承资料与谱系 lineage 链、不继承原身份,魂籍从 v1 起) - 河图域 domains/hetu:persistent_worlds 境界 + world_entities/world_relations 境界作用域重建 + 世谱挂接;境界正史视图(scope=persistent_world 归真集合);canonize 后置推进正史计数 - 三界分流:reality→息壤提取(不变);persistent_world→境界实录+正史推进;simulation→只记试演实录;factual_records 增 scope 列——境界/幻境实录与真界本相隔离 - 正影打通:引文 target_type=memory 时被引记忆 superseded → 返回 successor_memory_id 提示,不静默替换 - 前端:人物详情页缘契面板(提议维度选择/铭契/撤回/事件史)+ 世界页重建(境界目录/实体/正史概览)+ covenants/worlds api 库 - 退役:旧 personality/drive/evolution/world 模块、服务、端点、仓储、schema、前端五页与组件样式;迁移 007 退役九张旧表与 characters.current_personality_version_id - 验收(test_m6_domains 六用例):多维无单一好感度/铭契版本链/事件不可抹除/撤回立即生效/边界即时/铸魂确认流+魂籍版本/分灵谱系/境界正史不进真界(实录 scope 隔离+真界候选=0)/引文正影后继 - 四套件:后端 379、前端 199、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 007 净库+±1 干净
This commit is contained in:
1 parent
684261f4b4
commit
7e23d6fa84
108 files changed
+2600
-9732
No files matched your search
@@ -31,7 +31,7 @@ async def test_list_returns_seeded_default(client: AsyncClient) -> None:
|
||||
item = body["items"][0]
|
||||
assert item["id"].startswith("character_")
|
||||
assert item["status"] == "active"
|
||||
assert item["personality_version"] >= 1
|
||||
assert item["personality_version"] == 0 # 旧人格版本表退役,版本语义由魂籍承载
|
||||
assert item["memory_count"] == 0
|
||||
|
||||
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
"""内驱力 /drive 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.drive import CuriosityQuestion, ExploratorySubgoal
|
||||
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(character_id: str, subgoal_status: str = "candidate") -> str:
|
||||
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()
|
||||
curios = CuriosityQuestion(
|
||||
character_id=character_id,
|
||||
cycle_id=cycle.id,
|
||||
question="用户为何频繁提到项目X",
|
||||
source="memory_gap",
|
||||
expected_value=0.7,
|
||||
urgency=0.5,
|
||||
risk_level="L2",
|
||||
)
|
||||
session.add(curios)
|
||||
await session.flush()
|
||||
sub = ExploratorySubgoal(
|
||||
character_id=character_id,
|
||||
cycle_id=cycle.id,
|
||||
curiosity_question_id=curios.id,
|
||||
title="询问项目目标",
|
||||
description="主动询问",
|
||||
expected_information_gain=0.6,
|
||||
interruption_cost=0.3,
|
||||
risk_level="L2",
|
||||
status=subgoal_status,
|
||||
)
|
||||
session.add(sub)
|
||||
await session.commit()
|
||||
return sub.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_curiosities(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed(character.id)
|
||||
|
||||
resp = await client.get(f"/api/v1/characters/{character.id}/curiosities")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] == 1
|
||||
assert data["items"][0]["question"] == "用户为何频繁提到项目X"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_subgoals_with_status_filter(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await _seed(character.id, "candidate")
|
||||
await _seed(character.id, "approved")
|
||||
|
||||
resp = await client.get(
|
||||
f"/api/v1/characters/{character.id}/subgoals", params={"status": "candidate"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_subgoal(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed(character.id)
|
||||
|
||||
resp = await client.post(f"/api/v1/subgoals/{sid}/approve")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_defer_subgoal(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed(character.id)
|
||||
|
||||
resp = await client.post(f"/api/v1/subgoals/{sid}/defer")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "deferred"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_subgoal(client: AsyncClient) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed(character.id)
|
||||
|
||||
resp = await client.post(f"/api/v1/subgoals/{sid}/reject")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "rejected"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_subgoal_creates_goal(client: AsyncClient) -> None:
|
||||
"""审批通过次生目标后应自动创建太一目标。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.governance import Goal
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed(character.id)
|
||||
|
||||
resp = await client.post(f"/api/v1/subgoals/{sid}/approve")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# 验证生成了 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
|
||||
goal = matching[0]
|
||||
assert goal.level == "task"
|
||||
assert goal.type == "exploration"
|
||||
assert goal.status == "active"
|
||||
@@ -1,63 +0,0 @@
|
||||
"""人格提议列表 API 测试(api-spec §11,审批墙数据源)。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test", headers=auth_headers
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_proposals_empty(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/personality/proposals")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 0
|
||||
assert body["items"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_proposals_filter_by_status(client: AsyncClient) -> None:
|
||||
proposed = await client.post(
|
||||
"/api/v1/personality/propose-update",
|
||||
json={"fields": {"identity_profile": "更新的身份画像"}, "reason": "测试提议"},
|
||||
)
|
||||
assert proposed.status_code == 201
|
||||
proposal_id = proposed.json()["proposal_id"]
|
||||
|
||||
listed = await client.get("/api/v1/personality/proposals")
|
||||
assert listed.status_code == 200
|
||||
body = listed.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["proposal_id"] == proposal_id
|
||||
assert body["items"][0]["status"] == "pending"
|
||||
|
||||
pending = await client.get("/api/v1/personality/proposals?status=pending")
|
||||
assert pending.json()["total"] == 1
|
||||
|
||||
approved = await client.get("/api/v1/personality/proposals?status=approved")
|
||||
assert approved.json()["total"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_proposals_pagination(client: AsyncClient) -> None:
|
||||
for i in range(3):
|
||||
await client.post(
|
||||
"/api/v1/personality/propose-update",
|
||||
json={"fields": {"identity_profile": f"v{i}"}, "reason": f"r{i}"},
|
||||
)
|
||||
page1 = await client.get("/api/v1/personality/proposals?page=1&page_size=2")
|
||||
assert page1.json()["total"] == 3
|
||||
assert len(page1.json()["items"]) == 2
|
||||
page2 = await client.get("/api/v1/personality/proposals?page=2&page_size=2")
|
||||
assert len(page2.json()["items"]) == 1
|
||||
@@ -1,104 +0,0 @@
|
||||
"""世界模型 API 测试(河图枢运行时,api-spec §12)。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test", headers=auth_headers
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_entities_empty(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/world/entities")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 0
|
||||
assert body["items"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_get_entity(client: AsyncClient) -> None:
|
||||
created = await client.post(
|
||||
"/api/v1/world/entities",
|
||||
json={"name": "用户项目", "type": "project", "state_type": "fact", "confidence": 0.9},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
entity = created.json()
|
||||
assert entity["id"].startswith("world_entity_")
|
||||
assert entity["name"] == "用户项目"
|
||||
assert entity["type"] == "project"
|
||||
assert entity["state_type"] == "fact"
|
||||
assert entity["character_id"].startswith("character_")
|
||||
|
||||
fetched = await client.get(f"/api/v1/world/entities/{entity['id']}")
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.json()["id"] == entity["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_entity_not_found(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/world/entities/world_entity_missing")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_entities_filter_by_type(client: AsyncClient) -> None:
|
||||
await client.post("/api/v1/world/entities", json={"name": "概念A", "type": "concept"})
|
||||
await client.post("/api/v1/world/entities", json={"name": "项目B", "type": "project"})
|
||||
|
||||
filtered = await client.get("/api/v1/world/entities?type=project")
|
||||
assert filtered.status_code == 200
|
||||
body = filtered.json()
|
||||
assert body["total"] == 1
|
||||
assert body["items"][0]["type"] == "project"
|
||||
|
||||
all_entities = await client.get("/api/v1/world/entities")
|
||||
assert all_entities.json()["total"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_relation(client: AsyncClient) -> None:
|
||||
user = (
|
||||
await client.post("/api/v1/world/entities", json={"name": "用户", "type": "person"})
|
||||
).json()["id"]
|
||||
project = (
|
||||
await client.post("/api/v1/world/entities", json={"name": "魂镜项目", "type": "project"})
|
||||
).json()["id"]
|
||||
|
||||
created = await client.post(
|
||||
"/api/v1/world/relations",
|
||||
json={"subject_id": user, "predicate": "正在进行", "object_id": project},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
relation = created.json()
|
||||
assert relation["id"].startswith("world_relation_")
|
||||
assert relation["predicate"] == "正在进行"
|
||||
assert relation["subject_id"] == user
|
||||
assert relation["object_id"] == project
|
||||
|
||||
listed = await client.get("/api/v1/world/relations")
|
||||
assert listed.json()["total"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_relation_rejects_missing_entity(client: AsyncClient) -> None:
|
||||
project = (
|
||||
await client.post("/api/v1/world/entities", json={"name": "项目", "type": "project"})
|
||||
).json()["id"]
|
||||
|
||||
# 主体不存在
|
||||
resp = await client.post(
|
||||
"/api/v1/world/relations",
|
||||
json={"subject_id": "world_entity_missing", "predicate": "依赖", "object_id": project},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
@@ -1,96 +0,0 @@
|
||||
"""大周天演化候选抽取纯函数测试。"""
|
||||
|
||||
from app.modules.big_cycle.extract import (
|
||||
EvolutionCandidateDraft,
|
||||
parse_evolution_candidates,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_standard_json_array() -> None:
|
||||
raw = (
|
||||
'[{"type":"personality","level":"L4","scope":"character_scope",'
|
||||
'"title":"调整身份表达","proposal":"更主动介绍自身能力边界",'
|
||||
'"delta":{"identity_profile":"新的身份描述"},"confidence":0.8,'
|
||||
'"benefit_score":0.7,"risk_score":0.3,"auto_apply_allowed":false,'
|
||||
'"evidence":[0,1]}]'
|
||||
)
|
||||
drafts = parse_evolution_candidates(raw)
|
||||
assert len(drafts) == 1
|
||||
d = drafts[0]
|
||||
assert d.type == "personality"
|
||||
assert d.level == "L4"
|
||||
assert d.scope == "character_scope"
|
||||
assert d.title == "调整身份表达"
|
||||
assert d.proposal == "更主动介绍自身能力边界"
|
||||
assert d.delta == {"identity_profile": "新的身份描述"}
|
||||
assert d.confidence == 0.8
|
||||
assert d.auto_apply_allowed is False
|
||||
assert d.source == "big_cycle"
|
||||
assert d.evidence == [0, 1]
|
||||
|
||||
|
||||
def test_parse_markdown_code_block() -> None:
|
||||
raw = (
|
||||
'```json\n[{"type":"procedural","level":"L2","scope":"task_scope",'
|
||||
'"title":"t","proposal":"p"}]\n```'
|
||||
)
|
||||
drafts = parse_evolution_candidates(raw)
|
||||
assert len(drafts) == 1
|
||||
assert drafts[0].type == "procedural"
|
||||
|
||||
|
||||
def test_parse_text_mixed_json() -> None:
|
||||
raw = (
|
||||
'候选如下:\n[{"type":"mood","level":"L1","scope":"character_scope",'
|
||||
'"title":"t","proposal":"p"}]\n以上。'
|
||||
)
|
||||
drafts = parse_evolution_candidates(raw)
|
||||
assert len(drafts) == 1
|
||||
|
||||
|
||||
def test_parse_invalid_json_returns_empty() -> None:
|
||||
assert parse_evolution_candidates("不是JSON") == []
|
||||
assert parse_evolution_candidates("[]") == []
|
||||
|
||||
|
||||
def test_skip_entries_missing_required_fields() -> None:
|
||||
raw = (
|
||||
'[{"type":"personality","title":"缺proposal"},'
|
||||
'{"title":"缺type","proposal":"p"},'
|
||||
'{"type":"x","proposal":"缺title"}]'
|
||||
)
|
||||
assert parse_evolution_candidates(raw) == []
|
||||
|
||||
|
||||
def test_defaults_use_safe_pessimistic_values() -> None:
|
||||
"""缺省时取最严格默认:level=L4(必须人工)、auto_apply_allowed=False。"""
|
||||
raw = '[{"type":"personality","title":"t","proposal":"p"}]'
|
||||
d = parse_evolution_candidates(raw)[0]
|
||||
assert d.level == "L4"
|
||||
assert d.scope == "character_scope"
|
||||
assert d.auto_apply_allowed is False
|
||||
assert d.confidence == 0.5
|
||||
assert d.benefit_score == 0.5
|
||||
assert d.risk_score == 0.5
|
||||
assert d.drift_score == 0.0
|
||||
assert d.delta == {}
|
||||
|
||||
|
||||
def test_clamp_and_default_scores() -> None:
|
||||
raw = (
|
||||
'[{"type":"personality","title":"t","proposal":"p",'
|
||||
'"confidence":1.5,"benefit_score":-0.2,"risk_score":"high"}]'
|
||||
)
|
||||
d = parse_evolution_candidates(raw)[0]
|
||||
assert d.confidence == 1.0
|
||||
assert d.benefit_score == 0.0
|
||||
assert d.risk_score == 0.5 # 非法值 → 默认
|
||||
|
||||
|
||||
def test_auto_apply_allowed_truthy() -> None:
|
||||
raw = (
|
||||
'[{"type":"procedural","level":"L2","title":"t","proposal":"p","auto_apply_allowed":true}]'
|
||||
)
|
||||
d = parse_evolution_candidates(raw)[0]
|
||||
assert d.auto_apply_allowed is True
|
||||
assert isinstance(d, EvolutionCandidateDraft)
|
||||
@@ -1,62 +0,0 @@
|
||||
"""大周天演化候选 prompt 构建纯函数测试。"""
|
||||
|
||||
from app.modules.big_cycle.prompts import (
|
||||
EvaluationSnapshot,
|
||||
build_evolution_extraction_messages,
|
||||
)
|
||||
|
||||
|
||||
def _snapshot() -> EvaluationSnapshot:
|
||||
return EvaluationSnapshot(
|
||||
character_id="character_x",
|
||||
memory_count=25,
|
||||
recent_memories=[("喜欢简洁回答", "用户偏好简洁"), ("来自北京", "用户所在地")],
|
||||
personality_summary="身份:魂镜;表达风格:温和",
|
||||
world_entity_count=3,
|
||||
conclusion="近期互动偏技术话题,表达可更简洁",
|
||||
)
|
||||
|
||||
|
||||
def test_messages_return_system_user_pair() -> None:
|
||||
system, user = build_evolution_extraction_messages(_snapshot())
|
||||
assert isinstance(system, str) and isinstance(user, str)
|
||||
assert system and user
|
||||
|
||||
|
||||
def test_system_describes_contract_fields() -> None:
|
||||
system, _ = build_evolution_extraction_messages(_snapshot())
|
||||
for token in (
|
||||
"type",
|
||||
"level",
|
||||
"scope",
|
||||
"proposal",
|
||||
"delta",
|
||||
"auto_apply_allowed",
|
||||
):
|
||||
assert token in system
|
||||
|
||||
|
||||
def test_system_lists_evolution_types() -> None:
|
||||
system, _ = build_evolution_extraction_messages(_snapshot())
|
||||
for t in ("personality", "procedural", "tool_strategy", "memory"):
|
||||
assert t in system
|
||||
|
||||
|
||||
def test_system_states_level_semantics() -> None:
|
||||
system, _ = build_evolution_extraction_messages(_snapshot())
|
||||
assert "L4" in system
|
||||
|
||||
|
||||
def test_user_includes_snapshot_signals() -> None:
|
||||
_, user = build_evolution_extraction_messages(_snapshot())
|
||||
assert "喜欢简洁回答" in user
|
||||
assert "身份:魂镜" in user
|
||||
assert "25" in user
|
||||
|
||||
|
||||
def test_user_handles_empty_personality() -> None:
|
||||
snap = _snapshot()
|
||||
snap.personality_summary = ""
|
||||
_, user = build_evolution_extraction_messages(snap)
|
||||
assert isinstance(user, str)
|
||||
assert len(user) > 0
|
||||
@@ -1,95 +0,0 @@
|
||||
"""大周天内驱力抽取纯函数测试。"""
|
||||
|
||||
from app.modules.drive.extract import (
|
||||
CuriosityDraft,
|
||||
SubgoalDraft,
|
||||
parse_curiosities,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_standard_with_subgoals() -> None:
|
||||
raw = (
|
||||
'[{"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":"L2"}]}]'
|
||||
)
|
||||
drafts = parse_curiosities(raw)
|
||||
assert len(drafts) == 1
|
||||
c = drafts[0]
|
||||
assert c.question == "用户为何频繁提到项目X"
|
||||
assert c.source == "memory_gap"
|
||||
assert c.expected_value == 0.7
|
||||
assert c.risk_level == "L2"
|
||||
assert c.related_entities == ["项目X"]
|
||||
assert len(c.subgoals) == 1
|
||||
sg = c.subgoals[0]
|
||||
assert sg.title == "询问项目X目标"
|
||||
assert sg.expected_information_gain == 0.6
|
||||
assert sg.interruption_cost == 0.3
|
||||
|
||||
|
||||
def test_parse_markdown_block() -> None:
|
||||
raw = '```json\n[{"question":"q"}]\n```'
|
||||
drafts = parse_curiosities(raw)
|
||||
assert len(drafts) == 1
|
||||
assert drafts[0].question == "q"
|
||||
|
||||
|
||||
def test_parse_invalid_returns_empty() -> None:
|
||||
assert parse_curiosities("不是JSON") == []
|
||||
assert parse_curiosities("[]") == []
|
||||
|
||||
|
||||
def test_skip_missing_question() -> None:
|
||||
raw = '[{"source":"memory_gap"},{"question":"q"}]'
|
||||
drafts = parse_curiosities(raw)
|
||||
assert len(drafts) == 1
|
||||
assert drafts[0].question == "q"
|
||||
|
||||
|
||||
def test_defaults_use_safe_pessimistic_values() -> None:
|
||||
"""缺省取最严格默认:risk_level=L4、source=reflection。"""
|
||||
raw = '[{"question":"q"}]'
|
||||
c = parse_curiosities(raw)[0]
|
||||
assert c.source == "reflection"
|
||||
assert c.risk_level == "L4"
|
||||
assert c.expected_value == 0.5
|
||||
assert c.urgency == 0.5
|
||||
assert c.related_entities == []
|
||||
assert c.subgoals == []
|
||||
|
||||
|
||||
def test_clamp_values() -> None:
|
||||
raw = '[{"question":"q","expected_value":1.5,"urgency":-0.2}]'
|
||||
c = parse_curiosities(raw)[0]
|
||||
assert c.expected_value == 1.0
|
||||
assert c.urgency == 0.0
|
||||
|
||||
|
||||
def test_invalid_source_defaults() -> None:
|
||||
raw = '[{"question":"q","source":"unknown_source"}]'
|
||||
assert parse_curiosities(raw)[0].source == "reflection"
|
||||
|
||||
|
||||
def test_invalid_risk_level_defaults() -> None:
|
||||
raw = '[{"question":"q","risk_level":"L9"}]'
|
||||
assert parse_curiosities(raw)[0].risk_level == "L4"
|
||||
|
||||
|
||||
def test_subgoal_defaults_and_types() -> None:
|
||||
raw = '[{"question":"q","subgoals":[{"title":"t","description":"d"}]}]'
|
||||
c = parse_curiosities(raw)[0]
|
||||
assert isinstance(c, CuriosityDraft)
|
||||
sg = c.subgoals[0]
|
||||
assert isinstance(sg, SubgoalDraft)
|
||||
assert sg.risk_level == "L4" # 安全默认
|
||||
assert sg.expected_information_gain == 0.5
|
||||
assert sg.interruption_cost == 0.5
|
||||
|
||||
|
||||
def test_skip_subgoal_missing_title_or_description() -> None:
|
||||
raw = '[{"question":"q","subgoals":[{"title":"t"},{"description":"d"},{"title":"t","description":"d"}]}]'
|
||||
c = parse_curiosities(raw)[0]
|
||||
assert len(c.subgoals) == 1 # 只有完整的保留
|
||||
@@ -1,47 +0,0 @@
|
||||
"""大周天内驱力 prompt 构建纯函数测试。"""
|
||||
|
||||
from app.modules.big_cycle.prompts import EvaluationSnapshot
|
||||
from app.modules.drive.prompts import build_drive_extraction_messages
|
||||
|
||||
|
||||
def _snapshot() -> EvaluationSnapshot:
|
||||
return EvaluationSnapshot(
|
||||
character_id="character_x",
|
||||
memory_count=15,
|
||||
recent_memories=[("用户偏好简洁", "用户偏好简洁直接的回答")],
|
||||
personality_summary="身份:魂镜",
|
||||
world_entity_count=4,
|
||||
conclusion="近期对话偏技术,存在项目背景缺口",
|
||||
)
|
||||
|
||||
|
||||
def test_messages_return_system_user_pair() -> None:
|
||||
system, user = build_drive_extraction_messages(_snapshot())
|
||||
assert isinstance(system, str) and isinstance(user, str)
|
||||
assert system and user
|
||||
|
||||
|
||||
def test_system_describes_contract_fields() -> None:
|
||||
system, _ = build_drive_extraction_messages(_snapshot())
|
||||
for token in (
|
||||
"question",
|
||||
"source",
|
||||
"expected_value",
|
||||
"urgency",
|
||||
"risk_level",
|
||||
"subgoals",
|
||||
):
|
||||
assert token in system
|
||||
|
||||
|
||||
def test_system_lists_sources() -> None:
|
||||
system, _ = build_drive_extraction_messages(_snapshot())
|
||||
for src in ("world_uncertainty", "memory_gap", "reflection", "goal_tension"):
|
||||
assert src in system
|
||||
|
||||
|
||||
def test_user_includes_snapshot_signals() -> None:
|
||||
_, user = build_drive_extraction_messages(_snapshot())
|
||||
assert "用户偏好简洁" in user
|
||||
assert "身份:魂镜" in user
|
||||
assert "15" in user
|
||||
@@ -1,182 +0,0 @@
|
||||
"""灵核情绪状态机纯计算测试(modules/personality/emotion.py)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.personality.emotion import (
|
||||
MOODS,
|
||||
EmotionState,
|
||||
apply_signal,
|
||||
decay,
|
||||
map_signal_to_mood,
|
||||
parse_baseline,
|
||||
signal_intensity_delta,
|
||||
to_prompt_text,
|
||||
)
|
||||
|
||||
|
||||
def _baseline(mood: str = "neutral", intensity: float = 0.2) -> EmotionState:
|
||||
return EmotionState(mood=mood, intensity=intensity)
|
||||
|
||||
|
||||
# ---- map_signal_to_mood ----
|
||||
|
||||
|
||||
def test_map_reunion_to_touched() -> None:
|
||||
assert map_signal_to_mood("time.reunion", {"reunion_intensity": 0.8}) == "touched"
|
||||
|
||||
|
||||
def test_map_user_care_default_to_concerned() -> None:
|
||||
assert map_signal_to_mood("time.user_care", {"care_kind": "late_night"}) == "concerned"
|
||||
|
||||
|
||||
def test_map_user_care_festival_lonely_to_melancholy() -> None:
|
||||
assert map_signal_to_mood("time.user_care", {"care_kind": "festival_lonely"}) == "melancholy"
|
||||
|
||||
|
||||
def test_map_sentiment_default_to_wistful() -> None:
|
||||
assert map_signal_to_mood("time.sentiment", {"trigger_type": "reunion_memory"}) == "wistful"
|
||||
|
||||
|
||||
def test_map_sentiment_late_night_to_melancholy() -> None:
|
||||
assert map_signal_to_mood("time.sentiment", {"trigger_type": "late_night"}) == "melancholy"
|
||||
|
||||
|
||||
def test_map_unknown_event_returns_none() -> None:
|
||||
assert map_signal_to_mood("tool.started", {}) is None
|
||||
|
||||
|
||||
# ---- signal_intensity_delta ----
|
||||
|
||||
|
||||
def test_delta_reunion_uses_reunion_intensity() -> None:
|
||||
assert signal_intensity_delta("time.reunion", {"reunion_intensity": 0.7}) == 0.7
|
||||
|
||||
|
||||
def test_delta_user_care_uses_care_suggest() -> None:
|
||||
assert signal_intensity_delta("time.user_care", {"care_suggest": 0.6}) == 0.6
|
||||
|
||||
|
||||
def test_delta_sentiment_default_05() -> None:
|
||||
assert signal_intensity_delta("time.sentiment", {}) == 0.5
|
||||
|
||||
|
||||
def test_delta_unknown_event_zero() -> None:
|
||||
assert signal_intensity_delta("tool.started", {}) == 0.0
|
||||
|
||||
|
||||
# ---- apply_signal ----
|
||||
|
||||
|
||||
def test_apply_signal_changes_mood_and_sets_intensity_from_baseline() -> None:
|
||||
state = _baseline() # neutral 0.2
|
||||
new = apply_signal(state, "time.reunion", {"reunion_intensity": 0.6}, _baseline())
|
||||
assert new.mood == "touched"
|
||||
assert new.intensity == 0.8 # 0.2 baseline + 0.6 delta
|
||||
|
||||
|
||||
def test_apply_signal_same_mood_accumulates() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.5)
|
||||
new = apply_signal(state, "time.reunion", {"reunion_intensity": 0.3}, _baseline())
|
||||
assert new.mood == "touched"
|
||||
assert new.intensity == 0.8 # 0.5 current + 0.3 delta
|
||||
|
||||
|
||||
def test_apply_signal_clamps_to_1() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.9)
|
||||
new = apply_signal(state, "time.reunion", {"reunion_intensity": 0.5}, _baseline())
|
||||
assert new.mood == "touched"
|
||||
assert new.intensity == 1.0
|
||||
|
||||
|
||||
def test_apply_signal_unknown_event_returns_unchanged() -> None:
|
||||
state = EmotionState(mood="pleased", intensity=0.5)
|
||||
new = apply_signal(state, "tool.started", {}, _baseline())
|
||||
assert new == state
|
||||
|
||||
|
||||
# ---- decay ----
|
||||
|
||||
|
||||
def test_decay_toward_baseline_half_life() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.8)
|
||||
baseline = _baseline() # neutral 0.2
|
||||
# 半衰期 6 回合,elapsed 6 → intensity 向 baseline 走一半
|
||||
decayed = decay(state, baseline, elapsed_turns=6.0)
|
||||
assert decayed.mood == "touched"
|
||||
assert abs(decayed.intensity - 0.5) < 0.01 # 0.2 + (0.8-0.2)*0.5 = 0.5
|
||||
|
||||
|
||||
def test_decay_below_threshold_returns_baseline_mood() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.3)
|
||||
baseline = _baseline()
|
||||
# elapsed 大 → intensity 衰减到 < 0.15 → mood 回 baseline
|
||||
decayed = decay(state, baseline, elapsed_turns=30.0)
|
||||
assert decayed.mood == baseline.mood
|
||||
assert decayed.intensity == baseline.intensity
|
||||
|
||||
|
||||
def test_decay_zero_elapsed_returns_unchanged() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.8)
|
||||
baseline = _baseline()
|
||||
assert decay(state, baseline, elapsed_turns=0) == state
|
||||
|
||||
|
||||
# ---- parse_baseline ----
|
||||
|
||||
|
||||
def test_parse_baseline_from_dict() -> None:
|
||||
assert parse_baseline({"mood": "neutral", "intensity": 0.3}) == EmotionState(
|
||||
mood="neutral", intensity=0.3
|
||||
)
|
||||
|
||||
|
||||
def test_parse_baseline_from_valid_json() -> None:
|
||||
assert parse_baseline('{"mood": "pleased", "intensity": 0.4}') == EmotionState(
|
||||
mood="pleased", intensity=0.4
|
||||
)
|
||||
|
||||
|
||||
def test_parse_baseline_invalid_json_returns_neutral_default() -> None:
|
||||
assert parse_baseline("not json") == EmotionState()
|
||||
|
||||
|
||||
def test_parse_baseline_none_returns_neutral_default() -> None:
|
||||
assert parse_baseline(None) == EmotionState()
|
||||
|
||||
|
||||
def test_parse_baseline_clamps_intensity() -> None:
|
||||
assert parse_baseline({"mood": "neutral", "intensity": 1.5}).intensity == 1.0
|
||||
assert parse_baseline({"mood": "neutral", "intensity": -0.2}).intensity == 0.0
|
||||
|
||||
|
||||
def test_parse_baseline_unknown_mood_falls_back_neutral() -> None:
|
||||
assert parse_baseline({"mood": "unknown_mood", "intensity": 0.5}).mood == "neutral"
|
||||
|
||||
|
||||
# ---- to_prompt_text ----
|
||||
|
||||
|
||||
def test_to_prompt_text_includes_chinese_label_and_pct() -> None:
|
||||
text = to_prompt_text(EmotionState(mood="touched", intensity=0.7))
|
||||
assert "感动" in text
|
||||
assert "70" in text
|
||||
|
||||
|
||||
def test_to_prompt_text_unknown_mood_uses_raw() -> None:
|
||||
text = to_prompt_text(EmotionState(mood="custom", intensity=0.5))
|
||||
assert "custom" in text
|
||||
|
||||
|
||||
# ---- MOODS ----
|
||||
|
||||
|
||||
def test_moods_contains_seven() -> None:
|
||||
assert set(MOODS) == {
|
||||
"neutral",
|
||||
"pleased",
|
||||
"touched",
|
||||
"wistful",
|
||||
"concerned",
|
||||
"festive",
|
||||
"melancholy",
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
"""内驱力数据访问测试(好奇问题 + 次生目标)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
|
||||
from app.repositories import drive as drive_repo
|
||||
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
|
||||
|
||||
|
||||
def _curiosity(character_id: str, cycle_id: str, **overrides) -> CuriosityQuestion:
|
||||
base = {
|
||||
"character_id": character_id,
|
||||
"cycle_id": cycle_id,
|
||||
"question": "q",
|
||||
"source": "memory_gap",
|
||||
"related_entities_json": "[]",
|
||||
"expected_value": 0.5,
|
||||
"urgency": 0.5,
|
||||
"risk_level": "L2",
|
||||
}
|
||||
base.update(overrides)
|
||||
return CuriosityQuestion(**base)
|
||||
|
||||
|
||||
def _subgoal(
|
||||
character_id: str, cycle_id: str, curiosity_id: str, **overrides
|
||||
) -> ExploratorySubgoal:
|
||||
base = {
|
||||
"character_id": character_id,
|
||||
"cycle_id": cycle_id,
|
||||
"curiosity_question_id": curiosity_id,
|
||||
"title": "t",
|
||||
"description": "d",
|
||||
"expected_information_gain": 0.6,
|
||||
"interruption_cost": 0.3,
|
||||
"risk_level": "L2",
|
||||
"status": "candidate",
|
||||
}
|
||||
base.update(overrides)
|
||||
return ExploratorySubgoal(**base)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_curiosity_add_get_list() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cycle_id = await _seed_cycle(session, character.id)
|
||||
curios = _curiosity(character.id, cycle_id, question="为何频繁提到项目X")
|
||||
await drive_repo.add_curiosities(session, [curios])
|
||||
await drive_repo.add_curiosities(
|
||||
session, [_curiosity(character.id, cycle_id, source="world_uncertainty")]
|
||||
)
|
||||
await session.commit()
|
||||
cid = curios.id
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
fetched = await drive_repo.get_curiosity(session, cid)
|
||||
all_items, total = await drive_repo.list_curiosities(session, character.id)
|
||||
gap, gap_total = await drive_repo.list_curiosities(
|
||||
session, character.id, source="memory_gap"
|
||||
)
|
||||
assert fetched is not None
|
||||
assert fetched.question == "为何频繁提到项目X"
|
||||
assert total == 2
|
||||
assert gap_total == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subgoal_add_get_list() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cycle_id = await _seed_cycle(session, character.id)
|
||||
curios = _curiosity(character.id, cycle_id)
|
||||
await drive_repo.add_curiosities(session, [curios])
|
||||
await session.flush()
|
||||
curiosity_id = curios.id
|
||||
sub = _subgoal(character.id, cycle_id, curiosity_id, title="询问项目目标")
|
||||
await drive_repo.add_subgoals(session, [sub])
|
||||
await drive_repo.add_subgoals(
|
||||
session, [_subgoal(character.id, cycle_id, curiosity_id, status="approved")]
|
||||
)
|
||||
await session.commit()
|
||||
sid = sub.id
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
fetched = await drive_repo.get_subgoal(session, sid)
|
||||
_, total = await drive_repo.list_subgoals(session, character.id)
|
||||
cand, cand_total = await drive_repo.list_subgoals(session, character.id, status="candidate")
|
||||
assert fetched is not None
|
||||
assert fetched.title == "询问项目目标"
|
||||
assert fetched.curiosity_question_id == curiosity_id
|
||||
assert total == 2
|
||||
assert cand_total == 1
|
||||
@@ -1,100 +0,0 @@
|
||||
"""演化候选数据访问测试。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.evolution import EvolutionCandidate
|
||||
from app.repositories import evolution as evolution_repo
|
||||
from app.repositories.json_utils import dumps
|
||||
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
|
||||
|
||||
|
||||
def _make_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": dumps({}),
|
||||
"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_add_and_get_candidate() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cycle_id = await _seed_cycle(session, character.id)
|
||||
candidate = _make_candidate(character.id, cycle_id, title="身份调整")
|
||||
await evolution_repo.add_candidates(session, [candidate])
|
||||
await session.commit()
|
||||
cid = candidate.id
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
fetched = await evolution_repo.get_candidate(session, cid)
|
||||
assert fetched is not None
|
||||
assert fetched.title == "身份调整"
|
||||
assert fetched.status == "pending"
|
||||
assert fetched.source == "big_cycle"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_candidates_with_filters_and_paging() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cycle_id = await _seed_cycle(session, character.id)
|
||||
await evolution_repo.add_candidates(
|
||||
session,
|
||||
[
|
||||
_make_candidate(character.id, cycle_id, type="personality", status="pending"),
|
||||
_make_candidate(character.id, cycle_id, type="procedural", status="applied"),
|
||||
_make_candidate(character.id, cycle_id, type="procedural", status="pending"),
|
||||
],
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
all_items, total = await evolution_repo.list_candidates(session, character.id)
|
||||
assert total == 3
|
||||
assert len(all_items) == 3
|
||||
|
||||
pending, pending_total = await evolution_repo.list_candidates(
|
||||
session, character.id, status="pending"
|
||||
)
|
||||
assert pending_total == 2
|
||||
assert len(pending) == 2
|
||||
|
||||
procedural, proc_total = await evolution_repo.list_candidates(
|
||||
session, character.id, candidate_type="procedural"
|
||||
)
|
||||
assert proc_total == 2
|
||||
|
||||
page1, _ = await evolution_repo.list_candidates(session, character.id, page=1, page_size=2)
|
||||
page2, _ = await evolution_repo.list_candidates(session, character.id, page=2, page_size=2)
|
||||
assert len(page1) == 2
|
||||
assert len(page2) == 1
|
||||
@@ -1,97 +0,0 @@
|
||||
"""内驱力审核服务测试(次生目标 approve/defer/reject)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
|
||||
from app.services import drive as drive_service
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
|
||||
async def _seed_subgoal(session, character_id: str, status: str = "candidate") -> str:
|
||||
cycle = BigCycle(
|
||||
character_id=character_id,
|
||||
trigger="test",
|
||||
status="completed",
|
||||
memory_count_at_trigger=20,
|
||||
)
|
||||
session.add(cycle)
|
||||
await session.flush()
|
||||
curios = CuriosityQuestion(
|
||||
character_id=character_id,
|
||||
cycle_id=cycle.id,
|
||||
question="q",
|
||||
source="memory_gap",
|
||||
expected_value=0.5,
|
||||
urgency=0.5,
|
||||
risk_level="L2",
|
||||
)
|
||||
session.add(curios)
|
||||
await session.flush()
|
||||
sub = ExploratorySubgoal(
|
||||
character_id=character_id,
|
||||
cycle_id=cycle.id,
|
||||
curiosity_question_id=curios.id,
|
||||
title="询问项目目标",
|
||||
description="主动询问",
|
||||
expected_information_gain=0.6,
|
||||
interruption_cost=0.3,
|
||||
risk_level="L2",
|
||||
status=status,
|
||||
)
|
||||
session.add(sub)
|
||||
await session.flush()
|
||||
return sub.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_candidate_subgoal() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed_subgoal(session, character.id)
|
||||
await session.commit()
|
||||
|
||||
resp = await drive_service.approve_subgoal(sid)
|
||||
assert resp.status == "approved"
|
||||
assert resp.reviewed_by == "user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_defer_candidate_subgoal() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed_subgoal(session, character.id)
|
||||
await session.commit()
|
||||
|
||||
resp = await drive_service.defer_subgoal(sid)
|
||||
assert resp.status == "deferred"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_candidate_subgoal() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed_subgoal(session, character.id)
|
||||
await session.commit()
|
||||
|
||||
resp = await drive_service.reject_subgoal(sid)
|
||||
assert resp.status == "rejected"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_non_candidate_conflicts() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
sid = await _seed_subgoal(session, character.id, status="approved")
|
||||
await session.commit()
|
||||
|
||||
with pytest.raises(AppError):
|
||||
await drive_service.approve_subgoal(sid)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_not_found() -> None:
|
||||
with pytest.raises(AppError):
|
||||
await drive_service.approve_subgoal("subgoal_missing")
|
||||
@@ -29,89 +29,6 @@ async def test_health_and_defaults(client: AsyncClient) -> None:
|
||||
character = await client.get("/api/v1/characters/active")
|
||||
assert character.status_code == 200
|
||||
assert character.json()["id"].startswith("character_")
|
||||
personality = await client.get("/api/v1/personality/current")
|
||||
assert personality.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_personality_propose_approve_reject_and_rollback(client: AsyncClient) -> None:
|
||||
# 初始当前人格为 v1
|
||||
before = (await client.get("/api/v1/personality/current")).json()
|
||||
assert before["version"] == 1
|
||||
|
||||
# 空字段提议被拒(invalid_request)
|
||||
empty = await client.post(
|
||||
"/api/v1/personality/propose-update",
|
||||
json={"fields": {}, "reason": "空提议"},
|
||||
)
|
||||
assert empty.status_code == 400
|
||||
|
||||
# 提交 pending 建议(覆盖 string + json 字段)
|
||||
propose = await client.post(
|
||||
"/api/v1/personality/propose-update",
|
||||
json={
|
||||
"fields": {"identity_profile": "测试身份", "traits": {"warmth": 0.5}},
|
||||
"reason": "联调人格建议",
|
||||
},
|
||||
)
|
||||
assert propose.status_code == 201
|
||||
proposal = propose.json()
|
||||
assert proposal["status"] == "pending"
|
||||
assert proposal["fields"] == {"identity_profile": "测试身份", "traits": {"warmth": 0.5}}
|
||||
proposal_id = proposal["proposal_id"]
|
||||
|
||||
# 审核通过:派生新版本并切换当前指针,字段覆盖生效
|
||||
approve = await client.post(f"/api/v1/personality/proposals/{proposal_id}/approve")
|
||||
assert approve.status_code == 200
|
||||
applied = approve.json()
|
||||
assert applied["status"] == "applied"
|
||||
assert applied["resulting_version_id"]
|
||||
|
||||
after = (await client.get("/api/v1/personality/current")).json()
|
||||
assert after["version"] == before["version"] + 1
|
||||
assert after["identity_profile"] == "测试身份"
|
||||
assert after["traits"]["warmth"] == 0.5
|
||||
new_version_id = after["id"]
|
||||
|
||||
# 重复审核同一建议 → conflict
|
||||
again = await client.post(f"/api/v1/personality/proposals/{proposal_id}/approve")
|
||||
assert again.status_code == 409
|
||||
|
||||
# 驳回另一条建议:不生成版本,不改当前指针
|
||||
propose2 = await client.post(
|
||||
"/api/v1/personality/propose-update",
|
||||
json={"fields": {"expression_style": "被驳回"}, "reason": "测试驳回"},
|
||||
)
|
||||
pid2 = propose2.json()["proposal_id"]
|
||||
reject = await client.post(f"/api/v1/personality/proposals/{pid2}/reject")
|
||||
assert reject.status_code == 200
|
||||
assert reject.json()["status"] == "rejected"
|
||||
assert (await client.get("/api/v1/personality/current")).json()["id"] == new_version_id
|
||||
|
||||
# 回滚到 v1:当前指针切回,旧版本数不变
|
||||
versions = (await client.get("/api/v1/personality/versions")).json()
|
||||
v1 = next(v for v in versions["items"] if v["version"] == 1)
|
||||
rollback = await client.post(f"/api/v1/personality/versions/{v1['id']}/rollback")
|
||||
assert rollback.status_code == 200
|
||||
assert (await client.get("/api/v1/personality/current")).json()["id"] == v1["id"]
|
||||
|
||||
# 回滚到不存在的版本 → 404
|
||||
missing = await client.post("/api/v1/personality/versions/personality_nope/rollback")
|
||||
assert missing.status_code == 404
|
||||
|
||||
# 写操作均入起居注(personality_core 枢)
|
||||
# /admin/* 强制 require_admin 鉴权,联调测试在此局部绕过(鉴权本身覆盖见 test_admin_auth)
|
||||
app.dependency_overrides[require_admin] = lambda: None
|
||||
try:
|
||||
audit = await client.get("/api/v1/admin/audit/timeline?pivot=personality_core")
|
||||
assert audit.status_code == 200
|
||||
actions = {item["action"] for item in audit.json()["items"]}
|
||||
assert "personality.propose" in actions
|
||||
assert "personality.proposal.approved" in actions
|
||||
assert "personality.proposal.rejected" in actions
|
||||
assert "personality.rollback" in actions
|
||||
finally:
|
||||
app.dependency_overrides.pop(require_admin, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -147,38 +64,6 @@ async def test_mcp_server_crud(client: AsyncClient) -> None:
|
||||
assert deleted.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_world_entities_crud(client: AsyncClient) -> None:
|
||||
"""河图世界实体 CRUD——覆盖 services/world 路径。"""
|
||||
created = await client.post(
|
||||
"/api/v1/world/entities",
|
||||
json={
|
||||
"name": "测试地点",
|
||||
"type": "location",
|
||||
"attributes": {"climate": "温暖"},
|
||||
"state_type": "fixed",
|
||||
"sources": [],
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
entity_id = created.json()["id"]
|
||||
|
||||
listed = await client.get("/api/v1/world/entities")
|
||||
assert listed.status_code == 200
|
||||
assert len(listed.json()["items"]) >= 1
|
||||
|
||||
fetched = await client.get(f"/api/v1/world/entities/{entity_id}")
|
||||
assert fetched.status_code == 200
|
||||
assert fetched.json()["name"] == "测试地点"
|
||||
|
||||
# 世界实体无 PATCH/DELETE API,仅测试 GET 和建表
|
||||
deleted = await client.delete(f"/api/v1/world/entities/{entity_id}")
|
||||
assert deleted.status_code in (404, 405) # 无删除端点,接受未实现
|
||||
|
||||
gone = await client.get(f"/api/v1/world/entities/{entity_id}")
|
||||
assert gone.status_code in (200, 404)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preferences_flow(client: AsyncClient) -> None:
|
||||
"""偏好设置读写——覆盖 services/preferences 路径。"""
|
||||
|
||||
@@ -12,10 +12,8 @@ from app.db.init_data import seed_default_data
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.main import app
|
||||
from app.models.character import Character
|
||||
from app.models.personality import PersonalityVersion
|
||||
from app.models.user import User
|
||||
from app.repositories import characters as character_repo
|
||||
from app.repositories import personality as personality_repo
|
||||
from app.repositories.json_utils import dumps
|
||||
from app.modules.character import (
|
||||
MODEL_POLICY_CREATE_DEFAULTS,
|
||||
@@ -78,43 +76,6 @@ async def test_active_character_api_returns_user_copy() -> None:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_personality_is_copied_from_template(db_session: AsyncSession) -> None:
|
||||
db = db_session
|
||||
template = await character_repo.get_system_template_by_key(db, "default")
|
||||
admin_scope = await _seeded_admin_scope(db)
|
||||
character = await character_repo.get_active_character(db, admin_scope)
|
||||
assert template is not None
|
||||
assert character is not None
|
||||
assert template.current_personality_version_id is not None
|
||||
assert character.current_personality_version_id is not None
|
||||
assert template.current_personality_version_id != character.current_personality_version_id
|
||||
|
||||
template_personality = await personality_repo.get_personality(
|
||||
db, template.current_personality_version_id
|
||||
)
|
||||
user_personality = await personality_repo.get_personality(
|
||||
db, character.current_personality_version_id
|
||||
)
|
||||
assert template_personality is not None
|
||||
assert user_personality is not None
|
||||
assert template_personality.character_id == template.id
|
||||
assert user_personality.character_id == character.id
|
||||
assert user_personality.reason == "system_template_copy"
|
||||
|
||||
from app.services.auth import create_access_token
|
||||
|
||||
token = create_access_token(subject=admin_scope, extra={"owner_scope": admin_scope})
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as client:
|
||||
response = await client.get("/api/v1/personality/current")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == user_personality.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_seed_default_data_is_idempotent(db_session: AsyncSession) -> None:
|
||||
db = db_session
|
||||
@@ -138,7 +99,6 @@ async def test_seed_default_data_is_idempotent(db_session: AsyncSession) -> None
|
||||
Character.is_system_template.is_(False),
|
||||
)
|
||||
)
|
||||
personality_count = await db.scalar(select(func.count()).select_from(PersonalityVersion))
|
||||
|
||||
assert template_count == 5
|
||||
# is_default=true 模板只有一个(default),所以用户角色数为 1
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""M6 验收测试(rewrite-plan M6 验收门)。
|
||||
|
||||
① 多维关系无单一好感度;边界撤回立即生效;
|
||||
② 铸魂走确认流(锚点确认前不变),分灵保留谱系且不继承原身份;
|
||||
③ 境界正史不进真界本相(归真按 scope 分流,幻境不自动入忆);
|
||||
④ 引文指向的记忆被正影时提示后继版本。
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.main import app
|
||||
from app.models.character import Character
|
||||
from app.models.jingsheng import IdentityRecord
|
||||
from app.models.mirror_tree import Citation, MirrorTree, TreeNode
|
||||
from app.models.safety import AuditEvent
|
||||
from app.models.xirang import FactualRecord, Memory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(
|
||||
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
|
||||
yield c
|
||||
|
||||
|
||||
async def _owner(client: AsyncClient) -> str:
|
||||
"""最新测试用户 id(auth_headers fixture 所建)。"""
|
||||
from app.models.user import User
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
users = (
|
||||
await session.execute(select(User).order_by(User.created_at.desc()))
|
||||
).scalars().all()
|
||||
return users[0].id
|
||||
|
||||
|
||||
async def _insert_solidified_node(tree_id: str, character_id: str, content: str) -> str:
|
||||
"""直插已固化候答节点(测试造数,绕过 kernel 生成流)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
node = TreeNode(
|
||||
tree_id=tree_id,
|
||||
node_type="answer",
|
||||
status="solidified",
|
||||
author="character",
|
||||
content=content,
|
||||
)
|
||||
session.add(node)
|
||||
await session.commit()
|
||||
return node.id
|
||||
|
||||
|
||||
async def _insert_memory(character_id: str, content: str) -> str:
|
||||
"""直插生效明忆(user_explicit / explicit / active)。"""
|
||||
memory = Memory(
|
||||
character_id=character_id,
|
||||
tier="explicit",
|
||||
type="episodic",
|
||||
status="active",
|
||||
source="user_explicit",
|
||||
content=content,
|
||||
)
|
||||
async with AsyncSessionLocal() as session:
|
||||
session.add(memory)
|
||||
await session.commit()
|
||||
return memory.id
|
||||
|
||||
|
||||
async def _active_character(client: AsyncClient) -> dict:
|
||||
resp = await client.get("/api/v1/characters/active")
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ── ① 缘契 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_covenant_multidim_and_no_single_affinity(client: AsyncClient) -> None:
|
||||
character = await _active_character(client)
|
||||
# 单一好感度被拒
|
||||
bad = await client.post(
|
||||
"/api/v1/covenants",
|
||||
json={
|
||||
"character_id": character["id"],
|
||||
"dimensions": {"affinity": 88},
|
||||
},
|
||||
)
|
||||
assert bad.status_code == 422 or "好感度" in bad.text
|
||||
|
||||
# 多维并存 + 主称谓
|
||||
good = await client.post(
|
||||
"/api/v1/covenants",
|
||||
json={
|
||||
"character_id": character["id"],
|
||||
"dimensions": {"trust": "high", "mentorship": "active", "companionship": "warm"},
|
||||
"primary_title": "亦师亦友",
|
||||
"boundaries": {"stop_words": ["停下"]},
|
||||
},
|
||||
)
|
||||
assert good.status_code == 201, good.text
|
||||
cov = good.json()
|
||||
assert set(cov["dimensions"]) == {"trust", "mentorship", "companionship"}
|
||||
|
||||
# 问心(人物自陈候选,不动当前版本)
|
||||
wenxin = await client.post(
|
||||
f"/api/v1/covenants/{cov['id']}/wenxin",
|
||||
json={"candidates": {"trust": "deepening"}, "note": "近期相处更坦诚"},
|
||||
)
|
||||
assert wenxin.status_code == 200
|
||||
|
||||
# 铭契(版本 +1)
|
||||
sealed = await client.post(
|
||||
f"/api/v1/covenants/{cov['id']}/seal",
|
||||
json={"dimensions": {"trust": "deepening", "mentorship": "active", "companionship": "warm"}},
|
||||
)
|
||||
assert sealed.status_code == 200
|
||||
assert sealed.json()["status"] == "sealed"
|
||||
assert sealed.json()["version"] == 1
|
||||
|
||||
events = await client.get(f"/api/v1/covenants/{cov['id']}/events")
|
||||
types = [e["type"] for e in events.json()]
|
||||
assert types == ["propose", "wenxin", "seal"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_covenant_withdraw_effective_immediately(client: AsyncClient) -> None:
|
||||
character = await _active_character(client)
|
||||
created = await client.post(
|
||||
"/api/v1/covenants",
|
||||
json={"character_id": character["id"], "dimensions": {"companionship": "warm"}},
|
||||
)
|
||||
cov_id = created.json()["id"]
|
||||
|
||||
withdrawn = await client.post(f"/api/v1/covenants/{cov_id}/withdraw", json={"reason": "用户决定"})
|
||||
assert withdrawn.status_code == 200
|
||||
assert withdrawn.json()["effective"] == "immediate"
|
||||
|
||||
# 读取立即不可见;再操作被拒
|
||||
active = await client.get(f"/api/v1/covenants/by-character/{character['id']}")
|
||||
assert active.json() is None or active.json().get("id") != cov_id
|
||||
seal_after = await client.post(f"/api/v1/covenants/{cov_id}/seal", json={"dimensions": {"x": "y"}})
|
||||
assert seal_after.status_code == 422 or seal_after.status_code == 410
|
||||
|
||||
# 事件历史仍可查证(不可抹除)
|
||||
events = await client.get(f"/api/v1/covenants/{cov_id}/events")
|
||||
assert "withdraw" in [e["type"] for e in events.json()]
|
||||
|
||||
# 边界变更立即生效
|
||||
created2 = await client.post(
|
||||
"/api/v1/covenants",
|
||||
json={"character_id": character["id"], "dimensions": {"mentorship": "active"}},
|
||||
)
|
||||
cov2 = created2.json()["id"]
|
||||
patched = await client.patch(
|
||||
f"/api/v1/covenants/{cov2}/boundaries",
|
||||
json={"boundaries": {"topics": ["禁谈工作"]}},
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
assert patched.json()["boundaries"]["topics"] == ["禁谈工作"]
|
||||
|
||||
|
||||
# ── ② 铸魂 / 分灵 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soul_forging_requires_confirmation(client: AsyncClient) -> None:
|
||||
character = await _active_character(client)
|
||||
char_id = character["id"]
|
||||
|
||||
new_anchor = {"core": "经历重塑后的身份锚点", "taboos": ["不可说谎"]}
|
||||
proposed = await client.post(
|
||||
f"/api/v1/characters/{char_id}/soul/forgings",
|
||||
json={"anchor_changes": {"identity_baseline_json": new_anchor}, "reason": "重大价值重塑"},
|
||||
)
|
||||
assert proposed.status_code == 201, proposed.text
|
||||
forging_id = proposed.json()["id"]
|
||||
|
||||
# 确认前锚点不变
|
||||
async with AsyncSessionLocal() as session:
|
||||
row = await session.get(Character, char_id)
|
||||
import json as _json
|
||||
|
||||
assert _json.loads(row.identity_baseline_json or "{}").get("core") != "经历重塑后的身份锚点"
|
||||
|
||||
confirmed = await client.post(f"/api/v1/characters/{char_id}/soul/forgings/{forging_id}/confirm")
|
||||
assert confirmed.status_code == 200, confirmed.text
|
||||
body = confirmed.json()
|
||||
assert body["status"] == "confirmed"
|
||||
# 魂籍版本链 +1
|
||||
async with AsyncSessionLocal() as session:
|
||||
versions = [
|
||||
v
|
||||
for v in (
|
||||
await session.execute(
|
||||
select(IdentityRecord.version).where(IdentityRecord.character_id == char_id)
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
assert body["identity_record_version"] == max(versions)
|
||||
row = await session.get(Character, char_id)
|
||||
import json as _json
|
||||
|
||||
assert _json.loads(row.identity_baseline_json)["core"] == "经历重塑后的身份锚点"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_split_soul_preserves_lineage_not_identity(client: AsyncClient) -> None:
|
||||
character = await _active_character(client)
|
||||
char_id = character["id"]
|
||||
|
||||
split = await client.post(
|
||||
f"/api/v1/characters/{char_id}/soul/split",
|
||||
json={"reason": "形仪持续形成独立人格", "initial_anchor": {"core": "分灵新核"}},
|
||||
)
|
||||
assert split.status_code == 201, split.text
|
||||
body = split.json()
|
||||
new_id = body["new_character_id"]
|
||||
assert new_id != char_id
|
||||
# 谱系保留源人物
|
||||
assert body["lineage"][-1]["split_from"] == char_id
|
||||
# 新人物魂籍从 v1 起(不继承原身份)
|
||||
assert body["identity_record_version"] == 1
|
||||
|
||||
lineage = await client.get(f"/api/v1/characters/{new_id}/soul/lineage")
|
||||
assert lineage.status_code == 200
|
||||
assert lineage.json()[-1]["split_from"] == char_id
|
||||
|
||||
|
||||
# ── ③ 境界正史不进真界本相 ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_world_canonization_does_not_touch_reality(client: AsyncClient) -> None:
|
||||
owner = await _owner(client)
|
||||
character = await _active_character(client)
|
||||
|
||||
# 建境界
|
||||
world = await client.post(
|
||||
"/api/v1/worlds", json={"name": "云荒", "description": "东方幻想世界", "rules": {"era": "上古"}}
|
||||
)
|
||||
assert world.status_code == 201, world.text
|
||||
world_id = world.json()["id"]
|
||||
|
||||
# 实体
|
||||
entity = await client.post(
|
||||
f"/api/v1/worlds/{world_id}/entities",
|
||||
json={"kind": "location", "name": "镜湖", "description": "世界之眼"},
|
||||
)
|
||||
assert entity.status_code == 201
|
||||
|
||||
# 建树并直插固化节点(绕过生成流),归真到境界
|
||||
tree = await client.post("/api/v1/trees", json={"character_id": character["id"], "title": "云荒纪事"})
|
||||
tree_id = tree.json()["id"]
|
||||
node_id = await _insert_solidified_node(tree_id, character["id"], "湖面浮起旧镜……")
|
||||
canon = await client.post(
|
||||
f"/api/v1/trees/{tree_id}/canonize",
|
||||
json={"endpoint_node_id": node_id, "canonical_scope": "persistent_world", "world_id": world_id},
|
||||
)
|
||||
assert canon.status_code in (200, 201), canon.text
|
||||
|
||||
# 境界正史推进
|
||||
chronicle = await client.get(f"/api/v1/worlds/{world_id}/chronicle")
|
||||
assert chronicle.status_code == 200
|
||||
assert len(chronicle.json()["entries"]) == 1
|
||||
|
||||
# 真界本相未被触碰:无真界记忆候选(提取不触发)
|
||||
async with AsyncSessionLocal() as session:
|
||||
reality_candidates = [
|
||||
m
|
||||
for m in (
|
||||
await session.execute(
|
||||
select(Memory).where(Memory.character_id == character["id"])
|
||||
)
|
||||
).scalars()
|
||||
if m.realm == "reality"
|
||||
]
|
||||
assert reality_candidates == [], "境界归真不得产生真界记忆候选"
|
||||
# 实录带 scope 维度(境界实录,非真界实录)
|
||||
factuals = [
|
||||
f
|
||||
for f in (
|
||||
await session.execute(
|
||||
select(FactualRecord).where(FactualRecord.character_id == character["id"])
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
assert factuals and all(f.scope == "persistent_world" for f in factuals)
|
||||
|
||||
|
||||
# ── ④ 引文→记忆正影后继提示 ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_citation_reports_memory_successor(client: AsyncClient) -> None:
|
||||
character = await _active_character(client)
|
||||
owner = await _owner(client)
|
||||
|
||||
# 直插明忆(fact,user_explicit)→ 正影出新版本
|
||||
memory_id = await _insert_memory(character["id"], "初版记忆:曾在雨天同行")
|
||||
|
||||
corrected = await client.post(
|
||||
f"/api/v1/memories/{memory_id}/correct", json={"new_content": "正影后:雪天同行"}
|
||||
)
|
||||
assert corrected.status_code == 200, corrected.text
|
||||
new_id = corrected.json()["id"]
|
||||
assert new_id != memory_id
|
||||
|
||||
# 建树直插节点 + 指向旧记忆的引文
|
||||
tree = await client.post("/api/v1/trees", json={"character_id": character["id"], "title": "引文树"})
|
||||
tree_id = tree.json()["id"]
|
||||
node_id = await _insert_solidified_node(tree_id, character["id"], "关于那次同行")
|
||||
citation = await client.post(
|
||||
f"/api/v1/nodes/{node_id}/citations",
|
||||
json={
|
||||
"target_type": "memory",
|
||||
"locator": {"memory_id": memory_id},
|
||||
"snapshot": "初版记忆快照",
|
||||
"source_id": memory_id,
|
||||
"source_version": 1,
|
||||
},
|
||||
)
|
||||
assert citation.status_code == 201, citation.text
|
||||
|
||||
# 列出引文:被引记忆已 superseded → 提示后继版本,不静默替换
|
||||
listed = await client.get(f"/api/v1/nodes/{node_id}/citations")
|
||||
entries = listed.json()
|
||||
target = next(c for c in entries if c["source_id"] == memory_id)
|
||||
assert target.get("memory_superseded") is True
|
||||
assert target.get("successor_memory_id") == new_id
|
||||
Reference in new issue
Block a user