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:
chenxin committed 2026-08-27 20:10:11 +08:00
1 parent 684261f4b4
commit 7e23d6fa84
108 files changed
+2600 -9732

No files matched your search

+1 -1
View File
@@ -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
-144
View File
@@ -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
-104
View File
@@ -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")
-115
View File
@@ -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 路径。"""
-40
View File
@@ -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
+337
View File
@@ -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