✨ 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:
1 parent
603a683c14
commit
40221170ae
7 files changed
+859
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user