Files
chenxin 7e23d6fa84 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 干净
2026-08-27 20:10:11 +08:00

213 lines
8.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""道体铸魂与分灵(identity-continuity.md §3)。
- 道体锚点 = characters.identity_baseline_json(M1 已有):最小稳定身份锚点,
日常对话/模型输出/普通工具不可静默覆盖——修改必须走铸魂确认流;
- 调节层 = 息壤 persona 记忆(M3):语气/称谓/兴趣等渐进演进,演进有证据
(source_refs 指向归真),本模块不重复建设;
- 铸魂(soul_forgings):重大锚点变更 pending → confirm 后应用并签发新魂籍版本;
- 分灵(split):连续性无法维持时创建新人物——继承资料与谱系、不继承原身份。
"""
from __future__ import annotations
import json
from datetime import datetime
from sqlalchemy import select
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.core.ids import new_character_id
from app.domains.jingsheng.identity import build_identity_record
from app.domains import one_qi
from app.models.character import Character
from app.models.jingsheng import IdentityRecord, SoulForging
FORGING_FIELDS = ("identity_baseline_json",)
async def propose_forging(
*, character_id: str, owner_scope: str, anchor_changes: dict, reason: str
) -> dict:
"""发起铸魂:锚点变更提案(pending),确认前人物锚点不变。"""
character = await _owned(character_id, owner_scope)
if not anchor_changes:
raise AppError("invalid_request", "锚点变更不能为空。")
unknown = set(anchor_changes) - set(FORGING_FIELDS)
if unknown:
raise AppError("invalid_request", f"不可铸魂字段:{sorted(unknown)}(仅道体锚点)。")
async with AsyncSessionLocal() as session:
pending = (
await session.execute(
select(SoulForging).where(
SoulForging.character_id == character_id, SoulForging.status == "pending"
)
)
).scalars().first()
if pending is not None:
raise AppError("invalid_request", "已有待确认铸魂。", status_code=409)
forging = SoulForging(
character_id=character_id,
proposed_anchor_json=json.dumps(anchor_changes["identity_baseline_json"], ensure_ascii=False),
reason=reason,
status="pending",
)
session.add(forging)
await session.commit()
session.expunge(forging)
return _forging_dict(forging)
async def issue_record(character_id: str, owner_scope: str) -> dict:
"""签发下一版魂籍(版本 = 当前最大版本 + 1)。"""
await _owned(character_id, owner_scope)
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
latest = (
await session.execute(
select(IdentityRecord.version)
.where(IdentityRecord.character_id == character_id)
.order_by(IdentityRecord.version.desc())
.limit(1)
)
).scalar_one_or_none()
version = (latest or 0) + 1
record = build_identity_record(
character_id=character_id,
version=version,
identity={
"name": character.name,
"identity_baseline": json.loads(character.identity_baseline_json or "{}"),
},
runtime_state={"status": character.status},
)
session.add(record)
await session.commit()
return {"id": record.id, "version": version}
async def confirm_forging(forging_id: str, owner_scope: str) -> dict:
"""确认铸魂:应用锚点变更 + 签发新魂籍版本(identity_record +1)。"""
async with AsyncSessionLocal() as session:
forging = await session.get(SoulForging, forging_id)
if forging is None:
raise AppError("not_found", "铸魂提案不存在。", status_code=404)
character = await session.get(Character, forging.character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "铸魂提案不存在。", status_code=404)
if forging.status != "pending":
raise AppError("invalid_request", "铸魂已处理。", status_code=409)
new_anchor = forging.proposed_anchor_json
forging.status = "confirmed"
forging.confirmed_at = utc_now()
character.identity_baseline_json = new_anchor
await session.commit()
forging_id_out, character_id = forging.id, forging.character_id
await one_qi.emit(
"soul.forged", character_id=character_id,
payload={"forging_id": forging_id_out}, source="jingsheng",
)
# 新魂籍版本(铸魂必须可追溯到版本链)
record = await issue_record(character_id, owner_scope)
return {"forging_id": forging_id_out, "status": "confirmed", "identity_record_version": record["version"]}
async def reject_forging(forging_id: str, owner_scope: str) -> dict:
async with AsyncSessionLocal() as session:
forging = await session.get(SoulForging, forging_id)
if forging is None:
raise AppError("not_found", "铸魂提案不存在。", status_code=404)
character = await session.get(Character, forging.character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "铸魂提案不存在。", status_code=404)
if forging.status != "pending":
raise AppError("invalid_request", "铸魂已处理。", status_code=409)
forging.status = "rejected"
await session.commit()
return {"forging_id": forging_id, "status": "rejected"}
async def list_forgings(character_id: str, owner_scope: str) -> list[dict]:
await _owned(character_id, owner_scope)
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(SoulForging)
.where(SoulForging.character_id == character_id)
.order_by(SoulForging.created_at.desc())
)
).scalars().all()
return [_forging_dict(r) for r in rows]
async def split_soul(
*, character_id: str, owner_scope: str, reason: str, initial_anchor: dict
) -> dict:
"""分灵:以新身份创建新人物,继承资料与谱系、不继承原身份。
谱系(lineage):新人物锚点携带 split_from 链(源人物 id 与代数);
新人物 id 必然不同、魂籍独立从 v1 起。
"""
source = await _owned(character_id, owner_scope)
prior_lineage = json.loads(source.identity_baseline_json or "{}").get("lineage", [])
anchor = dict(initial_anchor)
anchor["lineage"] = [*prior_lineage, {"split_from": character_id, "at": utc_now().isoformat()}]
async with AsyncSessionLocal() as session:
new_character = Character(
id=new_character_id(),
owner_scope=owner_scope,
name=f"{source.name}·分灵",
identity_baseline_json=json.dumps(anchor, ensure_ascii=False),
)
session.add(new_character)
await session.commit()
new_id = new_character.id
await one_qi.emit(
"soul.split", character_id=None,
payload={"from": character_id, "new_character_id": new_id, "reason": reason},
source="jingsheng",
)
record = await issue_record(new_id, owner_scope)
return {
"new_character_id": new_id,
"lineage": anchor["lineage"],
"identity_record_version": record["version"],
}
async def get_lineage(character_id: str, owner_scope: str) -> list[dict]:
"""谱系回溯:从锚点 lineage 链展开(源人物资料可循)。"""
character = await _owned(character_id, owner_scope)
chain = json.loads(character.identity_baseline_json or "{}").get("lineage", [])
# 展开源人物名(存在性核验;已死亡/删除的源仍保留 id 考据)
out = []
for link in chain:
entry = dict(link)
async with AsyncSessionLocal() as session:
src = await session.get(Character, link.get("split_from", ""))
entry["source_name"] = src.name if src else None
out.append(entry)
return out
async def _owned(character_id: str, owner_scope: str) -> Character:
async with AsyncSessionLocal() as session:
character = await session.get(Character, character_id)
if character is None or character.owner_scope != owner_scope:
raise AppError("not_found", "人物不存在。", status_code=404)
session.expunge(character)
return character
def _forging_dict(f: SoulForging) -> dict:
return {
"id": f.id,
"character_id": f.character_id,
"proposed_anchor": json.loads(f.proposed_anchor_json or "{}"),
"reason": f.reason,
"status": f.status,
"created_at": f.created_at.isoformat() if f.created_at else None,
"confirmed_at": f.confirmed_at.isoformat() if f.confirmed_at else None,
}