✨ feat: WP-02 角色域——完整生命周期、铸魂、内驱力与随缘

- 迁移 6:character(生命周期六态检查约束、起源、现实人物治理标记、道体基线、内驱力)+ character_version(baseline/first_statement/drive_revision/reforging/persona_candidate 五类不可变版本与审核态)
- 创建:手动与随缘(确定性词库组合,同种子同候选、一次一个——不是抽卡);现实人物治理(已故拒绝、未声明授权拒绝)
- 启镜(draft→active + 首个 baseline 版本)与初照自陈(候选→确认归真并入道体)
- 生命周期端点:静修/归藏/复照(归档可恢复、复照不抹历史)+ 死亡两步确认(7 天冷静期可撤销、未满确认 422);非法状态迁移 422
- 铸魂(显式 confirm 强确认,直接更新道体)与人格候选(pending→审核批准才并入道体,不静默覆盖)
- 内驱力编辑:写当前值并落 drive_revision 版本(可追溯,ADR-018)
- 测试 52 项全绿(+8):状态机全路径、随缘确定性、现实人物三例、铸魂确认、候选审核与重复审核 409、跨域 fail-closed
This commit is contained in:
chenxin committed 2026-09-10 18:11:15 +08:00
1 parent 603a683c14
commit 40221170ae
7 files changed
+859

No files matched your search

