"""道体铸魂与分灵(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, }