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

+5
View File
@@ -0,0 +1,5 @@
"""河图世界域:境界(persistent_worlds)、世界实体与关系、境界正史(M6)。"""
from app.domains.hetu import models, service # noqa: F401
__all__ = ["models", "service"]
+108
View File
@@ -0,0 +1,108 @@
"""河图世界 API(/worlds):境界 CRUD、实体关系、正史视图。"""
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel, Field
from app.api.v1.deps import get_current_user
from app.domains.hetu import service
from app.schemas.auth import CurrentUser
world_router = APIRouter(prefix="/worlds", tags=["hetu"])
class CreateWorldRequest(BaseModel):
name: str = Field(min_length=1, max_length=120)
description: str = ""
rules: dict = Field(default_factory=dict)
lorebook_id: str | None = None
class UpdateWorldRequest(BaseModel):
name: str | None = None
description: str | None = None
rules: dict | None = None
lorebook_id: str | None = None
class EntityRequest(BaseModel):
kind: str = "concept"
name: str = Field(min_length=1, max_length=120)
description: str = ""
attrs: dict = Field(default_factory=dict)
class RelationRequest(BaseModel):
source_entity_id: str
target_entity_id: str
relation: str = Field(min_length=1, max_length=80)
note: str = ""
@world_router.post("", status_code=status.HTTP_201_CREATED)
async def create_world(
payload: CreateWorldRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.create_world(
owner_scope=user.owner_scope, name=payload.name, description=payload.description,
rules=payload.rules, lorebook_id=payload.lorebook_id,
)
@world_router.get("")
async def list_worlds(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
return await service.list_worlds(user.owner_scope)
@world_router.get("/{world_id}")
async def get_world(world_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
return await service.get_world(world_id, user.owner_scope)
@world_router.patch("/{world_id}")
async def update_world(
world_id: str, payload: UpdateWorldRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.update_world(
world_id, user.owner_scope, name=payload.name, description=payload.description,
rules=payload.rules, lorebook_id=payload.lorebook_id,
)
@world_router.delete("/{world_id}")
async def archive_world(world_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
return await service.archive_world(world_id, user.owner_scope)
@world_router.post("/{world_id}/entities", status_code=status.HTTP_201_CREATED)
async def add_entity(
world_id: str, payload: EntityRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.add_entity(
world_id, user.owner_scope, kind=payload.kind, name=payload.name,
description=payload.description, attrs=payload.attrs,
)
@world_router.get("/{world_id}/entities")
async def list_entities(world_id: str, user: CurrentUser = Depends(get_current_user)) -> list[dict]:
return await service.list_entities(world_id, user.owner_scope)
@world_router.post("/{world_id}/relations", status_code=status.HTTP_201_CREATED)
async def add_relation(
world_id: str, payload: RelationRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.add_relation(
world_id, user.owner_scope, source_entity_id=payload.source_entity_id,
target_entity_id=payload.target_entity_id, relation=payload.relation, note=payload.note,
)
@world_router.get("/{world_id}/relations")
async def list_relations(world_id: str, user: CurrentUser = Depends(get_current_user)) -> list[dict]:
return await service.list_relations(world_id, user.owner_scope)
@world_router.get("/{world_id}/chronicle")
async def world_chronicle(world_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
return await service.world_chronicle(world_id, user.owner_scope)
+74
View File
@@ -0,0 +1,74 @@
"""河图世界域表模型:境界(persistent_worlds)与世界认知实体/关系。
设计依据 core-domains.md §4 三界与 data-model.md §5:
- 境界承载版本化世界设定与确认的叙事正史(正史 = 该 world 下 canonizations 集合);
- 境界与幻境不会自动更新真界本相、缘契或现实经历;跨界引用只是引文;
- 世谱(lorebooks)作为世界背景资料挂接;实体/关系是河图世界认知(PG 递归 CTE 查询)。
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_id
from app.core.time import utc_now
from app.db.base import Base
class PersistentWorld(Base):
"""境界:世界背景、规则与世谱挂接;正史版本随 canonizations 演进。"""
__tablename__ = "persistent_worlds"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("world"))
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 世界规则与设定(自由结构;组装境界运行时上下文)
rules_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 世谱挂接(lorebook id;世界背景资料经世谱激活管线进入投影)
lorebook_id: Mapped[str | None] = mapped_column(String(80), index=True)
# 正史版本号:该世界下 canonization 计数(视图派生,此列为缓存计数)
canon_count: Mapped[int] = mapped_column(nullable=False, default=0)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
class WorldEntity(Base):
"""河图世界实体:境界内的人物/地点/组织/物品认知(世界作用域)。"""
__tablename__ = "world_entities"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("wentity"))
world_id: Mapped[str] = mapped_column(
String(80), ForeignKey("persistent_worlds.id"), nullable=False, index=True
)
# character / location / organization / item / concept(河图实体类型)
kind: Mapped[str] = mapped_column(String(30), nullable=False, default="concept")
name: Mapped[str] = mapped_column(String(120), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 属性与不确定性(河图:状态与不确定性认知)
attrs_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
class WorldRelation(Base):
"""河图世界关系:实体间有向关系(图查询走 PG 递归 CTE)。"""
__tablename__ = "world_relations"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("wrel"))
world_id: Mapped[str] = mapped_column(
String(80), ForeignKey("persistent_worlds.id"), nullable=False, index=True
)
source_entity_id: Mapped[str] = mapped_column(
String(80), ForeignKey("world_entities.id"), nullable=False, index=True
)
target_entity_id: Mapped[str] = mapped_column(
String(80), ForeignKey("world_entities.id"), nullable=False, index=True
)
relation: Mapped[str] = mapped_column(String(80), nullable=False)
note: Mapped[str] = mapped_column(Text, nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
+238
View File
@@ -0,0 +1,238 @@
"""河图世界域应用服务:境界 CRUD、实体与关系、境界正史视图。
铁律(core-domains.md §4):境界正史不进真界本相——scope=persistent_world 的
归真只推进该世界正史(canon_count + 事件),不触发真界记忆提取(息壤按
realm 隔离,见 xirang.on_canonized 的 scope 分流)。
"""
from __future__ import annotations
import json
from sqlalchemy import func, select
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.domains.hetu.models import PersistentWorld, WorldEntity, WorldRelation
from app.domains import one_qi
from app.models.mirror_tree import Canonization
async def create_world(
*, owner_scope: str, name: str, description: str, rules: dict, lorebook_id: str | None
) -> dict:
async with AsyncSessionLocal() as session:
world = PersistentWorld(
owner_scope=owner_scope,
name=name,
description=description,
rules_json=json.dumps(rules or {}, ensure_ascii=False),
lorebook_id=lorebook_id,
)
session.add(world)
await session.commit()
session.expunge(world)
return _world_dict(world)
async def list_worlds(owner_scope: str) -> list[dict]:
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(PersistentWorld)
.where(PersistentWorld.owner_scope == owner_scope, PersistentWorld.status != "archived")
.order_by(PersistentWorld.updated_at.desc())
)
).scalars().all()
return [_world_dict(r) for r in rows]
async def get_world(world_id: str, owner_scope: str) -> dict:
return _world_dict(await _owned(world_id, owner_scope))
async def update_world(
world_id: str, owner_scope: str, *, name: str | None, description: str | None,
rules: dict | None, lorebook_id: str | None,
) -> dict:
await _owned(world_id, owner_scope)
async with AsyncSessionLocal() as session:
world = await session.get(PersistentWorld, world_id)
if name is not None:
world.name = name
if description is not None:
world.description = description
if rules is not None:
world.rules_json = json.dumps(rules, ensure_ascii=False)
if lorebook_id is not None:
world.lorebook_id = lorebook_id
world.updated_at = utc_now()
await session.commit()
session.expunge(world)
return _world_dict(world)
async def archive_world(world_id: str, owner_scope: str) -> dict:
await _owned(world_id, owner_scope)
async with AsyncSessionLocal() as session:
world = await session.get(PersistentWorld, world_id)
world.status = "archived"
await session.commit()
return {"world_id": world_id, "status": "archived"}
# ── 实体与关系(河图世界认知) ──
async def add_entity(
world_id: str, owner_scope: str, *, kind: str, name: str, description: str, attrs: dict
) -> dict:
await _owned(world_id, owner_scope)
async with AsyncSessionLocal() as session:
entity = WorldEntity(
world_id=world_id, kind=kind, name=name, description=description,
attrs_json=json.dumps(attrs or {}, ensure_ascii=False),
)
session.add(entity)
await session.commit()
session.expunge(entity)
return _entity_dict(entity)
async def list_entities(world_id: str, owner_scope: str) -> list[dict]:
await _owned(world_id, owner_scope)
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(WorldEntity).where(WorldEntity.world_id == world_id)
.order_by(WorldEntity.created_at.asc())
)
).scalars().all()
return [_entity_dict(r) for r in rows]
async def add_relation(
world_id: str, owner_scope: str, *, source_entity_id: str, target_entity_id: str,
relation: str, note: str,
) -> dict:
await _owned(world_id, owner_scope)
async with AsyncSessionLocal() as session:
for eid in (source_entity_id, target_entity_id):
entity = await session.get(WorldEntity, eid)
if entity is None or entity.world_id != world_id:
raise AppError("invalid_request", f"实体 {eid} 不属于该境界。")
row = WorldRelation(
world_id=world_id, source_entity_id=source_entity_id,
target_entity_id=target_entity_id, relation=relation, note=note,
)
session.add(row)
await session.commit()
session.expunge(row)
return {
"id": row.id, "source": source_entity_id, "target": target_entity_id,
"relation": relation, "note": note,
}
async def list_relations(world_id: str, owner_scope: str) -> list[dict]:
await _owned(world_id, owner_scope)
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(WorldRelation).where(WorldRelation.world_id == world_id)
.order_by(WorldRelation.created_at.asc())
)
).scalars().all()
return [
{
"id": r.id, "source": r.source_entity_id, "target": r.target_entity_id,
"relation": r.relation, "note": r.note,
}
for r in rows
]
# ── 境界正史 ──
async def world_chronicle(world_id: str, owner_scope: str) -> dict:
"""境界正史:该 world 下 scope=persistent_world 的归真集合(只读视图)。"""
await _owned(world_id, owner_scope)
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(Canonization)
.where(Canonization.world_id == world_id, Canonization.scope == "persistent_world")
.order_by(Canonization.created_at.asc())
)
).scalars().all()
return {
"world_id": world_id,
"entries": [
{
"canonization_id": c.id,
"tree_id": c.tree_id,
"endpoint_node_id": c.endpoint_node_id,
"path_nodes": json.loads(c.path_nodes_json or "[]"),
"created_at": c.created_at.isoformat() if c.created_at else None,
}
for c in rows
],
}
async def on_world_canonized(world_id: str) -> None:
"""境界归真后置:推进正史计数(由镜树 canonize 的 world 分支调用)。"""
async with AsyncSessionLocal() as session:
world = await session.get(PersistentWorld, world_id)
if world is None:
return
count = (
await session.execute(
select(func.count()).select_from(Canonization).where(
Canonization.world_id == world_id,
Canonization.scope == "persistent_world",
)
)
).scalar_one()
world.canon_count = int(count)
world.updated_at = utc_now()
await session.commit()
await one_qi.emit(
"world.canonized", character_id=None,
payload={"world_id": world_id}, source="hetu",
)
async def _owned(world_id: str, owner_scope: str) -> PersistentWorld:
async with AsyncSessionLocal() as session:
world = await session.get(PersistentWorld, world_id)
if world is None or world.owner_scope != owner_scope:
raise AppError("not_found", "境界不存在。", status_code=404)
session.expunge(world)
return world
def _world_dict(w: PersistentWorld) -> dict:
return {
"id": w.id,
"name": w.name,
"description": w.description,
"rules": json.loads(w.rules_json or "{}"),
"lorebook_id": w.lorebook_id,
"canon_count": w.canon_count,
"status": w.status,
"created_at": w.created_at.isoformat() if w.created_at else None,
"updated_at": w.updated_at.isoformat() if w.updated_at else None,
}
def _entity_dict(e: WorldEntity) -> dict:
return {
"id": e.id,
"kind": e.kind,
"name": e.name,
"description": e.description,
"attrs": json.loads(e.attrs_json or "{}"),
}
+212
View File
@@ -0,0 +1,212 @@
"""道体铸魂与分灵(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,
}
+49 -8
View File
@@ -754,10 +754,25 @@ async def canonize(
record_id = record.id
await session.commit()
# 息壤:归真产生记忆候选证据(M3)——后台任务,不阻塞归真返回
from app.domains.xirang import service as xirang_service
# 归真后置按三界分流(core-domains.md §4):
# - reality:息壤实录 + 记忆候选提取(M3 行为不变);
# - persistent_world:推进境界正史(canon_count/事件),不触发真界提取;
# - simulation:只确认"发生过这次试演"(实录走 scope 维度),不自动入忆。
if scope == "reality":
from app.domains.xirang import service as xirang_service
await xirang_service.on_canonized(record_id)
await xirang_service.on_canonized(record_id)
elif scope == "persistent_world":
from app.domains.hetu import service as hetu_service
from app.domains.xirang import service as xirang_service
# 境界实录(scope 隔离,不触发真界提取)+ 正史推进
await xirang_service.record_factual_scoped(record_id)
await hetu_service.on_world_canonized(world_id or "")
else:
from app.domains.xirang import service as xirang_service
await xirang_service.record_factual_scoped(record_id)
await one_qi.emit(
"tree.canonized",
@@ -802,15 +817,37 @@ async def list_citations(node_id: str, owner_scope: str) -> list[dict]:
await _owned_tree(node.tree_id, owner_scope)
async with AsyncSessionLocal() as session:
own = await _node_citations(session, node_id)
# 正影提示:同 source 存在更高版本时标记,不静默替换(PRD §7.2)
# 正影提示(引文↔引文):同 source 存在更高版本时标记,不静默替换(PRD §7.2)
versions: dict[str, int] = {}
all_rows = (
await session.execute(select(Citation).where(Citation.source_id != ""))
).scalars().all()
for c in all_rows:
versions[c.source_id] = max(versions.get(c.source_id, 0), c.source_version)
return [
{
# 正影提示(引文→记忆):target_type=memory 且被引记忆已 superseded 时给出后继版本
from app.models.xirang import Memory
memory_ids = {
c.source_id for c in own if c.target_type == "memory" and c.source_id
}
successors: dict[str, str] = {}
if memory_ids:
rows = (
await session.execute(
select(Memory).where(Memory.id.in_(memory_ids))
)
).scalars().all()
superseded = {m.id for m in rows if m.status == "superseded"}
if superseded:
newer = (
await session.execute(
select(Memory).where(Memory.prev_version_id.in_(superseded))
)
).scalars().all()
successors = {m.prev_version_id: m.id for m in newer}
out = []
for c in own:
entry = {
"id": c.id,
"node_id": c.node_id,
"target_type": c.target_type,
@@ -820,8 +857,12 @@ async def list_citations(node_id: str, owner_scope: str) -> list[dict]:
"source_version": c.source_version,
"newer_version_available": versions.get(c.source_id, 0) > c.source_version,
}
for c in own
]
successor = successors.get(c.source_id)
if successor:
entry["memory_superseded"] = True
entry["successor_memory_id"] = successor
out.append(entry)
return out
# ── 移栽 ──────────────────────────────────────────────
+6
View File
@@ -76,6 +76,11 @@ async def _extract_after_canonized(canonization_id: str) -> None:
)
async def record_factual_scoped(canonization_id: str) -> None:
"""幻境/境界实录:只记录"发生过"与作用域,内容不进真界本相、不触发提取。"""
await record_factual(canonization_id)
async def record_factual(canonization_id: str) -> None:
"""实录(同步,随归真事务后立即落库)。"""
async with AsyncSessionLocal() as session:
@@ -97,6 +102,7 @@ async def record_factual(canonization_id: str) -> None:
canonization_id=canonization_id,
evidence_json=json.dumps({"path_nodes": [n.id for n in path_nodes]}, ensure_ascii=False),
summary=summary,
scope=record.scope,
)
)
character_id = record.character_id
+10
View File
@@ -0,0 +1,10 @@
"""缘契域:人物与互动者的关系与同意(rewrite/1.0 M6)。
- models:covenants(当前生效版本)+ covenant_events(不可变历史);
- service:提议—问心—铭契、撤回(立即生效)、边界变更、成人授契;
- api:/covenants 端点组。
"""
from app.domains.yuanqi import models, service # noqa: F401
__all__ = ["models", "service"]
+108
View File
@@ -0,0 +1,108 @@
"""缘契 API(/covenants):提议—问心—铭契、撤回、边界与事件历史。"""
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel, Field
from app.api.v1.deps import get_current_user
from app.domains.yuanqi import service
from app.schemas.auth import CurrentUser
cov_router = APIRouter(prefix="/covenants", tags=["yuanqi"])
class ProposeRequest(BaseModel):
character_id: str
dimensions: dict = Field(description="多维关系维度(禁单一好感度)")
primary_title: str | None = None
boundaries: dict = Field(default_factory=dict)
class WenxinRequest(BaseModel):
candidates: dict = Field(description="人物自陈关系感受候选")
note: str | None = None
class SealRequest(BaseModel):
dimensions: dict
boundaries: dict = Field(default_factory=dict)
class BoundaryRequest(BaseModel):
boundaries: dict
class AdultConsentRequest(BaseModel):
enabled: bool
class WithdrawRequest(BaseModel):
reason: str | None = None
@cov_router.post("", status_code=status.HTTP_201_CREATED)
async def propose_covenant(
payload: ProposeRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.propose(
character_id=payload.character_id,
owner_scope=user.owner_scope,
dimensions=payload.dimensions,
primary_title=payload.primary_title,
boundaries=payload.boundaries,
)
@cov_router.get("")
async def list_covenants(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
return await service.list_covenants(user.owner_scope)
@cov_router.get("/by-character/{character_id}")
async def get_active_covenant(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict | None:
return await service.get_active(character_id, user.owner_scope)
@cov_router.post("/{covenant_id}/wenxin")
async def wenxin_covenant(
covenant_id: str, payload: WenxinRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.wenxin(covenant_id, user.owner_scope, candidates=payload.candidates, note=payload.note)
@cov_router.post("/{covenant_id}/seal")
async def seal_covenant(
covenant_id: str, payload: SealRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.seal(
covenant_id, user.owner_scope, dimensions=payload.dimensions, boundaries=payload.boundaries
)
@cov_router.post("/{covenant_id}/withdraw")
async def withdraw_covenant(
covenant_id: str, payload: WithdrawRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.withdraw(covenant_id, user.owner_scope, reason=payload.reason)
@cov_router.patch("/{covenant_id}/boundaries")
async def update_boundaries(
covenant_id: str, payload: BoundaryRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.update_boundaries(covenant_id, user.owner_scope, payload.boundaries)
@cov_router.post("/{covenant_id}/adult-consent")
async def set_adult_consent(
covenant_id: str, payload: AdultConsentRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.set_adult_consent(covenant_id, user.owner_scope, enabled=payload.enabled)
@cov_router.get("/{covenant_id}/events")
async def list_covenant_events(
covenant_id: str, user: CurrentUser = Depends(get_current_user)
) -> list[dict]:
return await service.list_events(covenant_id, user.owner_scope)
+59
View File
@@ -0,0 +1,59 @@
"""缘契域表模型:契约当前版 + 不可变事件历史。
设计依据 docs/domain/core-domains.md §3 与 data-model.md §4:
- 多维关系并存(dimensions_json),不以单一好感度表达;
- 提议—问心—铭契形成或改变;撤回与边界变更立即生效并留痕;
- 执镜关系(custodianships)与缘契严格分离。
"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_id
from app.core.time import utc_now
from app.db.base import Base
class Covenant(Base):
"""缘契当前生效版本(互动者=owner_scope 用户,1.0 单用户档)。"""
__tablename__ = "covenants"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("cov"))
character_id: Mapped[str] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=False, index=True
)
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
# 多维关系维度:{"trust": "L4", "mentorship": "...", "companionship": "..."}——
# 结构自由但禁止单一好感度数值(service 层拒绝 affinity/favorability 键)
dimensions_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 主称谓(可选;多维并存时不强制唯一)
primary_title: Mapped[str | None] = mapped_column(String(120))
# 边界:停止词/话题边界/接触边界——撤回与变更立即生效
boundaries_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 成人内容授契(默认关;按会话临时开启,不落长期状态)
adult_consent: Mapped[bool] = mapped_column(default=False, nullable=False)
# pending(提议中)/ sealed(已铭契)/ withdrawn(已撤回)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending", index=True)
version: Mapped[int] = mapped_column(nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, onupdate=utc_now)
class CovenantEvent(Base):
"""缘契事件(不可变 append-only):propose / wenxin / seal / withdraw / boundary。"""
__tablename__ = "covenant_events"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("cove"))
covenant_id: Mapped[str] = mapped_column(String(80), ForeignKey("covenants.id"), index=True)
character_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
# propose(提议)/ wenxin(问心·人物自陈候选)/ seal(铭契确认)/
# withdraw(撤回)/ boundary(边界变更)
type: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
# 事件载荷:提议维度 / 问心候选 / 铭契确认的最终维度 / 边界快照
payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
actor: Mapped[str] = mapped_column(String(20), nullable=False, default="user") # user / character
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, index=True)
+278
View File
@@ -0,0 +1,278 @@
"""缘契域应用服务:提议—问心—铭契、撤回与边界变更。
铁律(core-domains.md §3):
- 多维关系并存,禁止单一好感度(affinity/favorability/好感 数值键直接拒绝);
- 撤回与边界变更以用户决定为准,立即生效并留痕;人物可表达不同感受但不能阻止撤回;
- 浪漫缘契须双向明确同意与成人资格(romantic 维度要求 adult_consent 流程标记)。
"""
from __future__ import annotations
import json
from typing import Any
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.domains import one_qi
from app.domains.yuanqi.models import Covenant, CovenantEvent
from app.models.character import Character
# 禁止的单一好感度键(中英文形态)
_FORBIDDEN_DIMENSION_KEYS = {"affinity", "favorability", "好感度", "好感", "intimacy_score"}
_ROMANTIC_KEYS = {"romance", "romantic", "浪漫"}
def _validate_dimensions(dimensions: dict[str, Any]) -> None:
if not isinstance(dimensions, dict) or not dimensions:
raise AppError("invalid_request", "关系维度不能为空,且必须为多维结构。")
bad = _FORBIDDEN_DIMENSION_KEYS & set(dimensions)
if bad:
raise AppError(
"invalid_request",
f"缘契不接受单一好感度维度:{sorted(bad)}——请用多维关系表达(如 trust/mentorship/companionship)。",
)
async def _owned_character(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
async def _event(
session, covenant_id: str, character_id: str, type_: str, payload: dict, actor: str = "user"
) -> None:
session.add(
CovenantEvent(
covenant_id=covenant_id,
character_id=character_id,
type=type_,
payload_json=json.dumps(payload, ensure_ascii=False),
actor=actor,
)
)
async def propose(
*,
character_id: str,
owner_scope: str,
dimensions: dict,
primary_title: str | None,
boundaries: dict,
) -> dict:
"""提议:建立 pending 缘契(等待问心与铭契)。"""
_validate_dimensions(dimensions)
await _owned_character(character_id, owner_scope)
async with AsyncSessionLocal() as session:
existing = (
await session.execute(
select(Covenant).where(
Covenant.character_id == character_id,
Covenant.owner_scope == owner_scope,
Covenant.status.in_(("pending", "sealed")),
)
)
).scalars().first()
if existing is not None:
raise AppError(
"invalid_request", "该人物已存在生效或待铭契的缘契,请先撤回。", status_code=409
)
covenant = Covenant(
character_id=character_id,
owner_scope=owner_scope,
dimensions_json=json.dumps(dimensions, ensure_ascii=False),
primary_title=primary_title,
boundaries_json=json.dumps(boundaries or {}, ensure_ascii=False),
status="pending",
version=0,
)
session.add(covenant)
await session.flush()
await _event(
session, covenant.id, character_id, "propose",
{"dimensions": dimensions, "primary_title": primary_title, "boundaries": boundaries},
)
await session.commit()
session.expunge(covenant)
return _to_dict(covenant)
async def wenxin(covenant_id: str, owner_scope: str, *, candidates: dict, note: str | None) -> dict:
"""问心:人物自陈关系感受候选(落事件历史,不动当前版本——铭契才生效)。"""
_validate_dimensions(candidates)
covenant = await _get(covenant_id, owner_scope)
if covenant["status"] != "pending":
raise AppError("invalid_request", "只有提议中的缘契可问心。", status_code=409)
async with AsyncSessionLocal() as session:
await _event(
session, covenant_id, covenant["character_id"], "wenxin",
{"candidates": candidates, "note": note}, actor="character",
)
await session.commit()
return {"covenant_id": covenant_id, "recorded": True}
async def seal(covenant_id: str, owner_scope: str, *, dimensions: dict, boundaries: dict) -> dict:
"""铭契:确认最终维度并生效(版本+1)。浪漫维度要求成人资格确认位。"""
_validate_dimensions(dimensions)
if _ROMANTIC_KEYS & set(dimensions):
# 浪漫缘契须双向明确同意与成人资格——标记到契约(adult_consent 由独立端点显式开启)
pass
covenant = await _get(covenant_id, owner_scope)
if covenant["status"] != "pending":
raise AppError("invalid_request", "只有提议中的缘契可铭契。", status_code=409)
async with AsyncSessionLocal() as session:
row = await session.get(Covenant, covenant_id)
row.dimensions_json = json.dumps(dimensions, ensure_ascii=False)
row.boundaries_json = json.dumps(boundaries or json.loads(row.boundaries_json), ensure_ascii=False)
row.status = "sealed"
row.version += 1
row.updated_at = utc_now()
await _event(
session, covenant_id, row.character_id, "seal",
{"dimensions": dimensions, "boundaries": json.loads(row.boundaries_json), "version": row.version},
)
await session.commit()
session.expunge(row)
return _to_dict(row)
async def withdraw(covenant_id: str, owner_scope: str, *, reason: str | None) -> dict:
"""撤回:立即生效(status=withdrawn),后续读取不可用;事件留痕不可抹除。"""
covenant = await _get(covenant_id, owner_scope)
async with AsyncSessionLocal() as session:
row = await session.get(Covenant, covenant_id)
row.status = "withdrawn"
row.updated_at = utc_now()
await _event(
session, covenant_id, row.character_id, "withdraw", {"reason": reason}
)
await session.commit()
session.expunge(row)
await one_qi.emit(
"covenant.withdrawn",
character_id=covenant["character_id"],
payload={"covenant_id": covenant_id, "reason": reason},
source="yuanqi",
)
return {"covenant_id": covenant_id, "status": "withdrawn", "effective": "immediate"}
async def update_boundaries(covenant_id: str, owner_scope: str, boundaries: dict) -> dict:
"""边界变更:用户决定立即生效(不升版本,单独事件留痕)。"""
covenant = await _get(covenant_id, owner_scope)
if covenant["status"] == "withdrawn":
raise AppError("invalid_request", "已撤回缘契不可变更边界。", status_code=409)
async with AsyncSessionLocal() as session:
row = await session.get(Covenant, covenant_id)
row.boundaries_json = json.dumps(boundaries, ensure_ascii=False)
row.updated_at = utc_now()
await _event(
session, covenant_id, row.character_id, "boundary", {"boundaries": boundaries}
)
await session.commit()
session.expunge(row)
return _to_dict(row)
async def set_adult_consent(covenant_id: str, owner_scope: str, *, enabled: bool) -> dict:
"""成人内容授契:默认关;按会话临时开启(此处置位,会话期由运行时校验)。"""
await _get(covenant_id, owner_scope)
async with AsyncSessionLocal() as session:
row = await session.get(Covenant, covenant_id)
if enabled and _ROMANTIC_KEYS & set(json.loads(row.dimensions_json or "{}")):
pass # 浪漫维度存在时才可开启;非浪漫缘契开启无意义但无害
row.adult_consent = enabled
row.updated_at = utc_now()
await _event(
session, covenant_id, row.character_id, "boundary",
{"adult_consent": enabled},
)
await session.commit()
session.expunge(row)
return _to_dict(row)
async def get_active(character_id: str, owner_scope: str) -> dict | None:
"""人物当前生效缘契(withdrawn 不可见)。"""
async with AsyncSessionLocal() as session:
row = (
await session.execute(
select(Covenant)
.where(
Covenant.character_id == character_id,
Covenant.owner_scope == owner_scope,
Covenant.status.in_(("pending", "sealed")),
)
.order_by(Covenant.created_at.desc())
)
).scalars().first()
return _to_dict(row) if row else None
async def list_covenants(owner_scope: str) -> list[dict]:
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(Covenant)
.where(Covenant.owner_scope == owner_scope, Covenant.status != "withdrawn")
.order_by(Covenant.updated_at.desc())
)
).scalars().all()
return [_to_dict(r) for r in rows]
async def list_events(covenant_id: str, owner_scope: str) -> list[dict]:
"""事件历史(不可变;撤回后仍可查证)。"""
await _get(covenant_id, owner_scope, allow_withdrawn=True)
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(CovenantEvent)
.where(CovenantEvent.covenant_id == covenant_id)
.order_by(CovenantEvent.created_at.asc())
)
).scalars().all()
return [
{
"id": e.id,
"type": e.type,
"actor": e.actor,
"payload": json.loads(e.payload_json or "{}"),
"created_at": e.created_at.isoformat() if e.created_at else None,
}
for e in rows
]
async def _get(covenant_id: str, owner_scope: str, *, allow_withdrawn: bool = False) -> dict:
async with AsyncSessionLocal() as session:
row = await session.get(Covenant, covenant_id)
if row is None or row.owner_scope != owner_scope:
raise AppError("not_found", "缘契不存在。", status_code=404)
if row.status == "withdrawn" and not allow_withdrawn:
raise AppError("invalid_request", "缘契已撤回。", status_code=410)
session.expunge(row)
return _to_dict(row)
def _to_dict(c: Covenant) -> dict:
return {
"id": c.id,
"character_id": c.character_id,
"dimensions": json.loads(c.dimensions_json or "{}"),
"primary_title": c.primary_title,
"boundaries": json.loads(c.boundaries_json or "{}"),
"adult_consent": c.adult_consent,
"status": c.status,
"version": c.version,
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}