M1:镜生域后端闭环——灵胚/照心试演/启镜原子闸门/执镜关系/命台租约/魂籍签名导出/死亡冷静期(迁移 001,测试 7 用例)
This commit is contained in:
1 parent
c090893e63
commit
1cd0069d65
24 files changed
+1508
-9
No files matched your search
@@ -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 ###
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,超时按拒绝处理;
|
||||
|
||||
@@ -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_")
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""六域领域层(rewrite/1.0)。镜生域先行,其余域随里程碑落地。"""
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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="本次运行使用的逻辑模型名或能力槽位。")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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 镜树(灵台域·对话)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 镜树闭环(核心主线)
|
||||
|
||||
|
||||
Reference in new issue
Block a user