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
@@ -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