Files
chenxin b419e0afde M7:形仪与语音——真形锚点、候选定相、声音档案与克隆授权、ASR 接入
- 形仪域 domains/xingyi:form_anchors(稳定辨识特征/年龄表现/许可禁忌/可变范围——生成内容不可静默覆盖锚点);forms(主仪/化仪/文字形仪,candidate→adopt 定相治理:未定相不进呈现位、主仪唯一、新主仪自动退役旧主仪);voice_profiles(三来源,授权克隆必填用途与传播范围,revoke-grant 撤回立即生效,assert-usable 合成前置校验拒绝撤回/过期)
- ASR:元炁 SenseVoice /v1/audio/transcriptions 透传(Whisper 兼容 multipart);YUANQI_BASE_URL 未配置时 503 不降级不伪造——多模态关闭纪律
- 前端形仪工坊(人物详情页):真形锚点编辑(特征逐行解析)/候选登记定相废弃/声音档案建立与授权撤回;头像呈现位改走定相主仪(forms/primary);xingyi api 库 + mock + 组件 4 用例
- 退役:旧 avatar 模块(services/endpoints/avatar、avatar_dir 配置、avatar_url 字段、前端直传组件与测试)
- 验收(test_m7_xingyi 三用例):未定相不成为正式形仪(含主仪唯一与旧主仪 retired 断言);克隆授权可撤回(缺用途拒/撤回即拒/过期同理/预设不受约束);关闭全部多模态后文字相伴完整可用(ASR 503 时镜树缘契百工照常——回归)
- 四套件:后端 376、前端 203、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 008 净库+±1 干净
2026-08-27 20:26:52 +08:00

158 lines
5.3 KiB
Python

"""形仪 API:真形锚点 / 形仪候选定相 / 声音档案与克隆授权 / ASR 转写。"""
from datetime import datetime
from fastapi import APIRouter, Depends, File, UploadFile, status
from pydantic import BaseModel, Field
from app.api.v1.deps import get_current_user
from app.domains.xingyi import service
from app.schemas.auth import CurrentUser
form_router = APIRouter(prefix="/characters/{character_id}", tags=["xingyi"])
class AnchorUpsertRequest(BaseModel):
features: dict = Field(default_factory=dict, description="稳定辨识特征")
age_presentation: str = "adult"
permissions: dict = Field(default_factory=dict)
mutable_scope: dict = Field(default_factory=dict)
class FormCreateRequest(BaseModel):
kind: str = "costume"
source: str = "upload"
description: str = Field(min_length=1, max_length=4000)
media_ref: str | None = None
live2d: dict | None = None
class VoiceCreateRequest(BaseModel):
source: str
name: str = Field(min_length=1, max_length=120)
params: dict = Field(default_factory=dict)
grant_purpose: str | None = None
grant_expires_at: datetime | None = None
grant_distribution: str | None = None
# ── 真形锚点 ──
@form_router.get("/form-anchor")
async def get_anchor(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.get_anchor(character_id, user.owner_scope)
@form_router.put("/form-anchor")
async def upsert_anchor(
character_id: str, payload: AnchorUpsertRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.upsert_anchor(
character_id, user.owner_scope,
features=payload.features, age_presentation=payload.age_presentation,
permissions=payload.permissions, mutable_scope=payload.mutable_scope,
)
# ── 形仪候选与定相 ──
@form_router.get("/forms", status_code=status.HTTP_200_OK)
async def list_forms_api(
character_id: str, user: CurrentUser = Depends(get_current_user), status_filter: str | None = None
) -> list[dict]:
return await service.list_forms(character_id, user.owner_scope, status=status_filter)
@form_router.post("/forms", status_code=status.HTTP_201_CREATED)
async def create_form_api(
character_id: str, payload: FormCreateRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.create_form(
character_id, user.owner_scope,
kind=payload.kind, source=payload.source, description=payload.description,
media_ref=payload.media_ref, live2d=payload.live2d,
)
@form_router.get("/forms/primary")
async def current_primary_api(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict | None:
return await service.current_primary(character_id, user.owner_scope)
@form_router.post("/forms/{form_id}/adopt")
async def adopt_form_api(
character_id: str, form_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.adopt_form(form_id, user.owner_scope)
@form_router.post("/forms/{form_id}/discard")
async def discard_form_api(
character_id: str, form_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.discard_form(form_id, user.owner_scope)
# ── 声音档案 ──
@form_router.get("/voices")
async def list_voices_api(
character_id: str, user: CurrentUser = Depends(get_current_user)
) -> list[dict]:
return await service.list_voices(character_id, user.owner_scope)
@form_router.post("/voices", status_code=status.HTTP_201_CREATED)
async def create_voice_api(
character_id: str, payload: VoiceCreateRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.create_voice(
character_id, user.owner_scope,
source=payload.source, name=payload.name, params=payload.params,
grant_purpose=payload.grant_purpose, grant_expires_at=payload.grant_expires_at,
grant_distribution=payload.grant_distribution,
)
@form_router.post("/voices/{voice_id}/revoke-grant")
async def revoke_voice_grant_api(
character_id: str, voice_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.revoke_voice_grant(voice_id, user.owner_scope)
@form_router.post("/voices/{voice_id}/assert-usable")
async def assert_voice_usable_api(
character_id: str, voice_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
"""合成前置校验(授权克隆:未撤回且未过期才可用)。"""
return await service.assert_voice_usable(voice_id, user.owner_scope)
# ── ASR 转写(元炁 /v1/audio/transcriptions 透传) ──
@form_router.post("/voice/transcriptions")
async def transcribe_audio_api(
character_id: str,
user: CurrentUser = Depends(get_current_user),
audio: UploadFile = File(...),
) -> dict:
"""语音转文本(元炁 SenseVoice)。转写文本由前端作为镜树问节内容提交。
多模态关闭(YUANQI 未启用)时本端点 503——文字相伴链路不受影响(回归由
测试钉死:关闭全部多模态后镜树/缘契照常)。
"""
from app.domains.xingyi.asr import transcribe
await service._owned(character_id, user.owner_scope)
content = await audio.read()
return await transcribe(content, audio.filename or "audio.webm")