diff --git a/backend/alembic/versions/6292e21950cc_角色与版本链.py b/backend/alembic/versions/6292e21950cc_角色与版本链.py new file mode 100644 index 0000000..4ad9915 --- /dev/null +++ b/backend/alembic/versions/6292e21950cc_角色与版本链.py @@ -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 ### diff --git a/backend/app/characters/__init__.py b/backend/app/characters/__init__.py new file mode 100644 index 0000000..e5c3b61 --- /dev/null +++ b/backend/app/characters/__init__.py @@ -0,0 +1 @@ +"""角色域:创建、生命周期、版本链与内驱力。""" diff --git a/backend/app/characters/routes.py b/backend/app/characters/routes.py new file mode 100644 index 0000000..c60cbef --- /dev/null +++ b/backend/app/characters/routes.py @@ -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) diff --git a/backend/app/characters/service.py b/backend/app/characters/service.py new file mode 100644 index 0000000..e6242e1 --- /dev/null +++ b/backend/app/characters/service.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 5ca729f..c1af63b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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]: diff --git a/backend/app/models.py b/backend/app/models.py index cd422ae..eacbb82 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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", + ), + ) diff --git a/backend/tests/test_conversations.py b/backend/tests/test_conversations.py index bbf4d5d..19381de 100644 --- a/backend/tests/test_conversations.py +++ b/backend/tests/test_conversations.py @@ -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)