M1:镜生域后端闭环——灵胚/照心试演/启镜原子闸门/执镜关系/命台租约/魂籍签名导出/死亡冷静期(迁移 001,测试 7 用例)
This commit is contained in:
1 parent
c090893e63
commit
1cd0069d65
24 files changed
+1508
-9
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user