✨ 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

@@ -0,0 +1,69 @@
"""角色与版本链
Revision ID: 6292e21950cc
Revises: 8f48659a83f6
Create Date: 2026-09-10 18:08:36.628784
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '6292e21950cc'
down_revision: str | Sequence[str] | None = '8f48659a83f6'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('character',
sa.Column('id', sa.String(), nullable=False),
sa.Column('personal_domain_id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('origin', sa.String(), nullable=False),
sa.Column('based_on_real_person', sa.Boolean(), nullable=False),
sa.Column('real_person_attested', sa.Boolean(), nullable=False),
sa.Column('identity_baseline', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('drives', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('current_version_id', sa.String(), nullable=True),
sa.Column('death_requested_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("origin IN ('original', 'serendipity', 'template', 'import', 'fork')", name=op.f('ck_character_origin_values')),
sa.CheckConstraint("status IN ('draft', 'active', 'resting', 'archived', 'death_pending', 'deceased')", name=op.f('ck_character_status_values')),
sa.ForeignKeyConstraint(['current_version_id'], ['character_version.id'], name=op.f('fk_character_character_version'), ondelete='set null', use_alter=True),
sa.ForeignKeyConstraint(['personal_domain_id'], ['personal_domain.id'], name=op.f('fk_character_personal_domain'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_character'))
)
op.create_index(op.f('ix_character_personal_domain_id'), 'character', ['personal_domain_id'], unique=False)
op.create_table('character_version',
sa.Column('id', sa.String(), nullable=False),
sa.Column('character_id', sa.String(), nullable=False),
sa.Column('kind', sa.String(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('payload', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("kind IN ('baseline', 'first_statement', 'drive_revision', 'reforging', 'persona_candidate')", name=op.f('ck_character_version_kind_values')),
sa.CheckConstraint("status IN ('pending', 'approved', 'rejected')", name=op.f('ck_character_version_status_values')),
sa.ForeignKeyConstraint(['character_id'], ['character.id'], name=op.f('fk_character_version_character'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_character_version'))
)
op.create_index(op.f('ix_character_version_character_id'), 'character_version', ['character_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_character_version_character_id'), table_name='character_version')
op.drop_table('character_version')
op.drop_index(op.f('ix_character_personal_domain_id'), table_name='character')
op.drop_table('character')
# ### end Alembic commands ###
+1
View File
@@ -0,0 +1 @@
"""角色域:创建、生命周期、版本链与内驱力。"""
+239
View File
@@ -0,0 +1,239 @@
"""角色域路由(WP-02)。"""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from app.auth.dependencies import get_current_user, require_state_change
from app.characters import service
from app.conversations.routes import _domain_of # 复用域解析
from app.db.session import DbSession
from app.models import Character, CharacterVersion, User
router = APIRouter(prefix="/v1/characters", tags=["characters"])
class DriveIn(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
description: str
intensity: int = Field(default=1, ge=1, le=5)
class DrivesUpdateIn(BaseModel):
model_config = ConfigDict(extra="forbid")
drives: list[DriveIn] = Field(max_length=16)
def _character_out(c: Character) -> dict[str, Any]:
return {
"id": c.id,
"name": c.name,
"status": c.status,
"origin": c.origin,
"based_on_real_person": c.based_on_real_person,
"identity_baseline": c.identity_baseline,
"drives": c.drives,
"current_version_id": c.current_version_id,
"created_at": c.created_at.isoformat(),
}
def _version_out(v: CharacterVersion) -> dict[str, Any]:
return {
"id": v.id,
"character_id": v.character_id,
"kind": v.kind,
"status": v.status,
"payload": v.payload,
"created_at": v.created_at.isoformat(),
}
@router.get("")
async def list_characters(
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
limit: int = 50,
cursor: str | None = None,
) -> dict[str, Any]:
if not 1 <= limit <= 200:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "limit 取值范围 1-200")
domain = await _domain_of(db, user)
stmt = select(Character).where(Character.personal_domain_id == domain.id)
stmt = stmt.order_by(Character.created_at.asc(), Character.id.asc()).limit(limit + 1)
rows = list((await db.scalars(stmt)).all())
has_more = len(rows) > limit
return {
"data": [_character_out(c) for c in rows[:limit]],
"next_cursor": rows[limit].id if has_more else None,
"has_more": has_more,
}
@router.post("", status_code=201)
async def create_character(
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
character = await service.create_character(
db, domain.id, body, getattr(request.state, "trace_id", None)
)
return _character_out(character)
@router.get("/{character_id}")
async def get_character(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
character = await service.require_character(db, domain.id, character_id)
return _character_out(character)
@router.post("/{character_id}/activations", status_code=201)
async def activate(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
character = await service.activate_character(
db, domain.id, character_id, getattr(request.state, "trace_id", None)
)
return _character_out(character)
@router.post("/{character_id}/first-statement", status_code=201)
async def submit_first_statement(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
version = await service.submit_first_statement(
db, domain.id, character_id, body.get("text", ""),
getattr(request.state, "trace_id", None),
)
return _version_out(version)
@router.post("/{character_id}/first-statement/confirmations", status_code=201)
async def confirm_first_statement(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, str],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
version = await service.confirm_first_statement(
db, domain.id, character_id, body.get("version_id", ""),
getattr(request.state, "trace_id", None),
)
return _version_out(version)
LIFECYCLE_ACTIONS = {
"rests": "rest",
"archives": "archive",
"revivals": "revive",
"death-requests": "request_death",
"death-cancellations": "cancel_death",
"death-confirmations": "confirm_death",
}
@router.post("/{character_id}/reforgings", status_code=201)
async def reforge(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
version = await service.reforge_character(
db, domain.id, character_id, body, getattr(request.state, "trace_id", None)
)
return _version_out(version)
@router.patch("/{character_id}/drives")
async def update_drives(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: DrivesUpdateIn,
) -> dict[str, Any]:
domain = await _domain_of(db, user)
await service.update_drives(
db, domain.id, character_id,
[d.model_dump() for d in body.drives], getattr(request.state, "trace_id", None),
)
character = await service.require_character(db, domain.id, character_id)
return _character_out(character)
@router.post("/{character_id}/persona-candidates", status_code=201)
async def submit_persona_candidate(
character_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
version = await service.submit_persona_candidate(
db, domain.id, character_id, body, getattr(request.state, "trace_id", None)
)
return _version_out(version)
@router.post("/{character_id}/versions/{version_id}/decisions", status_code=201)
async def review_version(
character_id: str,
version_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, bool],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
version = await service.review_version(
db, domain.id, character_id, version_id, bool(body.get("approve")),
getattr(request.state, "trace_id", None),
)
return _version_out(version)
@router.post("/{character_id}/{action}", status_code=200)
async def lifecycle(
character_id: str,
action: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
) -> dict[str, Any]:
if action not in LIFECYCLE_ACTIONS:
raise HTTPException(status.HTTP_404_NOT_FOUND, "未知生命周期动作")
domain = await _domain_of(db, user)
character = await service.lifecycle_action(
db, domain.id, character_id, LIFECYCLE_ACTIONS[action],
getattr(request.state, "trace_id", None),
)
return _character_out(character)
+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
+2
View File
@@ -3,6 +3,7 @@
from fastapi import FastAPI
from app.auth.routes import router as auth_router
from app.characters.routes import router as characters_router
from app.conversations.routes import message_router, run_router
from app.conversations.routes import router as conversations_router
from app.core.config import get_settings
@@ -28,6 +29,7 @@ def create_app() -> FastAPI:
app.include_router(conversations_router)
app.include_router(run_router)
app.include_router(message_router)
app.include_router(characters_router)
@app.get("/healthz", tags=["ops"])
async def healthz() -> dict[str, str]:
+74
View File
@@ -300,3 +300,77 @@ class RunEvent(Base):
# Run 内 sequence 单调且唯一(重放去重的服务端保证)
Index("uq_run_event_run_sequence", "run_id", "sequence", unique=True),
)
class CharacterVersion(Base):
"""角色版本链:baseline/初照自陈/内驱力修订/铸魂/人格候选(不可变版本)。"""
__tablename__ = "character_version"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("character_version_")
)
character_id: Mapped[str] = mapped_column(
ForeignKey("character.id", ondelete="cascade"), nullable=False, index=True
)
kind: Mapped[str] = mapped_column(String, nullable=False)
status: Mapped[str] = mapped_column(String, nullable=False, default="approved")
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"kind IN ('baseline', 'first_statement', 'drive_revision', 'reforging', "
"'persona_candidate')",
name="kind_values",
),
CheckConstraint("status IN ('pending', 'approved', 'rejected')", name="status_values"),
)
class Character(Base):
"""角色:稳定身份、生命周期状态、道体基线与内驱力(WP-02)。"""
__tablename__ = "character"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("character_")
)
personal_domain_id: Mapped[str] = mapped_column(
ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String, nullable=False)
# 生命周期:灵胚 → 启镜 → 静修/归藏(可复照);死亡走两步确认,deceased 不可逆
status: Mapped[str] = mapped_column(String, nullable=False, default="draft")
origin: Mapped[str] = mapped_column(String, nullable=False, default="original")
based_on_real_person: Mapped[bool] = mapped_column(nullable=False, default=False)
real_person_attested: Mapped[bool] = mapped_column(nullable=False, default=False)
# 道体基线(identity_baseline):最小稳定身份锚点,普通对话/记忆不可静默覆盖
identity_baseline: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
# 内驱力(character_drives):声明式动机维度,用户可审阅编辑(ADR-018)
drives: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list)
current_version_id: Mapped[str | None] = mapped_column(
ForeignKey("character_version.id", ondelete="set null", use_alter=True), nullable=True
)
death_requested_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
__table_args__ = (
CheckConstraint(
"status IN ('draft', 'active', 'resting', 'archived', 'death_pending', 'deceased')",
name="status_values",
),
CheckConstraint(
"origin IN ('original', 'serendipity', 'template', 'import', 'fork')",
name="origin_values",
),
)
+148
View File
@@ -459,3 +459,151 @@ class TestRevisions:
f"/v1/messages/{mid}/revisions", json={"content": [{"type": "text", "text": "篡改"}]}
)
assert rev.status_code in (401, 403, 404)
@pytest.mark.usefixtures("client")
class TestCharacters:
async def _create(self, client, headers, body) -> dict:
resp = await client.post("/v1/characters", json=body, headers=headers)
assert resp.status_code == 201, resp.text
return resp.json()
async def test_manual_create_activate_and_first_statement(self, client: AsyncClient) -> None:
headers = await login(client, "char1@example.com")
c = await self._create(
client, headers, {"name": "昭星", "identity_baseline": {"core_traits": ["沉静"]}}
)
assert c["status"] == "draft" and c["origin"] == "original"
act = await client.post(f"/v1/characters/{c['id']}/activations", headers=headers)
assert act.status_code == 201 and act.json()["status"] == "active"
assert act.json()["current_version_id"] is not None
fs = await client.post(
f"/v1/characters/{c['id']}/first-statement",
json={"text": "初次见面,我是昭星。"},
headers=headers,
)
assert fs.status_code == 201 and fs.json()["status"] == "pending"
confirm = await client.post(
f"/v1/characters/{c['id']}/first-statement/confirmations",
json={"version_id": fs.json()["id"]},
headers=headers,
)
assert confirm.status_code == 201 and confirm.json()["status"] == "approved"
detail = await client.get(f"/v1/characters/{c['id']}", headers=headers)
assert detail.json()["identity_baseline"]["first_statement"] == "初次见面,我是昭星。"
async def test_serendipity_is_deterministic_single(self, client: AsyncClient) -> None:
"""随缘:同种子同候选(可复现),一次一个,不是抽卡。"""
headers = await login(client, "char2@example.com")
a = await self._create(client, headers, {"origin": "serendipity", "seed": 3})
b = await self._create(client, headers, {"origin": "serendipity", "seed": 3})
assert a["name"] == b["name"] and a["origin"] == "serendipity"
assert a["drives"] and a["drives"][0]["name"]
async def test_real_person_governance(self, client: AsyncClient) -> None:
headers = await login(client, "char3@example.com")
# 已故现实人物:拒绝
dead = await client.post(
"/v1/characters",
json={"name": "某人", "based_on_real_person": True, "deceased_real_person": True,
"real_person_attested": True},
headers=headers,
)
assert dead.status_code == 422
# 未声明授权:拒绝
no_attest = await client.post(
"/v1/characters", json={"name": "某人", "based_on_real_person": True}, headers=headers
)
assert no_attest.status_code == 422
# 本人声明:允许
ok = await self._create(
client, headers,
{"name": "我自己", "based_on_real_person": True, "real_person_attested": True},
)
assert ok["based_on_real_person"] is True
async def test_lifecycle_full_path(self, client: AsyncClient) -> None:
headers = await login(client, "char4@example.com")
c = await self._create(client, headers, {"name": "汀棠"})
await client.post(f"/v1/characters/{c['id']}/activations", headers=headers)
# 静修 → 归藏 → 复照
rest = await client.post(f"/v1/characters/{c['id']}/rests", headers=headers)
assert rest.json()["status"] == "resting"
arch = await client.post(f"/v1/characters/{c['id']}/archives", headers=headers)
assert arch.json()["status"] == "archived"
revive = await client.post(f"/v1/characters/{c['id']}/revivals", headers=headers)
assert revive.json()["status"] == "active"
# 死亡:请求 → 撤销(复照)→ 再请求 → 未满冷静期确认被拒
req = await client.post(f"/v1/characters/{c['id']}/death-requests", headers=headers)
assert req.json()["status"] == "death_pending"
cancel = await client.post(f"/v1/characters/{c['id']}/death-cancellations", headers=headers)
assert cancel.json()["status"] == "active"
await client.post(f"/v1/characters/{c['id']}/death-requests", headers=headers)
early = await client.post(f"/v1/characters/{c['id']}/death-confirmations", headers=headers)
assert early.status_code == 422 # 冷静期未满
async def test_invalid_transition_rejected(self, client: AsyncClient) -> None:
headers = await login(client, "char5@example.com")
c = await self._create(client, headers, {"name": "砚谷"})
# 灵胚不能直接归藏
resp = await client.post(f"/v1/characters/{c['id']}/archives", headers=headers)
assert resp.status_code == 422
async def test_reforging_requires_confirm(self, client: AsyncClient) -> None:
headers = await login(client, "char6@example.com")
c = await self._create(client, headers, {"name": "朔川"})
await client.post(f"/v1/characters/{c['id']}/activations", headers=headers)
no = await client.post(
f"/v1/characters/{c['id']}/reforgings",
json={"identity_baseline": {"core_traits": ["焕新"]}},
headers=headers,
)
assert no.status_code == 422
yes = await client.post(
f"/v1/characters/{c['id']}/reforgings",
json={"confirm": True, "identity_baseline": {"core_traits": ["焕新"]}},
headers=headers,
)
assert yes.status_code == 201 and yes.json()["kind"] == "reforging"
detail = await client.get(f"/v1/characters/{c['id']}", headers=headers)
assert detail.json()["identity_baseline"]["core_traits"] == ["焕新"]
async def test_drives_update_and_persona_review(self, client: AsyncClient) -> None:
headers = await login(client, "char7@example.com")
c = await self._create(client, headers, {"name": "霁灯"})
await client.post(f"/v1/characters/{c['id']}/activations", headers=headers)
upd = await client.patch(
f"/v1/characters/{c['id']}/drives",
json={"drives": [{"name": "求知", "description": "想弄明白一切", "intensity": 3}]},
headers=headers,
)
assert upd.status_code == 200 and upd.json()["drives"][0]["name"] == "求知"
# 人格候选:pending → 批准后并入道体
cand = await client.post(
f"/v1/characters/{c['id']}/persona-candidates",
json={"identity_baseline": {"core_traits": ["温柔"]}},
headers=headers,
)
assert cand.status_code == 201 and cand.json()["status"] == "pending"
approve = await client.post(
f"/v1/characters/{c['id']}/versions/{cand.json()['id']}/decisions",
json={"approve": True},
headers=headers,
)
assert approve.json()["status"] == "approved"
detail = await client.get(f"/v1/characters/{c['id']}", headers=headers)
assert detail.json()["identity_baseline"]["core_traits"] == ["温柔"]
# 已审核版本重复审核:409
again = await client.post(
f"/v1/characters/{c['id']}/versions/{cand.json()['id']}/decisions",
json={"approve": False},
headers=headers,
)
assert again.status_code == 409
async def test_cross_domain_character_404(self, client: AsyncClient) -> None:
alice = await login(client, "char8@example.com")
c = await self._create(client, alice, {"name": "澜川"})
await login(client, "char8-evil@example.com")
resp = await client.get(f"/v1/characters/{c['id']}")
assert resp.status_code in (401, 403, 404)