✨ 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,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 ###
|
||||
@@ -0,0 +1 @@
|
||||
"""角色域:创建、生命周期、版本链与内驱力。"""
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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]:
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user