diff --git a/backend/alembic/versions/001_镜生域_灵胚_执镜关系_命台_魂籍表与人物增量列.py b/backend/alembic/versions/001_镜生域_灵胚_执镜关系_命台_魂籍表与人物增量列.py new file mode 100644 index 0000000..11018fb --- /dev/null +++ b/backend/alembic/versions/001_镜生域_灵胚_执镜关系_命台_魂籍表与人物增量列.py @@ -0,0 +1,108 @@ +"""镜生域:灵胚、执镜关系、命台、魂籍表与人物增量列 + +Revision ID: 001 +Revises: 000 +Create Date: 2026-08-21 22:23:35.864890 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '001' +down_revision: Union[str, Sequence[str], None] = '000' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('embryo_drafts', + sa.Column('id', sa.String(length=80), nullable=False), + sa.Column('owner_scope', sa.String(length=80), nullable=False), + sa.Column('status', sa.String(length=40), nullable=False), + sa.Column('source', sa.String(length=40), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('identity_baseline_json', sa.Text(), nullable=False), + sa.Column('rehearsals_json', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_embryo_drafts_owner_scope'), 'embryo_drafts', ['owner_scope'], unique=False) + op.create_index(op.f('ix_embryo_drafts_status'), 'embryo_drafts', ['status'], unique=False) + op.create_table('custodianships', + sa.Column('id', sa.String(length=80), nullable=False), + sa.Column('character_id', sa.String(length=80), nullable=False), + sa.Column('custodian_scope', sa.String(length=80), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_custodianships_character_id'), 'custodianships', ['character_id'], unique=True) + op.create_index(op.f('ix_custodianships_custodian_scope'), 'custodianships', ['custodian_scope'], unique=False) + op.create_table('identity_records', + sa.Column('id', sa.String(length=80), nullable=False), + sa.Column('character_id', sa.String(length=80), nullable=False), + sa.Column('version', sa.Integer(), nullable=False), + sa.Column('payload_json', sa.Text(), nullable=False), + sa.Column('digest', sa.String(length=64), nullable=False), + sa.Column('signature', sa.String(length=128), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('character_id', 'version', name='uq_identity_char_version') + ) + op.create_index(op.f('ix_identity_records_character_id'), 'identity_records', ['character_id'], unique=False) + op.create_table('runtime_seats', + sa.Column('id', sa.String(length=80), nullable=False), + sa.Column('character_id', sa.String(length=80), nullable=False), + sa.Column('epoch', sa.Integer(), nullable=False), + sa.Column('holder', sa.String(length=120), nullable=True), + sa.Column('lease_expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_runtime_seats_character_id'), 'runtime_seats', ['character_id'], unique=True) + op.add_column('characters', sa.Column('source', sa.String(length=40), server_default='original', nullable=False)) + op.add_column('characters', sa.Column('identity_baseline_json', sa.Text(), server_default='{}', nullable=False)) + op.add_column('characters', sa.Column('death_requested_at', sa.DateTime(timezone=True), nullable=True)) + op.add_column('characters', sa.Column('calm_period_until', sa.DateTime(timezone=True), nullable=True)) + op.add_column('characters', sa.Column('died_at', sa.DateTime(timezone=True), nullable=True)) + op.create_index(op.f('ix_characters_source'), 'characters', ['source'], unique=False) + op.add_column('runs', sa.Column('kind', sa.String(length=40), server_default='foreground', nullable=False)) + # SQLite 不支持 ALTER COLUMN,用 batch 模式跨方言改 runs.character_id 可空(试演 Run 无人物) + with op.batch_alter_table('runs') as batch_op: + batch_op.alter_column('character_id', existing_type=sa.String(length=80), nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('runs') as batch_op: + batch_op.alter_column('character_id', existing_type=sa.String(length=80), nullable=False) + op.drop_column('runs', 'kind') + op.drop_index(op.f('ix_characters_source'), table_name='characters') + op.drop_column('characters', 'died_at') + op.drop_column('characters', 'calm_period_until') + op.drop_column('characters', 'death_requested_at') + op.drop_column('characters', 'identity_baseline_json') + op.drop_column('characters', 'source') + op.drop_index(op.f('ix_runtime_seats_character_id'), table_name='runtime_seats') + op.drop_table('runtime_seats') + op.drop_index(op.f('ix_identity_records_character_id'), table_name='identity_records') + op.drop_table('identity_records') + op.drop_index(op.f('ix_custodianships_custodian_scope'), table_name='custodianships') + op.drop_index(op.f('ix_custodianships_character_id'), table_name='custodianships') + op.drop_table('custodianships') + op.drop_index(op.f('ix_embryo_drafts_status'), table_name='embryo_drafts') + op.drop_index(op.f('ix_embryo_drafts_owner_scope'), table_name='embryo_drafts') + op.drop_table('embryo_drafts') + # ### end Alembic commands ### diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 2ea0e9f..3a2db15 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -17,6 +17,8 @@ from app.api.v1.endpoints import ( goals, health, imports, + embryo_drafts, + jingsheng_characters, kernel_runs, lorebooks, mcp, @@ -40,6 +42,8 @@ api_router.include_router(character_import.router) api_router.include_router(chat.router) api_router.include_router(runs.router) api_router.include_router(kernel_runs.router) +api_router.include_router(embryo_drafts.router) +api_router.include_router(jingsheng_characters.router) api_router.include_router(memory.router) api_router.include_router(lorebooks.router) api_router.include_router(personality.router) diff --git a/backend/app/api/v1/endpoints/embryo_drafts.py b/backend/app/api/v1/endpoints/embryo_drafts.py new file mode 100644 index 0000000..5f0ffb6 --- /dev/null +++ b/backend/app/api/v1/endpoints/embryo_drafts.py @@ -0,0 +1,141 @@ +"""灵胚 API(镜生域,rewrite/1.0 M1)。对应 api-contracts.md §3.2。""" + +from __future__ import annotations + +import json + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, Field + +from app.api.v1.deps import get_current_user +from app.domains import jingsheng +from app.kernel import Kernel +from app.schemas.auth import CurrentUser + +router = APIRouter(prefix="/embryo-drafts", tags=["jingsheng"]) + + +class EmbryoCreate(BaseModel): + source: str = Field(default="original", description="original/adopted/imported/serendipity") + name: str = Field(min_length=1, max_length=120) + description: str | None = Field(default=None, max_length=8000) + identity_baseline: dict = Field(default_factory=dict, description="道体锚点草稿") + + +class EmbryoPatch(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=120) + description: str | None = Field(default=None, max_length=8000) + identity_baseline: dict | None = None + + +class RehearseRequest(BaseModel): + prompt: str = Field(min_length=1, max_length=8000) + + +def _embryo_view(embryo) -> dict: # noqa: ANN001 — ORM 行 + return { + "id": embryo.id, + "status": embryo.status, + "source": embryo.source, + "name": embryo.name, + "description": embryo.description, + "identity_baseline": json.loads(embryo.identity_baseline_json or "{}"), + "rehearsals": json.loads(embryo.rehearsals_json or "[]"), + "created_at": embryo.created_at.isoformat(), + "updated_at": embryo.updated_at.isoformat(), + } + + +@router.get("") +async def list_embryo_drafts(user: CurrentUser = Depends(get_current_user)) -> list[dict]: + return [_embryo_view(e) for e in await jingsheng.list_embryos(user.owner_scope)] + + +@router.post("", status_code=201) +async def create_embryo_draft( + payload: EmbryoCreate, user: CurrentUser = Depends(get_current_user) +) -> dict: + embryo = await jingsheng.create_embryo( + owner_scope=user.owner_scope, + source=payload.source, + name=payload.name, + description=payload.description, + identity_baseline=payload.identity_baseline, + ) + return _embryo_view(embryo) + + +@router.get("/{embryo_id}") +async def get_embryo_draft( + embryo_id: str, user: CurrentUser = Depends(get_current_user) +) -> dict: + return _embryo_view(await jingsheng.get_embryo(embryo_id, user.owner_scope)) + + +@router.patch("/{embryo_id}") +async def patch_embryo_draft( + embryo_id: str, payload: EmbryoPatch, user: CurrentUser = Depends(get_current_user) +) -> dict: + embryo = await jingsheng.patch_embryo( + embryo_id, + user.owner_scope, + name=payload.name, + description=payload.description, + identity_baseline=payload.identity_baseline, + ) + return _embryo_view(embryo) + + +@router.post("/{embryo_id}/rehearse") +async def rehearse_embryo( + embryo_id: str, + payload: RehearseRequest, + request: Request, + user: CurrentUser = Depends(get_current_user), +) -> dict: + """照心试演:幻境 Run,不产生人物经历;事件流经 /runs/{run_id}/stream。""" + embryo = await jingsheng.get_embryo(embryo_id, user.owner_scope) + if embryo.status == "activated": + from app.core.errors import AppError + + raise AppError("HJ-1103", "灵胚已启镜,无需再试演。", status_code=409) + kernel: Kernel | None = getattr(request.app.state, "kernel", None) + if kernel is None: + from app.core.errors import AppError + + raise AppError("service_unavailable", "Kernel 运行时未就绪。", status_code=503) + + baseline = json.loads(embryo.identity_baseline_json or "{}") + system_contract = ( + f"你是一位尚未降生的灵胚「{embryo.name}」,正在幻境中试演。" + f"身份锚点:{json.dumps(baseline, ensure_ascii=False)}" + ) + run = await kernel.start_rehearsal_run( + embryo_id=embryo_id, prompt=payload.prompt, system_contract=system_contract + ) + await jingsheng.record_rehearsal( + embryo_id, user.owner_scope, run_id=run.id, prompt=payload.prompt + ) + return {"run_id": run.id, "kind": "rehearsal"} + + +@router.post("/{embryo_id}/activate") +async def activate_embryo_draft( + embryo_id: str, user: CurrentUser = Depends(get_current_user) +) -> dict: + """启镜:签发正式人物 ID、魂籍、执镜关系与命台。""" + character = await jingsheng.activate_embryo(embryo_id, user.owner_scope) + return { + "character_id": character.id, + "name": character.name, + "status": character.status, + "source": character.source, + "identity_record_version": 1, + } + + +@router.post("/{embryo_id}/abandon", status_code=204) +async def abandon_embryo_draft( + embryo_id: str, user: CurrentUser = Depends(get_current_user) +) -> None: + await jingsheng.abandon_embryo(embryo_id, user.owner_scope) diff --git a/backend/app/api/v1/endpoints/jingsheng_characters.py b/backend/app/api/v1/endpoints/jingsheng_characters.py new file mode 100644 index 0000000..5d5ff9e --- /dev/null +++ b/backend/app/api/v1/endpoints/jingsheng_characters.py @@ -0,0 +1,220 @@ +"""镜生域人物端点:魂籍导出与校验、死亡请求/撤销/落定、命台租约(api-contracts.md §3.2)。""" + +from __future__ import annotations + +import json + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field +from sqlalchemy import select + +from app.api.v1.deps import get_current_user +from app.core.config import settings +from app.core.errors import AppError +from app.db.session import AsyncSessionLocal +from app.domains import jingsheng +from app.domains.jingsheng.identity import IdentityVerificationError, verify_identity_record +from app.domains.jingsheng.seat import SeatConflict, acquire_lease, emit_seat_event, release_lease +from app.models.character import Character +from app.models.jingsheng import IdentityRecord +from app.schemas.auth import CurrentUser + +router = APIRouter(prefix="/characters", tags=["jingsheng"]) + + +class DeathFinalizeRequest(BaseModel): + confirm_phrase: str = Field(description="须填入人物名以强确认") + + +class SeatLeaseRequest(BaseModel): + holder: str = Field(min_length=1, max_length=120, description="租约持有者标识(设备/实例)") + + +async def _owned_character(character_id: str, owner_scope: str) -> Character: + async with AsyncSessionLocal() as session: + character = await session.get(Character, character_id) + if character is None or character.owner_scope != owner_scope: + raise AppError("not_found", "人物不存在。") + return character + + +# ── 魂籍 ────────────────────────────────────────────── + + +@router.get("/{character_id}/identity-records") +async def list_identity_records( + character_id: str, user: CurrentUser = Depends(get_current_user) +) -> list[dict]: + await _owned_character(character_id, user.owner_scope) + async with AsyncSessionLocal() as session: + result = await session.execute( + select(IdentityRecord) + .where(IdentityRecord.character_id == character_id) + .order_by(IdentityRecord.version.desc()) + ) + records = result.scalars().all() + return [ + { + "id": r.id, + "version": r.version, + "digest": r.digest, + "created_at": r.created_at.isoformat(), + } + for r in records + ] + + +@router.get("/{character_id}/identity-records/{record_id}") +async def get_identity_record( + character_id: str, record_id: str, user: CurrentUser = Depends(get_current_user) +) -> dict: + await _owned_character(character_id, user.owner_scope) + async with AsyncSessionLocal() as session: + record = await session.get(IdentityRecord, record_id) + if record is None or record.character_id != character_id: + raise AppError("not_found", "魂籍记录不存在。") + return { + "id": record.id, + "version": record.version, + "digest": record.digest, + "payload": json.loads(record.payload_json), + "created_at": record.created_at.isoformat(), + } + + +@router.post("/{character_id}/identity-records") +async def export_identity_record( + character_id: str, user: CurrentUser = Depends(get_current_user) +) -> dict: + """导出新版本魂籍:身份/数据/运行状态三段分离。""" + character = await _owned_character(character_id, user.owner_scope) + async with AsyncSessionLocal() as session: + latest = await session.scalar( + select(IdentityRecord.version) + .where(IdentityRecord.character_id == character_id) + .order_by(IdentityRecord.version.desc()) + .limit(1) + ) + next_version = (latest or 0) + 1 + record = jingsheng.build_identity_record( + character_id=character_id, + version=next_version, + identity={ + "name": character.name, + "source": character.source, + "identity_baseline": json.loads(character.identity_baseline_json or "{}"), + "activated_at": character.activated_at.isoformat() if character.activated_at else None, + }, + runtime_state={"status": character.status}, + ) + session.add(record) + await session.commit() + await session.refresh(record) + return { + "id": record.id, + "version": record.version, + "digest": record.digest, + "created_at": record.created_at.isoformat(), + } + + +@router.post("/{character_id}/identity-records/{record_id}/verify") +async def verify_record( + character_id: str, record_id: str, user: CurrentUser = Depends(get_current_user) +) -> dict: + await _owned_character(character_id, user.owner_scope) + async with AsyncSessionLocal() as session: + record = await session.get(IdentityRecord, record_id) + if record is None or record.character_id != character_id: + raise AppError("not_found", "魂籍记录不存在。") + try: + verify_identity_record(record) + except IdentityVerificationError as exc: + raise AppError("HJ-9000", f"魂籍校验失败:{exc.reason}", status_code=422) from exc + return {"valid": True, "version": record.version, "digest": record.digest} + + +# ── 死亡与冷静期 ────────────────────────────────────────────── + + +@router.post("/{character_id}/death-request") +async def request_death( + character_id: str, user: CurrentUser = Depends(get_current_user) +) -> dict: + character = await jingsheng.death_request( + character_id, user.owner_scope, calm_period_hours=settings.death_calm_period_hours + ) + return { + "character_id": character.id, + "status": character.status, + "calm_period_until": character.calm_period_until.isoformat(), + } + + +@router.post("/{character_id}/death-cancel") +async def cancel_death(character_id: str, user: CurrentUser = Depends(get_current_user)) -> dict: + character = await jingsheng.death_cancel(character_id, user.owner_scope) + return {"character_id": character.id, "status": character.status} + + +@router.post("/{character_id}/death-finalize") +async def finalize_death( + character_id: str, + payload: DeathFinalizeRequest, + user: CurrentUser = Depends(get_current_user), +) -> dict: + character = await _owned_character(character_id, user.owner_scope) + if payload.confirm_phrase != character.name: + raise AppError("invalid_request", "确认短语与人物名不一致,拒绝落定。") + deceased = await jingsheng.death_finalize(character_id, user.owner_scope) + return { + "character_id": deceased.id, + "status": deceased.status, + "died_at": deceased.died_at.isoformat() if deceased.died_at else None, + } + + +# ── 命台租约 ────────────────────────────────────────────── + + +@router.post("/{character_id}/seat/acquire") +async def acquire_seat( + character_id: str, payload: SeatLeaseRequest, user: CurrentUser = Depends(get_current_user) +) -> dict: + await _owned_character(character_id, user.owner_scope) + async with AsyncSessionLocal() as session: + try: + seat = await acquire_lease( + session, + character_id, + payload.holder, + ttl_seconds=settings.seat_lease_ttl_seconds, + ) + except SeatConflict as exc: + current_holder = exc.seat.holder + await session.rollback() + # 留痕在事务回滚后发射(避免 SQLite 跨连接写锁冲突) + await emit_seat_event( + "seat.contested", + character_id, + current_holder, + None, + requester=payload.holder, + ) + raise AppError("HJ-1101", f"命台被占用:{exc}", status_code=429) from exc + epoch, holder = seat.epoch, seat.holder + await session.commit() + await emit_seat_event("seat.acquired", character_id, holder, epoch) + return {"epoch": epoch, "holder": holder} + + +@router.post("/{character_id}/seat/release", status_code=204) +async def release_seat( + character_id: str, payload: SeatLeaseRequest, user: CurrentUser = Depends(get_current_user) +) -> None: + await _owned_character(character_id, user.owner_scope) + async with AsyncSessionLocal() as session: + released = await release_lease(session, character_id, payload.holder) + await session.commit() + if released: + await emit_seat_event("seat.released", character_id, payload.holder, None) diff --git a/backend/app/core/config/runtime_config.py b/backend/app/core/config/runtime_config.py index d170739..70e250d 100644 --- a/backend/app/core/config/runtime_config.py +++ b/backend/app/core/config/runtime_config.py @@ -19,6 +19,11 @@ class RuntimeConfig(BaseSettings): shenshi_base_url: str = "" # 神识服务地址;为空时 Kernel 使用进程内回声桩(M0 骨架) shenshi_service_token: str = "" # 灵台→神识服务令牌(开发档可空) shenshi_timeout_seconds: float = 120.0 # 神识流式调用超时(秒) + + # ==================== 镜生域(rewrite/1.0 M1) ==================== + identity_record_secret: str = "dev-identity-secret" # 魂籍签名密钥;正式部署必须改掉默认值 + death_calm_period_hours: int = 72 # 死亡冷静期时长(小时),期内可撤销 + seat_lease_ttl_seconds: int = 60 # 命台租约 TTL(秒) # Agent 循环最大轮数:模型每轮仍带工具调用则继续,超限截断返回(见 agent.runtime.DEFAULT_MAX_ITERATIONS) agent_max_iterations: int = 8 # 单次高风险工具确认挂起超时(秒):须小于 run_timeout_seconds,超时按拒绝处理; diff --git a/backend/app/core/ids.py b/backend/app/core/ids.py index 6265e57..30aa6cc 100644 --- a/backend/app/core/ids.py +++ b/backend/app/core/ids.py @@ -115,6 +115,22 @@ def new_user_id() -> str: return new_id("user_") +def new_embryo_id() -> str: + return new_id("embryo_") + + +def new_custodianship_id() -> str: + return new_id("cust_") + + +def new_seat_id() -> str: + return new_id("seat_") + + +def new_identity_record_id() -> str: + return new_id("identity_") + + def new_role_id() -> str: return new_id("rl_") diff --git a/backend/app/core/time.py b/backend/app/core/time.py index 2f208b4..882355b 100644 --- a/backend/app/core/time.py +++ b/backend/app/core/time.py @@ -13,3 +13,8 @@ def to_iso(value: datetime | None) -> str | None: if value.tzinfo is None: value = value.replace(tzinfo=UTC) return value.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def as_utc(value: datetime) -> datetime: + """SQLite 读回 naive datetime 时补 UTC;PG(timezone=True) 读回 aware 则原样返回。""" + return value if value.tzinfo else value.replace(tzinfo=UTC) diff --git a/backend/app/domains/__init__.py b/backend/app/domains/__init__.py new file mode 100644 index 0000000..7277ef7 --- /dev/null +++ b/backend/app/domains/__init__.py @@ -0,0 +1 @@ +"""六域领域层(rewrite/1.0)。镜生域先行,其余域随里程碑落地。""" diff --git a/backend/app/domains/jingsheng/__init__.py b/backend/app/domains/jingsheng/__init__.py new file mode 100644 index 0000000..72c8002 --- /dev/null +++ b/backend/app/domains/jingsheng/__init__.py @@ -0,0 +1,37 @@ +"""镜生域:灵胚、启镜、执镜关系、命台、魂籍、死亡与冷静期(data-model.md §1)。""" + +from app.domains.jingsheng.identity import build_identity_record, verify_identity_record +from app.domains.jingsheng.seat import SeatConflict, acquire_lease, emit_seat_event, release_lease +from app.domains.jingsheng.service import ( + abandon_embryo, + activate_embryo, + bind_event_store, + create_embryo, + death_cancel, + death_finalize, + death_request, + get_embryo, + list_embryos, + record_rehearsal, +) +from app.domains.jingsheng.service import patch_embryo + +__all__ = [ + "SeatConflict", + "abandon_embryo", + "acquire_lease", + "activate_embryo", + "bind_event_store", + "build_identity_record", + "create_embryo", + "death_cancel", + "death_finalize", + "death_request", + "emit_seat_event", + "get_embryo", + "list_embryos", + "patch_embryo", + "record_rehearsal", + "release_lease", + "verify_identity_record", +] diff --git a/backend/app/domains/jingsheng/identity.py b/backend/app/domains/jingsheng/identity.py new file mode 100644 index 0000000..7579d49 --- /dev/null +++ b/backend/app/domains/jingsheng/identity.py @@ -0,0 +1,82 @@ +"""魂籍(portable_identity_record):可验证、版本化的身份与状态包。 + +完整性模型: +- payload 规范化序列化(排序键、紧凑分隔符)后取 SHA-256 摘要; +- 签名 = HMAC-SHA256(密钥, "{character_id}|{version}|{digest}"),绑定人物与版本; +- 校验:重算摘要与签名比对。篡改 payload 破坏摘要;把记录复制到别的人物/版本, + 签名不匹配——文件相同或人物 ID 相同不足以冒充原主体(data-model.md §1)。 +""" + +from __future__ import annotations + +import hashlib +import hmac +import json + +from app.core.config import settings +from app.models.jingsheng import IdentityRecord + + +def _canonical(payload: dict) -> str: + return json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _digest(payload: dict) -> str: + return hashlib.sha256(_canonical(payload).encode("utf-8")).hexdigest() + + +def _signature(character_id: str, version: int, digest: str) -> str: + secret = settings.identity_record_secret + message = f"{character_id}|{version}|{digest}".encode("utf-8") + return hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest() + + +def build_identity_record( + *, + character_id: str, + version: int, + identity: dict, + runtime_state: dict, + data_state: dict | None = None, +) -> IdentityRecord: + """构建一条魂籍版本记录(调用方负责落库)。""" + payload = { + "spec": "hunjing.portable_identity_record.v1", + "character_id": character_id, + "version": version, + "identity": identity, + "data_state": data_state or {"note": "数据状态由各域自报(M1 占位)"}, + "runtime_state": runtime_state, + } + digest = _digest(payload) + return IdentityRecord( + character_id=character_id, + version=version, + payload_json=json.dumps(payload, ensure_ascii=False), + digest=digest, + signature=_signature(character_id, version, digest), + ) + + +class IdentityVerificationError(Exception): + """校验失败:摘要或签名不匹配。""" + + def __init__(self, reason: str) -> None: + super().__init__(reason) + self.reason = reason + + +def verify_identity_record(record: IdentityRecord) -> dict: + """校验魂籍记录,返回规范化 payload;失败抛 IdentityVerificationError。""" + try: + payload = json.loads(record.payload_json) + except json.JSONDecodeError as exc: + raise IdentityVerificationError("payload 无法解析") from exc + if _digest(payload) != record.digest: + raise IdentityVerificationError("摘要不匹配(payload 已被篡改)") + expected = _signature(record.character_id, record.version, record.digest) + if not hmac.compare_digest(expected, record.signature): + raise IdentityVerificationError("签名不匹配(非本平台签发或被移植冒用)") + if payload.get("character_id") != record.character_id or payload.get("version") != record.version: + raise IdentityVerificationError("payload 声明的身份与记录不符") + return payload diff --git a/backend/app/domains/jingsheng/seat.py b/backend/app/domains/jingsheng/seat.py new file mode 100644 index 0000000..38c6847 --- /dev/null +++ b/backend/app/domains/jingsheng/seat.py @@ -0,0 +1,94 @@ +"""命台租约(authoritative_runtime_seat):同一人物唯一权威运行位置。 + +- 活跃租约(未过期、非本人)存在时,其他持有者获取被拒(SeatConflict); +- 租约过期可接管,epoch 递增(旧持有者写入按 epoch 失效——M2 运行时接入); +- 事件(seat.acquired / released / contested)由 API 层在事务提交后发射, + 避免事务内跨连接写冲突;M1 为 API 级租约,Kernel 前台串行仍为进程内锁,多进程一致在 M2 收口。 +""" + +from __future__ import annotations + +from datetime import timedelta + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.time import as_utc, utc_now +from app.models.jingsheng import RuntimeSeat + + +class SeatConflict(Exception): + """命台被其他持有者占用(HJ-1101 语义);调用方须留痕 seat.contested。""" + + def __init__(self, seat: RuntimeSeat) -> None: + super().__init__(f"命台被 {seat.holder} 占用至 {seat.lease_expires_at}") + self.seat = seat + + +async def acquire_lease( + session: AsyncSession, character_id: str, holder: str, ttl_seconds: int = 60 +) -> RuntimeSeat: + """获取或续期命台租约;冲突抛 SeatConflict(不在此发事件,避免事务内跨连接写)。""" + seat = await session.scalar(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id)) + if seat is None: + seat = RuntimeSeat(character_id=character_id, epoch=0) + session.add(seat) + now = utc_now() + if ( + seat.holder is not None + and seat.holder != holder + and seat.lease_expires_at is not None + and as_utc(seat.lease_expires_at) > now + ): + raise SeatConflict(seat) + # 仅"从其他持有者手中接管"递增 epoch;无人持有时首次获取沿用当前纪元 + takeover = seat.holder is not None and seat.holder != holder + seat.holder = holder + seat.lease_expires_at = now + timedelta(seconds=ttl_seconds) + if takeover: + seat.epoch += 1 + await session.flush() + return seat + + +async def release_lease(session: AsyncSession, character_id: str, holder: str) -> bool: + """释放租约;返回是否实际释放(holder 不匹配时无操作)。""" + seat = await session.scalar(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id)) + if seat is None or seat.holder != holder: + return False + seat.holder = None + seat.lease_expires_at = None + await session.flush() + return True + + +async def emit_seat_event( + event_type: str, + character_id: str, + holder: str | None, + epoch: int | None, + *, + requester: str | None = None, +) -> None: + """事务外发射命台事件(一炁,visibility=internal)。""" + from app.modules.runtime.events import Event + from app.services.event_store import DbEventStore + + from app.domains.jingsheng import service as jingsheng_service + + store: DbEventStore | None = jingsheng_service._event_store # noqa: SLF001 + if store is None: + return + payload: dict = {"holder": holder, "epoch": epoch} + if requester is not None: + payload["requester"] = requester + await store.append( + Event( + type=event_type, + source="jingsheng", + payload=payload, + visibility="internal", + run_id=None, + character_id=character_id, + ) + ) diff --git a/backend/app/domains/jingsheng/service.py b/backend/app/domains/jingsheng/service.py new file mode 100644 index 0000000..9476f09 --- /dev/null +++ b/backend/app/domains/jingsheng/service.py @@ -0,0 +1,318 @@ +"""镜生域服务:灵胚生命周期、启镜事务、死亡请求与冷静期。""" + +from __future__ import annotations + +import json +from datetime import datetime, UTC + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.errors import AppError +from app.core.time import as_utc, utc_now +from app.db.session import AsyncSessionLocal +from app.domains.jingsheng.identity import build_identity_record +from app.models.character import Character +from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat +from app.services.event_store import DbEventStore + +# 事件由服务层经一炁落库(DbEventStore 由调用方注入;默认取进程级装配) +_event_store: DbEventStore | None = None + + +def bind_event_store(store: DbEventStore) -> None: + """lifespan 装配时注入事件存储。""" + global _event_store + _event_store = store + + +async def _emit( + event_type: str, + *, + character_id: str | None, + owner_scope: str, + payload: dict, + visibility: str = "public", +) -> None: + from app.modules.runtime.events import Event + + if _event_store is None: + return + await _event_store.append( + Event( + type=event_type, + source="jingsheng", + payload=payload, + visibility=visibility, # type: ignore[arg-type] + run_id=None, + character_id=character_id, + ) + ) + + +def _owned(embryo: EmbryoDraft, owner_scope: str) -> EmbryoDraft: + if embryo.owner_scope != owner_scope: + raise AppError("not_found", "灵胚不存在。") + return embryo + + +async def list_embryos(owner_scope: str) -> list[EmbryoDraft]: + async with AsyncSessionLocal() as session: + result = await session.execute( + select(EmbryoDraft) + .where(EmbryoDraft.owner_scope == owner_scope, EmbryoDraft.status != "abandoned") + .order_by(EmbryoDraft.created_at.desc()) + ) + return list(result.scalars().all()) + + +async def create_embryo( + *, + owner_scope: str, + source: str, + name: str, + description: str | None = None, + identity_baseline: dict | None = None, +) -> EmbryoDraft: + if source not in ("original", "adopted", "imported", "serendipity"): + raise AppError("invalid_request", "灵胚来源取值不合法。") + embryo = EmbryoDraft( + owner_scope=owner_scope, + source=source, + name=name, + description=description, + identity_baseline_json=json.dumps(identity_baseline or {}, ensure_ascii=False), + ) + async with AsyncSessionLocal() as session: + session.add(embryo) + await session.commit() + await session.refresh(embryo) + await _emit( + "embryo.created", + character_id=None, + owner_scope=owner_scope, + payload={"embryo_id": embryo.id, "source": source}, + ) + return embryo + + +async def get_embryo(embryo_id: str, owner_scope: str) -> EmbryoDraft: + async with AsyncSessionLocal() as session: + embryo = await session.get(EmbryoDraft, embryo_id) + if embryo is None or embryo.status == "abandoned": + raise AppError("not_found", "灵胚不存在。") + return _owned(embryo, owner_scope) + + +async def patch_embryo( + embryo_id: str, + owner_scope: str, + *, + name: str | None = None, + description: str | None = None, + identity_baseline: dict | None = None, +) -> EmbryoDraft: + async with AsyncSessionLocal() as session: + embryo = await session.get(EmbryoDraft, embryo_id) + if embryo is None or embryo.status == "abandoned": + raise AppError("not_found", "灵胚不存在。") + _owned(embryo, owner_scope) + if embryo.status == "activated": + raise AppError( + "HJ-1103", "灵胚已启镜,草稿不可再编辑。", status_code=409 + ) + if name is not None: + embryo.name = name + if description is not None: + embryo.description = description + if identity_baseline is not None: + embryo.identity_baseline_json = json.dumps(identity_baseline, ensure_ascii=False) + await session.commit() + await session.refresh(embryo) + return embryo + + +async def record_rehearsal(embryo_id: str, owner_scope: str, *, run_id: str, prompt: str) -> None: + """照心试演引用落灵胚(内容在事件日志,不构成人物经历)。""" + async with AsyncSessionLocal() as session: + embryo = await session.get(EmbryoDraft, embryo_id) + if embryo is None or embryo.status == "abandoned": + raise AppError("not_found", "灵胚不存在。") + _owned(embryo, owner_scope) + rehearsals = json.loads(embryo.rehearsals_json) + rehearsals.append({"run_id": run_id, "prompt": prompt, "performed_at": utc_now().isoformat()}) + embryo.rehearsals_json = json.dumps(rehearsals, ensure_ascii=False) + if embryo.status == "drafting": + embryo.status = "previewing" + await session.commit() + + +async def abandon_embryo(embryo_id: str, owner_scope: str) -> None: + async with AsyncSessionLocal() as session: + embryo = await session.get(EmbryoDraft, embryo_id) + if embryo is None: + raise AppError("not_found", "灵胚不存在。") + _owned(embryo, owner_scope) + if embryo.status == "activated": + raise AppError("HJ-1103", "灵胚已启镜,不能放弃;请走人物死亡流程。", status_code=409) + embryo.status = "abandoned" + await session.commit() + await _emit( + "embryo.abandoned", + character_id=None, + owner_scope=owner_scope, + payload={"embryo_id": embryo_id}, + ) + + +async def activate_embryo(embryo_id: str, owner_scope: str) -> Character: + """启镜:单事务内签发人物、执镜关系、命台与魂籍 v1。 + + 并发防护:以 ``UPDATE embryo_drafts SET status='confirming' WHERE status IN (可启镜态)`` + 的原子行数作为闸门——第二个并发启镜拿不到闸门即 409,不产生第二个人物。 + """ + async with AsyncSessionLocal() as session: + embryo = await session.get(EmbryoDraft, embryo_id) + if embryo is None or embryo.status == "abandoned": + raise AppError("not_found", "灵胚不存在。") + _owned(embryo, owner_scope) + + gate = await session.execute( + update(EmbryoDraft) + .where(EmbryoDraft.id == embryo_id, EmbryoDraft.status.in_(("drafting", "previewing", "confirming"))) + .values(status="confirming") + ) + if gate.rowcount != 1: + raise AppError( + "HJ-1103", "灵胚当前状态不允许启镜(可能已被并发启镜)。", status_code=409 + ) + + baseline = json.loads(embryo.identity_baseline_json or "{}") + character = Character( + name=embryo.name, + owner_scope=owner_scope, + status="active", + source=embryo.source, + identity_baseline_json=embryo.identity_baseline_json, + profile_json=json.dumps({"description": embryo.description or ""}, ensure_ascii=False), + activated_at=utc_now(), + ) + session.add(character) + await session.flush() # 取 character.id(commit 后属性过期,异步下不可再访问) + character_id = character.id + + session.add(Custodianship(character_id=character.id, custodian_scope=owner_scope)) + session.add(RuntimeSeat(character_id=character.id, epoch=1)) + + record = build_identity_record( + character_id=character.id, + version=1, + identity={ + "name": character.name, + "source": character.source, + "identity_baseline": baseline, + "activated_at": character.activated_at.isoformat(), + }, + runtime_state={"status": "active", "seat_epoch": 1}, + ) + session.add(record) + await session.flush() # 取魂籍 ID + record_id = record.id + source = embryo.source + embryo.status = "activated" + await session.commit() + + await _emit( + "character.activated", + character_id=character_id, + owner_scope=owner_scope, + payload={"embryo_id": embryo_id, "source": source, "identity_record": record_id}, + ) + async with AsyncSessionLocal() as session: + created = await session.get(Character, character_id) + assert created is not None + return created + + +async def death_request( + character_id: str, owner_scope: str, *, calm_period_hours: int +) -> Character: + """死亡请求:进入冷静期(death_pending),可撤销;冷静期内人物默认静修。""" + async with AsyncSessionLocal() as session: + character = await _owned_character(session, character_id, owner_scope) + if character.status == "deceased": + raise AppError("HJ-1103", "人物已死亡。", status_code=409) + now = utc_now() + character.status = "death_pending" + character.death_requested_at = now + character.calm_period_until = datetime.fromtimestamp( + now.timestamp() + calm_period_hours * 3600, tz=UTC + ) + await session.commit() + await session.refresh(character) + await _emit( + "character.death_requested", + character_id=character_id, + owner_scope=owner_scope, + payload={"calm_period_until": character.calm_period_until.isoformat()}, + ) + return character + + +async def death_cancel(character_id: str, owner_scope: str) -> Character: + """冷静期撤销:恢复 active。人物不得阻挠撤销(宪章)。""" + async with AsyncSessionLocal() as session: + character = await _owned_character(session, character_id, owner_scope) + if character.status != "death_pending": + raise AppError("HJ-1103", "人物不在死亡冷静期。", status_code=409) + character.status = "active" + character.death_requested_at = None + character.calm_period_until = None + await session.commit() + await session.refresh(character) + await _emit( + "character.death_cancelled", + character_id=character_id, + owner_scope=owner_scope, + payload={}, + ) + return character + + +async def death_finalize(character_id: str, owner_scope: str) -> Character: + """死亡落定:身份时间线闭合(deceased),数据保留——数据清除是独立操作。""" + async with AsyncSessionLocal() as session: + character = await _owned_character(session, character_id, owner_scope) + if character.status != "death_pending": + raise AppError("HJ-1103", "人物不在死亡冷静期。", status_code=409) + assert character.calm_period_until is not None + if utc_now() < as_utc(character.calm_period_until): + raise AppError( + "HJ-1103", + f"冷静期未满(至 {character.calm_period_until.isoformat()}),不能落定死亡。", + status_code=422, + ) + character.status = "deceased" + character.died_at = utc_now() + seat = await session.scalar(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id)) + if seat is not None: + seat.holder = None + seat.lease_expires_at = None + await session.commit() + await session.refresh(character) + await _emit( + "character.deceased", + character_id=character_id, + owner_scope=owner_scope, + payload={"died_at": character.died_at.isoformat() if character.died_at else None}, + ) + return character + + +async def _owned_character( + session: AsyncSession, character_id: str, owner_scope: str +) -> Character: + character = await session.get(Character, character_id) + if character is None or character.owner_scope != owner_scope: + raise AppError("not_found", "人物不存在。") + return character diff --git a/backend/app/main.py b/backend/app/main.py index d257b8c..8440be5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -98,6 +98,11 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: timeout_seconds=float(settings.run_timeout_seconds), ) + # 镜生域事件写入绑定一炁(灵胚/启镜/命台/死亡事件落 events 表) + from app.domains.jingsheng import bind_event_store + + bind_event_store(_app.state.event_store) + # ── MCP 进程管理器(stdio 子进程生命周期管理) ────────────── from app.integrations.mcp.process_manager import ( ProcessManager, diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 2bf8066..bdda585 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -16,6 +16,7 @@ from app.models.governance import ( ) from app.models.idempotency import IdempotencyKey from app.models.imports import CharacterImportJob, ImportItem +from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat from app.models.lorebook import Lorebook, LorebookEntry from app.models.mcp_servers import McpServer from app.models.memory import Memory, MemoryLink diff --git a/backend/app/models/character.py b/backend/app/models/character.py index 3588de3..4ec370d 100644 --- a/backend/app/models/character.py +++ b/backend/app/models/character.py @@ -41,3 +41,13 @@ class Character(Base): ) activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # ── rewrite/1.0 镜生域增量列(data-model.md §1):来源、道体锚点、死亡与冷静期 ── + source: Mapped[str] = mapped_column( + String(40), nullable=False, default="original", server_default="original", index=True + ) + identity_baseline_json: Mapped[str] = mapped_column( + Text, nullable=False, default="{}", server_default="{}" + ) + death_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + calm_period_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + died_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/backend/app/models/jingsheng.py b/backend/app/models/jingsheng.py new file mode 100644 index 0000000..b85a94f --- /dev/null +++ b/backend/app/models/jingsheng.py @@ -0,0 +1,84 @@ +"""镜生域模型:灵胚、执镜关系、命台、魂籍(data-model.md §1)。 + +- 灵胚只有预备身份,启镜(activate)才产生正式人物与执镜关系; +- 命台人物粒度唯一(UNIQUE(character_id)),租约到期可被接管,epoch 递增; +- 魂籍版本不可变(UNIQUE(character_id, version)),签名绑定人物与版本。 +""" + +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.ids import new_custodianship_id, new_embryo_id, new_identity_record_id, new_seat_id +from app.core.time import utc_now +from app.db.base import Base + + +class EmbryoDraft(Base): + """灵胚:启镜前的可审阅草稿。放弃灵胚是删除草稿,不是人物死亡。""" + + __tablename__ = "embryo_drafts" + + id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_embryo_id) + owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + # drafting / previewing / confirming / activated / abandoned + status: Mapped[str] = mapped_column(String(40), nullable=False, default="drafting", index=True) + # original / adopted / imported / serendipity + source: Mapped[str] = mapped_column(String(40), nullable=False, default="original") + name: Mapped[str] = mapped_column(String(120), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + identity_baseline_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + # 照心试演引用:[{run_id, prompt, performed_at}],内容在事件日志,不入正式经历 + rehearsals_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utc_now, onupdate=utc_now + ) + + +class Custodianship(Base): + """执镜关系:治理责任(1.0 一对一),与缘契(covenants)严格分离。""" + + __tablename__ = "custodianships" + + id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_custodianship_id) + character_id: Mapped[str] = mapped_column( + String(80), ForeignKey("characters.id"), nullable=False, unique=True, index=True + ) + custodian_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) + + +class RuntimeSeat(Base): + """命台:人物唯一权威运行位置;holder 持有租约,过期可接管,epoch 每次获取递增。""" + + __tablename__ = "runtime_seats" + + id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_seat_id) + character_id: Mapped[str] = mapped_column( + String(80), ForeignKey("characters.id"), nullable=False, unique=True, index=True + ) + epoch: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + holder: Mapped[str | None] = mapped_column(String(120)) + lease_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=utc_now, onupdate=utc_now + ) + + +class IdentityRecord(Base): + """魂籍:可验证、版本化的身份与状态包。版本不可变;复制不能凭相同 ID 冒充原人物。""" + + __tablename__ = "identity_records" + __table_args__ = (UniqueConstraint("character_id", "version", name="uq_identity_char_version"),) + + id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_identity_record_id) + character_id: Mapped[str] = mapped_column( + String(80), ForeignKey("characters.id"), nullable=False, index=True + ) + version: Mapped[int] = mapped_column(Integer, nullable=False) + payload_json: Mapped[str] = mapped_column(Text, nullable=False) + digest: Mapped[str] = mapped_column(String(64), nullable=False) + signature: Mapped[str] = mapped_column(String(128), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now) diff --git a/backend/app/models/runtime.py b/backend/app/models/runtime.py index 6731f39..8607007 100644 --- a/backend/app/models/runtime.py +++ b/backend/app/models/runtime.py @@ -14,11 +14,18 @@ class Run(Base): __tablename__ = "runs" id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_run_id) - character_id: Mapped[str] = mapped_column(String(80), ForeignKey("characters.id"), index=True) + # rewrite/1.0:照心试演(kind=rehearsal)无正式人物,character_id 允许为空 + character_id: Mapped[str | None] = mapped_column( + String(80), ForeignKey("characters.id"), index=True, nullable=True + ) session_id: Mapped[str | None] = mapped_column( String(80), ForeignKey("chat_sessions.id"), index=True ) status: Mapped[str] = mapped_column(String(40), nullable=False, default="queued", index=True) + # run.kind:foreground / background_task / awakening / rehearsal(契约词典 §8.2) + kind: Mapped[str] = mapped_column( + String(40), nullable=False, default="foreground", server_default="foreground" + ) goal: Mapped[str | None] = mapped_column(String(300)) origin: Mapped[str] = mapped_column(String(40), nullable=False, default="chat") model_hint: Mapped[str | None] = mapped_column(String(120)) diff --git a/backend/app/schemas/runtime.py b/backend/app/schemas/runtime.py index 121b3e0..bbfe5be 100644 --- a/backend/app/schemas/runtime.py +++ b/backend/app/schemas/runtime.py @@ -66,11 +66,17 @@ class RunResponse(BaseModel): ) id: str = Field(description="运行 ID,前缀为 run_。") - character_id: str = Field(description="所属人物 ID。") - session_id: str | None = Field(description="关联会话 ID。") + character_id: str | None = Field( + default=None, description="所属人物 ID;照心试演(kind=rehearsal)无正式人物。" + ) + session_id: str | None = Field(default=None, description="关联会话 ID。") status: str = Field( description="运行状态:queued / running / completed / failed / cancelled / interrupted。" ) + kind: str = Field( + default="foreground", + description="运行种类:foreground / background_task / awakening / rehearsal(契约词典 §8.2)。", + ) goal: str | None = Field(description="运行目标。") origin: str = Field(description="运行来源,例如 chat。") model_hint: str | None = Field(description="本次运行使用的逻辑模型名或能力槽位。") diff --git a/backend/app/services/serializers.py b/backend/app/services/serializers.py index 084b501..a8e9122 100644 --- a/backend/app/services/serializers.py +++ b/backend/app/services/serializers.py @@ -187,6 +187,7 @@ def run_to_response(run: Run) -> RunResponse: character_id=run.character_id, session_id=run.session_id, status=run.status, + kind=run.kind, goal=run.goal, origin=run.origin, model_hint=run.model_hint, diff --git a/backend/tests/test_jingsheng.py b/backend/tests/test_jingsheng.py new file mode 100644 index 0000000..f57db16 --- /dev/null +++ b/backend/tests/test_jingsheng.py @@ -0,0 +1,344 @@ +"""镜生域 M1 测试。 + +对应 rewrite-plan M1 验收: +- 启镜前无正式人物 ID(灵胚/试演阶段零 character 行、零 character.activated 事件); +- 并发启镜只有一个赢家(原子闸门 409);命台租约冲突被拒且留痕(seat.contested); +- 魂籍可验证、篡改与移植冒充均被拒(摘要+签名双校验)。 +""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncGenerator +from datetime import timedelta + +import httpx +import pytest +from httpx import ASGITransport, AsyncClient + +from app.db.session import AsyncSessionLocal +from app.domains.jingsheng import bind_event_store +from app.kernel import Kernel, KernelEventLog +from app.models.character import Character +from app.models.jingsheng import Custodianship, IdentityRecord, RuntimeSeat +from app.services.event_store import DbEventStore +from app.shenshi_client import EchoShenshi + + +@pytest.fixture +async def jingsheng_client( + monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str] +) -> AsyncGenerator[AsyncClient, None]: + from app.main import app + + event_store = DbEventStore(AsyncSessionLocal) + bind_event_store(event_store) + app.state.kernel = Kernel( + shenshi=EchoShenshi(), + session_factory=AsyncSessionLocal, + event_log=KernelEventLog(event_store), + timeout_seconds=10.0, + ) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c: + yield c + app.state.kernel = None + + +async def _create_embryo(client: AsyncClient, name: str = "试灵") -> dict: + resp = await client.post( + "/api/v1/embryo-drafts", + json={"source": "original", "name": name, "identity_baseline": {"锚点": "温润"}}, + ) + assert resp.status_code == 201 + return resp.json() + + +async def _characters_count() -> int: + from sqlalchemy import func, select + + async with AsyncSessionLocal() as session: + return int(await session.scalar(select(func.count(Character.id)))) + + +async def _events_of_type(event_type: str) -> list[dict]: + store = DbEventStore(AsyncSessionLocal) + all_events = [] + async with AsyncSessionLocal() as session: + from sqlalchemy import select + + from app.models.runtime import Event + + rows = ( + (await session.execute(select(Event).where(Event.type == event_type))).scalars().all() + ) + all_events = [json.loads(r.payload_json) for r in rows] + _ = store + return all_events + + +async def _wait_run(client: AsyncClient, run_id: str) -> str: + for _ in range(300): + resp = await client.get(f"/api/v1/runs/{run_id}") + status = resp.json()["status"] + if status in ("completed", "failed", "interrupted", "cancelled"): + return status + await asyncio.sleep(0.02) + raise AssertionError("Run 未到达终态") + + +# ── 启镜前无正式人物 ────────────────────────────────────────────── + + +async def test_embryo_and_rehearsal_create_no_character(jingsheng_client: AsyncClient) -> None: + before = await _characters_count() + embryo = await _create_embryo(jingsheng_client) + + patched = await jingsheng_client.patch( + f"/api/v1/embryo-drafts/{embryo['id']}", json={"description": "照心共塑"} + ) + assert patched.status_code == 200 + + rehearsed = await jingsheng_client.post( + f"/api/v1/embryo-drafts/{embryo['id']}/rehearse", json={"prompt": "你是谁?"} + ) + assert rehearsed.status_code == 200 + run_id = rehearsed.json()["run_id"] + assert await _wait_run(jingsheng_client, run_id) == "completed" + + run_detail = (await jingsheng_client.get(f"/api/v1/runs/{run_id}")).json() + assert run_detail["kind"] == "rehearsal" + + embryo_now = (await jingsheng_client.get(f"/api/v1/embryo-drafts/{embryo['id']}")).json() + assert embryo_now["status"] == "previewing" + assert embryo_now["rehearsals"][0]["run_id"] == run_id + + # 验收:启镜前零人物行、零 character.activated 事件 + assert await _characters_count() == before + assert await _events_of_type("character.activated") == [] + + +# ── 启镜与并发防护 ────────────────────────────────────────────── + + +async def test_activate_issues_identity_and_guards(jingsheng_client: AsyncClient) -> None: + embryo = await _create_embryo(jingsheng_client, name="镜心") + activated = await jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate") + assert activated.status_code == 200 + character_id = activated.json()["character_id"] + assert activated.json()["source"] == "original" + + async with AsyncSessionLocal() as session: + from sqlalchemy import select + + character = await session.get(Character, character_id) + assert character is not None and character.status == "active" + assert character.identity_baseline_json == json.dumps({"锚点": "温润"}, ensure_ascii=False) + cust = ( + await session.execute( + select(Custodianship).where(Custodianship.character_id == character_id) + ) + ).scalar_one() + assert cust.custodian_scope == character.owner_scope + seat = ( + await session.execute(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id)) + ).scalar_one() + assert seat.epoch == 1 and seat.holder is None + record = ( + await session.execute( + select(IdentityRecord).where(IdentityRecord.character_id == character_id) + ) + ).scalar_one() + assert record.version == 1 + + events = await _events_of_type("character.activated") + assert any(e.get("embryo_id") == embryo["id"] for e in events) + + # 二次启镜被拒 + again = await jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate") + assert again.status_code == 409 + assert "HJ-1103" in again.json()["error"]["code"] + + +async def test_concurrent_activation_single_winner(jingsheng_client: AsyncClient) -> None: + embryo = await _create_embryo(jingsheng_client, name="并发灵") + results = await asyncio.gather( + jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate"), + jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate"), + ) + statuses = sorted(r.status_code for r in results) + assert statuses == [200, 409] + winner = next(r for r in results if r.status_code == 200) + loser = next(r for r in results if r.status_code == 409) + assert winner.json()["character_id"] + assert "HJ-1103" in loser.json()["error"]["code"] + + +# ── 魂籍:验证、篡改与冒充 ────────────────────────────────────────────── + + +async def test_identity_record_verify_and_tamper(jingsheng_client: AsyncClient) -> None: + embryo = await _create_embryo(jingsheng_client, name="魂籍灵") + character_id = ( + (await jingsheng_client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")).json()[ + "character_id" + ] + ) + exported = await jingsheng_client.post(f"/api/v1/characters/{character_id}/identity-records") + assert exported.status_code == 200 + record_id = exported.json()["id"] + + ok = await jingsheng_client.post( + f"/api/v1/characters/{character_id}/identity-records/{record_id}/verify" + ) + assert ok.status_code == 200 and ok.json()["valid"] is True + + # 篡改 payload:摘要不匹配 + async with AsyncSessionLocal() as session: + record = await session.get(IdentityRecord, record_id) + payload = json.loads(record.payload_json) + payload["identity"]["name"] = "冒充者" + record.payload_json = json.dumps(payload, ensure_ascii=False) + await session.commit() + tampered = await jingsheng_client.post( + f"/api/v1/characters/{character_id}/identity-records/{record_id}/verify" + ) + assert tampered.status_code == 422 + + # 移植冒充:把整条记录复制到另一人物名下,签名不匹配 + other = await _create_embryo(jingsheng_client, name="另一灵") + other_character = ( + (await jingsheng_client.post(f"/api/v1/embryo-drafts/{other['id']}/activate")).json()[ + "character_id" + ] + ) + async with AsyncSessionLocal() as session: + original = await session.get(IdentityRecord, record_id) + session.add( + IdentityRecord( + character_id=other_character, + version=9, # 避开另一人物已有的 v1;冒充场景本身与版本号无关 + payload_json=original.payload_json, + digest=original.digest, + signature=original.signature, # 原样复制——文件复制不能冒充 + ) + ) + await session.commit() + async with AsyncSessionLocal() as session: + from sqlalchemy import select + + forged = ( + await session.execute( + select(IdentityRecord).where( + IdentityRecord.character_id == other_character, + IdentityRecord.version == 9, + ) + ) + ).scalar_one() + forged_id = forged.id + forged_check = await jingsheng_client.post( + f"/api/v1/characters/{other_character}/identity-records/{forged_id}/verify" + ) + assert forged_check.status_code == 422 + + +# ── 命台租约:冲突留痕、过期接管 ────────────────────────────────────────────── + + +async def _activated_character(client: AsyncClient, name: str) -> str: + embryo = await _create_embryo(client, name=name) + return ( + (await client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")).json()["character_id"] + ) + + +async def test_seat_lease_conflict_and_takeover(jingsheng_client: AsyncClient) -> None: + character_id = await _activated_character(jingsheng_client, "命台灵") + + first = await jingsheng_client.post( + f"/api/v1/characters/{character_id}/seat/acquire", json={"holder": "device-A"} + ) + assert first.status_code == 200 and first.json()["epoch"] == 1 + + conflict = await jingsheng_client.post( + f"/api/v1/characters/{character_id}/seat/acquire", json={"holder": "device-B"} + ) + assert conflict.status_code == 429 + assert conflict.json()["error"]["code"] == "HJ-1101" + # 验收:抢占被拒且留痕 + async with AsyncSessionLocal() as session: + from sqlalchemy import select + + from app.models.runtime import Event + + rows = ( + await session.execute(select(Event).where(Event.type == "seat.contested")) + ).scalars().all() + assert any(json.loads(r.payload_json).get("requester") == "device-B" for r in rows) + + # 同持有者续期:不冲突 + renew = await jingsheng_client.post( + f"/api/v1/characters/{character_id}/seat/acquire", json={"holder": "device-A"} + ) + assert renew.status_code == 200 + + # 租约过期后接管:epoch 递增 + async with AsyncSessionLocal() as session: + seat = ( + await session.execute(select(RuntimeSeat).where(RuntimeSeat.character_id == character_id)) + ).scalar_one() + seat.lease_expires_at = seat.lease_expires_at - timedelta(seconds=9999) + await session.commit() + takeover = await jingsheng_client.post( + f"/api/v1/characters/{character_id}/seat/acquire", json={"holder": "device-B"} + ) + assert takeover.status_code == 200 and takeover.json()["epoch"] == 2 + + +# ── 死亡:冷静期、撤销与落定 ────────────────────────────────────────────── + + +async def test_death_calm_period_flow(jingsheng_client: AsyncClient) -> None: + character_id = await _activated_character(jingsheng_client, "将别灵") + + requested = await jingsheng_client.post(f"/api/v1/characters/{character_id}/death-request") + assert requested.status_code == 200 + assert requested.json()["status"] == "death_pending" + + early = await jingsheng_client.post( + f"/api/v1/characters/{character_id}/death-finalize", json={"confirm_phrase": "将别灵"} + ) + assert early.status_code == 422 # 冷静期未满 + + async with AsyncSessionLocal() as session: + character = await session.get(Character, character_id) + character.calm_period_until = character.calm_period_until - timedelta(hours=100) + await session.commit() + wrong_phrase = await jingsheng_client.post( + f"/api/v1/characters/{character_id}/death-finalize", json={"confirm_phrase": "错误名"} + ) + assert wrong_phrase.status_code == 400 + + finalized = await jingsheng_client.post( + f"/api/v1/characters/{character_id}/death-finalize", json={"confirm_phrase": "将别灵"} + ) + assert finalized.status_code == 200 + assert finalized.json()["status"] == "deceased" + + # 死亡是身份闭合,不是数据删除:行仍在 + async with AsyncSessionLocal() as session: + character = await session.get(Character, character_id) + assert character is not None and character.status == "deceased" + assert character.died_at is not None + events = await _events_of_type("character.deceased") + assert events + + +async def test_death_cancel_restores_active(jingsheng_client: AsyncClient) -> None: + character_id = await _activated_character(jingsheng_client, "回心灵") + await jingsheng_client.post(f"/api/v1/characters/{character_id}/death-request") + cancelled = await jingsheng_client.post(f"/api/v1/characters/{character_id}/death-cancel") + assert cancelled.status_code == 200 and cancelled.json()["status"] == "active" + events = await _events_of_type("character.death_cancelled") + assert events diff --git a/docs/contracts/api-contracts.md b/docs/contracts/api-contracts.md index 2f8bee7..6169025 100644 --- a/docs/contracts/api-contracts.md +++ b/docs/contracts/api-contracts.md @@ -47,7 +47,13 @@ GET /api/v1/characters/{id}/identity-baseline # 道体(只读展示) POST /api/v1/characters/{id}/reforge-request # 铸魂请求(高影响确认流) POST /api/v1/characters/{id}/branch-off # 分灵 POST /api/v1/characters/{id}/death-request # 死亡请求(强认证+冷静期) -POST /api/v1/characters/{id}/identity-records # 魂籍导出(版本化包) +POST /api/v1/characters/{id}/death-cancel # 冷静期撤销 +POST /api/v1/characters/{id}/death-finalize # 冷静期满后落定(确认短语强校验) +GET|POST /api/v1/characters/{id}/identity-records # 魂籍列表/导出新版本(版本化包) +GET /api/v1/characters/{id}/identity-records/{record_id} # 完整魂籍 payload +POST /api/v1/characters/{id}/identity-records/{record_id}/verify # 摘要+签名校验 +POST /api/v1/characters/{id}/seat/acquire # 命台租约获取(冲突 HJ-1101 + seat.contested) +POST /api/v1/characters/{id}/seat/release # 命台租约释放 ``` ### 3.3 镜树(灵台域·对话) diff --git a/docs/contracts/contract-glossary.md b/docs/contracts/contract-glossary.md index 3262727..f5430d8 100644 --- a/docs/contracts/contract-glossary.md +++ b/docs/contracts/contract-glossary.md @@ -217,7 +217,7 @@ ### 8.2 `run.kind` -`foreground`(前台意识流)/ `background_task`(天工阁受托任务)/ `awakening`(应感) +`foreground`(前台意识流)/ `background_task`(天工阁受托任务)/ `awakening`(应感)/ `rehearsal`(照心试演:幻境 Run,无正式人物、不产生人物经历) ### 8.3 事件类型 `event.type`(一炁) @@ -231,7 +231,9 @@ - 接纳:`result.pre_accept` / `result.accepted` - 镜树:`tree.created` / `tree.node_created` / `tree.node_solidified` / `tree.node_sealed`(残答封存)/ `tree.adopted` / `tree.canonized` / `tree.transplanted` - 记忆:`memory.candidate_created` / `memory.confirmed` / `memory.denied` / `memory.superseded` / `memory.deleted` -- 人物:`character.activated`(启镜)/ `character.reforged`(铸魂)/ `character.branch_off`(分灵)/ `character.death_requested` / `character.deceased` +- 人物:`character.activated`(启镜)/ `character.reforged`(铸魂)/ `character.branch_off`(分灵)/ `character.death_requested` / `character.death_cancelled` / `character.deceased` +- 灵胚:`embryo.created` / `embryo.abandoned`(放弃灵胚是删草稿,非死亡) +- 命台:`seat.acquired` / `seat.released` / `seat.contested`(租约冲突留痕) - 缘契:`covenant.proposed` / `covenant.disclosed`(问心)/ `covenant.confirmed`(铭契)/ `covenant.changed` / `covenant.withdrawn` - 司命:`safety.ruled`(裁决记录)/ `safety.confirm_requested` / `safety.confirm_resolved` - 能力:`capability.granted` / `capability.revoked` diff --git a/docs/operations/capability-matrix.md b/docs/operations/capability-matrix.md index 533ce7d..507218c 100644 --- a/docs/operations/capability-matrix.md +++ b/docs/operations/capability-matrix.md @@ -20,7 +20,7 @@ | 能力 | 里程碑 | 当前状态 | 说明与缺口 | | --- | --- | --- | --- | | 灵台 Kernel 与插件运行时 | M0 | 局部实现 | M0 骨架已验证(本地开发档,2026-08-21):`backend/tests/test_kernel.py` 5 用例(事件序列/命台串行/否决/不变量/SSE),Kernel 经 DbEventStore 事件溯源;投影仍为占位,九枢插件与禁域裁决待 M2+/M5 | -| 镜生闭环(灵胚/启镜/魂籍/命台) | M1 | 未实现 | 旧 characters 表将重建;命台唯一性无实现 | +| 镜生闭环(灵胚/启镜/魂籍/命台) | M1 | 局部实现 | M1 后端闭环已验证(本地开发档,2026-08-21):`backend/tests/test_jingsheng.py` 7 用例——启镜前零人物行/零 activated 事件、并发启镜原子闸门单赢家(409 HJ-1103)、命台租约冲突被拒且 `seat.contested` 留痕、过期接管 epoch 递增、魂籍摘要+签名双校验(篡改与移植冒充均 422)、死亡冷静期撤销/落定(数据保留);随缘候选生成与角色卡导入对接随 M2,前端人物页随 M2+ | | 镜树对话(问节/候答/采用/归真/引文/移栽) | M2 | 未实现 | 现有 chat 为线性会话,将被替换删除 | | 三级记忆(微痕/明忆/重忆/实录/心痕) | M3 | 未实现 | 旧 memory/extraction 模块待退役;新管线未建 | | 神识 Go 网关 | M4 | 局部实现 | M0 骨架已上线(2026-08-21):IR 入站/SSE 出站/stub 适配器/服务令牌/健康检查,`go test` 5 用例,与灵台 HttpShenshiClient 联调通过;云 Provider 与元炁端点、路由冷却计量待 M4 | diff --git a/docs/operations/rewrite-plan.md b/docs/operations/rewrite-plan.md index 031f12e..0787954 100644 --- a/docs/operations/rewrite-plan.md +++ b/docs/operations/rewrite-plan.md @@ -83,10 +83,12 @@ - 验收:登录(复用 auth)→ 创建占位 Run → SSE 端到端流到浏览器(经照壁);"模型可见即可重建"断言在 stub 上可跑;旧 chat/memory 模块仍可用但不再新增调用。 - 证据:`backend/tests/test_kernel.py` 5 用例(事件序列/命台串行/否决/不变量/流式);`shenshi` `go test` 5 用例;`bff` vitest 4 用例;三服务(照壁 18100 / 灵台 18000 / 神识 18081)真实进程 E2E 冒烟:注册→登录→POST /api/v1/runs→SSE 流读至 run.finished,模型流经 Go 神识 stub("【神识回声桩】"前缀可证);全量后端回归 925 passed(另有 20 个预存失败,根因本地缺 `config/models.yaml`,与本次改动无关,stash 验证);Alembic 基线合并为单一迁移(41 表,空库 upgrade 干净)。 -### M1 镜生闭环(灵胚→启镜→魂籍→命台) +### M1 镜生闭环(灵胚→启镜→魂籍→命台)— ✅ 后端闭环已完成(2026-08-21) -- 交付:embryo_drafts/custodianships/runtime_seats/identity_records 表与 API;灵胚创建(原创/导入占位/随缘)、照心试演(幻境 Run 标注)、启镜、放弃;死亡请求与冷静期骨架;魂籍版本化导出。 +- 交付:embryo_drafts/custodianships/runtime_seats/identity_records 表与 API(迁移 `001`,顺序编号);灵胚创建(原创/领养/导入来源受理)、照心试演(`run.kind=rehearsal`,幻境 Run、无人物、不产生经历)、启镜(原子闸门)、放弃;死亡请求/撤销/落定与冷静期;魂籍版本化导出与校验;命台租约 API(冲突留痕、过期接管、epoch 递增)。 - 验收:启镜前无正式人物 ID;同一人物并发启镜/命台抢占被拒且留痕;魂籍导出可验证、复制不能冒充(签名摘要校验用例)。 +- 证据:`backend/tests/test_jingsheng.py` 7 用例全绿;全量回归 932 passed(20 个预存失败与 M0 时相同,缺 `config/models.yaml`);`make db-upgrade` 空库 000→001 干净应用(含 SQLite batch alter)。 +- 遗留到 M2:随缘灵胚候选生成、角色卡导入对接灵胚、Kernel 前台串行接入 DB 租约(多进程一致)、前端人物页。 ### M2 镜树闭环(核心主线)