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

+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