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,10 @@
|
||||
"""缘契域:人物与互动者的关系与同意(rewrite/1.0 M6)。
|
||||
|
||||
- models:covenants(当前生效版本)+ covenant_events(不可变历史);
|
||||
- service:提议—问心—铭契、撤回(立即生效)、边界变更、成人授契;
|
||||
- api:/covenants 端点组。
|
||||
"""
|
||||
|
||||
from app.domains.yuanqi import models, service # noqa: F401
|
||||
|
||||
__all__ = ["models", "service"]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
Reference in new issue
Block a user