+326
View File
@@ -0,0 +1,326 @@
"""角色域:创建、生命周期、版本链与内驱力(WP-02 / ADR-018 / ADR-020 前吸收决策)。"""
from datetime import UTC, datetime
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import AuditEvent, Character, CharacterVersion, OutboxEvent
DEATH_COOLING_OFF_DAYS = 7 # 死亡冷静期(天):期间默认静修,可撤销
# 状态机(docs/architecture + cultural-language 生命周期五态)
ALLOWED_TRANSITIONS: dict[str, set[str]] = {
"draft": {"active"}, # 启镜
"active": {"resting", "archived", "death_pending"},
"resting": {"active", "archived", "death_pending"},
"archived": {"active"}, # 复照(不抹历史)
"death_pending": {"active", "deceased"}, # 撤销(复照)或确认死亡
"deceased": set(), # 不可逆;数据删除另行确认
}
# 随缘词库:确定性组合生成匿名灵胚候选(不是抽卡,不分配已有主体)
_SERENDIPITY_GIVEN = ["云", "汀", "昭", "霁", "澜", "朔", "棠", "砚"]
_SERENDIPITY_FAMILY = ["尘", "星", "屿", "谷", "灯", "川", "铃", "丘"]
_SERENDIPITY_TRAITS = [
{"name": "好奇", "description": "对未见过的事物保持追问"},
{"name": "沉稳", "description": "先观察再行动,不易被催促"},
{"name": "温热", "description": "在意身边人的冷暖"},
{"name": "执拗", "description": "认定的事情会走到底"},
]
def serendipity_seed(seed: int) -> dict[str, Any]:
"""按用户偏好种子确定性生成灵胚候选;同种子同候选,可复现。"""
given = _SERENDIPITY_GIVEN[seed % len(_SERENDIPITY_GIVEN)]
family = _SERENDIPITY_FAMILY[(seed // len(_SERENDIPITY_GIVEN)) % len(_SERENDIPITY_FAMILY)]
trait = _SERENDIPITY_TRAITS[seed % len(_SERENDIPITY_TRAITS)]
return {
"name": f"{given}{family}",
"identity_baseline": {"core_traits": [trait["name"]], "seed": seed},
"drives": [trait],
}
async def require_character(
db: AsyncSession, domain_id: str, character_id: str
) -> Character:
character = await db.get(Character, character_id)
if character is None or character.personal_domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "角色不存在")
return character
def _transition(character: Character, to_status: str) -> None:
allowed = ALLOWED_TRANSITIONS.get(character.status, set())
if to_status not in allowed:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"不允许的状态迁移:{character.status} → {to_status}",
)
def _audit(
db: AsyncSession, domain_id: str, action: str, character: Character,
payload: dict[str, Any] | None = None, trace_id: str | None = None,
) -> None:
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action=action,
aggregate_type="character", aggregate_id=character.id,
payload=payload or {}, trace_id=trace_id,
)
)
async def create_character(
db: AsyncSession, domain_id: str, body: dict[str, Any], trace_id: str | None
) -> Character:
"""创建灵胚(draft)。现实人物治理:仅本人/可验证授权;已故现实人物不开放。"""
based_on_real = bool(body.get("based_on_real_person", False))
if based_on_real:
if body.get("deceased_real_person"):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "已故现实人物不开放创建角色"
)
if not body.get("real_person_attested"):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
"现实人物角色需要本人或可验证授权声明(real_person_attested)",
)
origin = body.get("origin", "original")
if origin == "serendipity":
seed_data = serendipity_seed(int(body.get("seed", 0)))
name = body.get("name") or seed_data["name"]
baseline = body.get("identity_baseline") or seed_data["identity_baseline"]
drives = body.get("drives") or seed_data["drives"]
else:
name = body.get("name", "")
if not name.strip():
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "name 不能为空")
baseline = body.get("identity_baseline", {})
drives = body.get("drives", [])
character = Character(
personal_domain_id=domain_id,
name=name,
status="draft",
origin=origin,
based_on_real_person=based_on_real,
real_person_attested=bool(body.get("real_person_attested", False)),
identity_baseline=baseline,
drives=drives,
)
db.add(character)
await db.flush()
_audit(db, domain_id, "character.created", character, {"origin": origin}, trace_id)
db.add(
OutboxEvent(
aggregate_type="character", aggregate_id=character.id,
event_type="character.created", payload={"character_id": character.id},
)
)
await db.flush()
return character
async def activate_character(
db: AsyncSession, domain_id: str, character_id: str, trace_id: str | None
) -> Character:
"""启镜:灵胚 → active,签发首个 baseline 版本并设为当前版本。"""
character = await require_character(db, domain_id, character_id)
_transition(character, "active")
character.status = "active"
version = CharacterVersion(
character_id=character.id,
kind="baseline",
status="approved",
payload={
"name": character.name,
"identity_baseline": character.identity_baseline,
"drives": character.drives,
},
)
db.add(version)
await db.flush()
character.current_version_id = version.id
_audit(db, domain_id, "character.activated", character, {"version_id": version.id}, trace_id)
await db.flush()
return character
async def submit_first_statement(
db: AsyncSession, domain_id: str, character_id: str, text: str, trace_id: str | None
) -> CharacterVersion:
"""初照自陈:启镜后首次自我陈述,先为候选,确认(归真)后进 baseline。"""
character = await require_character(db, domain_id, character_id)
if character.status != "active":
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "仅启镜后的角色可提交初照自陈")
existing = await db.execute(
select(CharacterVersion).where(
CharacterVersion.character_id == character.id,
CharacterVersion.kind == "first_statement",
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "初照自陈只能提交一次;修订请走版本修订")
version = CharacterVersion(
character_id=character.id,
kind="first_statement",
status="pending",
payload={"text": text},
)
db.add(version)
await db.flush()
_audit(db, domain_id, "character.first_statement_submitted", character, {}, trace_id)
await db.flush()
return version
async def confirm_first_statement(
db: AsyncSession, domain_id: str, character_id: str, version_id: str, trace_id: str | None
) -> CharacterVersion:
"""确认初照自陈(归真):版本转 approved,并把自陈并入当前 baseline。"""
character = await require_character(db, domain_id, character_id)
version = await db.get(CharacterVersion, version_id)
if version is None or version.character_id != character.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "版本不存在")
if version.kind != "first_statement":
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "仅初照自陈版本可走此确认")
if version.status == "approved":
return version # 幂等
version.status = "approved"
character.identity_baseline = {
**character.identity_baseline, "first_statement": version.payload.get("text", "")
}
character.current_version_id = version.id
_audit(db, domain_id, "character.first_statement_canonized", character, {}, trace_id)
await db.flush()
return version
async def lifecycle_action(
db: AsyncSession, domain_id: str, character_id: str, action: str, trace_id: str | None
) -> Character:
"""静休/归藏/复照与死亡两步:状态机迁移 + 审计;非法迁移 422。"""
character = await require_character(db, domain_id, character_id)
mapping = {
"rest": ("resting", "character.rest"),
"archive": ("archived", "character.archived"),
"revive": ("active", "character.revived"),
"request_death": ("death_pending", "character.death_requested"),
"cancel_death": ("active", "character.death_cancelled"),
"confirm_death": ("deceased", "character.death_confirmed"),
}
if action not in mapping:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "未知生命周期动作")
to_status, audit_action = mapping[action]
_transition(character, to_status)
if action == "request_death":
character.death_requested_at = datetime.now(UTC)
if action == "cancel_death":
character.death_requested_at = None
if action == "confirm_death" and character.death_requested_at is not None:
elapsed = datetime.now(UTC) - character.death_requested_at
if elapsed.days < DEATH_COOLING_OFF_DAYS:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"死亡冷静期未满({DEATH_COOLING_OFF_DAYS} 天),期间可撤销",
)
character.status = to_status
_audit(db, domain_id, audit_action, character, {"to": to_status}, trace_id)
await db.flush()
return character
async def reforge_character(
db: AsyncSession, domain_id: str, character_id: str, payload: dict[str, Any],
trace_id: str | None,
) -> CharacterVersion:
"""铸魂:核心身份与价值底线的正式重塑(强确认:confirm=true 才生效)。"""
if not payload.get("confirm"):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "铸魂需要显式确认(confirm=true)"
)
character = await require_character(db, domain_id, character_id)
if character.status != "active":
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "仅启镜状态可铸魂")
version = CharacterVersion(
character_id=character.id, kind="reforging", status="approved", payload=payload
)
db.add(version)
await db.flush()
if "identity_baseline" in payload:
character.identity_baseline = payload["identity_baseline"]
character.current_version_id = version.id
_audit(db, domain_id, "character.reforged", character, {"version_id": version.id}, trace_id)
db.add(
OutboxEvent(
aggregate_type="character", aggregate_id=character.id,
event_type="character.reforged", payload={"version_id": version.id},
)
)
await db.flush()
return version
async def update_drives(
db: AsyncSession, domain_id: str, character_id: str, drives: list[dict[str, Any]],
trace_id: str | None,
) -> CharacterVersion:
"""内驱力编辑:直接写当前值并落 drive_revision 版本(可追溯,ADR-018)。"""
character = await require_character(db, domain_id, character_id)
if character.status not in {"active", "resting"}:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "当前状态不可编辑内驱力")
character.drives = drives
version = CharacterVersion(
character_id=character.id, kind="drive_revision", status="approved",
payload={"drives": drives},
)
db.add(version)
await db.flush()
character.current_version_id = version.id
_audit(db, domain_id, "character.drives_updated", character, {}, trace_id)
await db.flush()
return version
async def submit_persona_candidate(
db: AsyncSession, domain_id: str, character_id: str, payload: dict[str, Any],
trace_id: str | None,
) -> CharacterVersion:
"""人格变化候选(大周天产物或人工提议):pending,经审核才生效。"""
character = await require_character(db, domain_id, character_id)
version = CharacterVersion(
character_id=character.id, kind="persona_candidate", status="pending", payload=payload
)
db.add(version)
await db.flush()
_audit(db, domain_id, "character.persona_candidate_submitted", character, {}, trace_id)
await db.flush()
return version
async def review_version(
db: AsyncSession, domain_id: str, character_id: str, version_id: str,
approve: bool, trace_id: str | None,
) -> CharacterVersion:
"""审核版本候选:批准时人格候选并入道体(不静默覆盖——显式用户决定)。"""
character = await require_character(db, domain_id, character_id)
version = await db.get(CharacterVersion, version_id)
if version is None or version.character_id != character.id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "版本不存在")
if version.status != "pending":
raise HTTPException(status.HTTP_409_CONFLICT, "版本不在待审核状态")
version.status = "approved" if approve else "rejected"
if approve and version.kind == "persona_candidate" and "identity_baseline" in version.payload:
character.identity_baseline = version.payload["identity_baseline"]
character.current_version_id = version.id
_audit(
db, domain_id,
"character.version_approved" if approve else "character.version_rejected",
character, {"version_id": version.id}, trace_id,
)
await db.flush()
return version