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

+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,
}