"""生成/入库端点(C3 扩 / ARCH §6.5 角色生成器 / §5.4 / §7.2;不变量 #1/#3/#9)。 三类端点 + 两个读端点(M5-d 生成走即时返回:预览 → 作者确认 → 入库,非 jobs): - `POST /projects/:id/world/generate`:worldbuilder(writer 网关)→ **预览**世界观实体(不入库)。 - `POST /projects/:id/characters/generate`:character-gen(writer 网关,群像防雷同)→ 预览角色卡。 - `POST /projects/:id/characters`:作者确认的角色卡入库——**入库前 gate**: 1. `precheck_generated_cards`(continuity 预检,analyst 网关)比对生成卡 vs 真相源; 有冲突且未 `acknowledge_conflicts` → **409 CONFLICT_UNRESOLVED**(不静默入库,仿 accept gate, 守不变量 #3);作者确认后放行。 2. `partition_writes`:按 character-gen 声明的 `writes` 白名单过滤(越权表丢弃 + 审计),守 §5.6。 3. 写 `characters` 行(schema list/str → DB JSONB dict 形变,见 character_repo)。 - `GET /projects/:id/rules`:规则列表(规则页,T5.6)。 - `GET /skills`:技能库列表(技能库 UI,T5.6)。 提交边界:网关 ledger + 角色写侧均只 flush → 端点末尾一次 `commit()`(生成预览也 commit ledger,否则 usage 静默丢失,同 draft/review 纪律)。无凭据 → `LLM_UNAVAILABLE`(503)。 """ from __future__ import annotations import uuid from typing import Annotated from fastapi import APIRouter, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from ww_agents import ( CharacterCard, CharacterRelation, character_gen_spec, continuity_spec, worldbuilder_spec, ) from ww_core.domain import ( CharacterWriteRepo, ProjectRepo, ) from ww_core.domain.repositories import MemoryRepos, RulesRepo from ww_core.orchestrator import ( precheck_generated_cards, run_character_gen, run_worldbuilder, ) from ww_db import get_session from ww_llm_gateway import Gateway from ww_shared import AppError, ErrorCode from ww_skills import SkillRegistry, partition_writes from ww_api.logging_config import get_logger from ww_api.schemas.generation import ( CharacterCardView, CharacterGenerateRequest, CharacterGenPreviewResponse, CharacterIngestRequest, CharacterIngestResponse, CharacterListResponse, CharacterRelationView, IngestConflictView, RuleListResponse, SkillListResponse, SkillView, WorldEntityCardView, WorldEntityListResponse, WorldGenerateRequest, WorldGenPreviewResponse, ) from ww_api.schemas.rules import RuleView from ww_api.services.credentials import STUB_OWNER_ID from ww_api.services.project_deps import ( get_character_gen_gateway, get_character_write_repo, get_memory_repos, get_precheck_gateway, get_project_repo, get_rules_read_repo, get_skill_registry, get_worldbuilder_gateway, ) log = get_logger("ww.api.generation") router = APIRouter(prefix="/projects", tags=["generation"]) skills_router = APIRouter(prefix="/skills", tags=["skills"]) ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)] CharacterWriteRepoDep = Annotated[CharacterWriteRepo, Depends(get_character_write_repo)] RulesReadRepoDep = Annotated[RulesRepo, Depends(get_rules_read_repo)] SkillRegistryDep = Annotated[SkillRegistry, Depends(get_skill_registry)] WorldGatewayDep = Annotated[Gateway, Depends(get_worldbuilder_gateway)] CharacterGatewayDep = Annotated[Gateway, Depends(get_character_gen_gateway)] PrecheckGatewayDep = Annotated[Gateway, Depends(get_precheck_gateway)] SessionDep = Annotated[AsyncSession, Depends(get_session)] def _project_context(title: str, genre: str | None, premise: str | None, theme: str | None) -> str: """确定性序列化作品设定(喂 worldbuilder;无时间戳/UUID)。""" lines = [f"标题:{title}"] if genre: lines.append(f"题材:{genre}") if premise: lines.append(f"前提:{premise}") if theme: lines.append(f"主题:{theme}") return "\n".join(lines) def _render_characters_context(cards: list[CharacterCard]) -> str: """已有角色简表(喂 character-gen 防雷同 + precheck 真相源)。确定性、保序。""" if not cards: return "" return "\n".join(f"- {c.name}({c.role}):{'、'.join(c.traits) or '(未列)'}" for c in cards) async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> list[CharacterCard]: """把已有角色读侧视图转成 `CharacterCard`(喂防雷同 / precheck)。 DB JSONB dict 列 → schema list/str 反向解包(与写侧形变互逆;缺则空/占位)。 """ views = await memory.character.list_for_project(project_id) cards: list[CharacterCard] = [] for v in views: traits = list((v.traits or {}).get("items", [])) if isinstance(v.traits, dict) else [] tics = ( list((v.speech_tics or {}).get("items", [])) if isinstance(v.speech_tics, dict) else [] ) arc = (v.arc or {}).get("text", "") if isinstance(v.arc, dict) else "" cards.append( CharacterCard( name=v.name, role=v.role or "", traits=traits, backstory=v.backstory or "", arc=arc or "", speech_tics=tics, tags=list(v.tags or []), relations=[], ) ) return cards async def _world_context(memory: MemoryRepos, project_id: uuid.UUID) -> str: """已有世界观硬规则简表(喂 character-gen / precheck 真相源)。""" views = await memory.world_entity.list_for_project(project_id) lines: list[str] = [] for v in views: rules = list((v.rules or {}).get("rules", [])) if isinstance(v.rules, dict) else [] rule_text = ";".join(rules) if rules else "(无硬规则)" lines.append(f"- [{v.type}] {v.name}:{rule_text}") return "\n".join(lines) # ---- 世界观生成(预览,不入库)---- @router.post("/{project_id}/world/generate") async def generate_world( project_id: uuid.UUID, body: WorldGenerateRequest, request: Request, project_repo: ProjectRepoDep, gateway: WorldGatewayDep, session: SessionDep, ) -> WorldGenPreviewResponse: """生成世界观实体预览(不持久化;作者确认后另入库)。无凭据 → 503;项目不存在 → 404。""" request_id = getattr(request.state, "request_id", None) project = await project_repo.get(STUB_OWNER_ID, project_id) if project is None: raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") project_context = _project_context(project.title, project.genre, project.premise, project.theme) result = await run_worldbuilder( worldbuilder_spec, brief=body.brief, project_context=project_context, gateway=gateway, user_id=STUB_OWNER_ID, project_id=project_id, ) # 提交边界:预览不写业务表,但网关 ledger 只 flush → 末尾 commit 落 usage(否则丢失)。 await session.commit() log.info( "world_generate_done", project_id=str(project_id), request_id=request_id, entity_count=len(result.entities), ) return WorldGenPreviewResponse( entities=[ WorldEntityCardView(type=e.type, name=e.name, rules=list(e.rules)) for e in result.entities ] ) # ---- 角色生成(预览,不入库;群像防雷同)---- @router.post("/{project_id}/characters/generate") async def generate_characters( project_id: uuid.UUID, body: CharacterGenerateRequest, request: Request, project_repo: ProjectRepoDep, memory: MemoryReposDep, gateway: CharacterGatewayDep, session: SessionDep, ) -> CharacterGenPreviewResponse: """生成角色卡预览(不持久化;注入已有角色防雷同)。无凭据 → 503;项目不存在 → 404。""" request_id = getattr(request.state, "request_id", None) project = await project_repo.get(STUB_OWNER_ID, project_id) if project is None: raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") existing = await _existing_characters(memory, project_id) world_context = await _world_context(memory, project_id) result = await run_character_gen( character_gen_spec, brief=body.brief, count=body.count, role=body.role, world_context=world_context, existing_chars=existing, generated_so_far=[], gateway=gateway, user_id=STUB_OWNER_ID, project_id=project_id, ) await session.commit() log.info( "characters_generate_done", project_id=str(project_id), request_id=request_id, card_count=len(result.cards), ) return CharacterGenPreviewResponse(cards=[_card_to_view(c) for c in result.cards]) # ---- 角色入库(作者确认后;continuity gate + 权限白名单)---- @router.post("/{project_id}/characters", status_code=201) async def ingest_characters( project_id: uuid.UUID, body: CharacterIngestRequest, request: Request, project_repo: ProjectRepoDep, memory: MemoryReposDep, char_repo: CharacterWriteRepoDep, gateway: PrecheckGatewayDep, session: SessionDep, ) -> CharacterIngestResponse: """入库作者确认的角色卡。有 continuity 冲突且未确认 → 409;越权写表丢弃 + 审计。""" request_id = getattr(request.state, "request_id", None) project = await project_repo.get(STUB_OWNER_ID, project_id) if project is None: raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") cards = [_view_to_card(v) for v in body.cards] # gate 1:入库前 continuity 预检(编排器追加的检查,非 character-gen 互调,守不变量 #1)。 world_context = await _world_context(memory, project_id) existing = await _existing_characters(memory, project_id) conflicts = await precheck_generated_cards( continuity_spec, cards=cards, world_context=world_context, characters_context=_render_characters_context(existing), gateway=gateway, user_id=STUB_OWNER_ID, project_id=project_id, ) if conflicts and not body.acknowledge_conflicts: # 仿 accept 的冲突 gate:不静默入库,须作者裁决/确认(不变量 #3)。 await session.commit() # 落 precheck 网关 usage(否则丢失);不写业务表。 log.info( "characters_ingest_blocked_by_conflicts", project_id=str(project_id), request_id=request_id, conflict_count=len(conflicts), ) raise AppError( ErrorCode.CONFLICT_UNRESOLVED, "生成角色卡与现有设定存在 continuity 冲突,请裁决后确认入库", { "conflicts": [ IngestConflictView( type=c.type, where=c.where, refs=list(c.refs), suggestion=c.suggestion ).model_dump() for c in conflicts ], "conflict_count": len(conflicts), }, ) # gate 2:权限白名单——character-gen 只声明 writes=["characters"],越权产出丢弃 + 审计。 allowed, rejected = partition_writes(character_gen_spec, {"characters": cards}) if rejected: log.warning( "characters_ingest_rejected_over_permission", project_id=str(project_id), request_id=request_id, rejected_tables=rejected, ) created: list[str] = [] for card in allowed.get("characters", []): view = await char_repo.create( project_id, name=card.name, role=card.role, traits=list(card.traits), backstory=card.backstory, arc=card.arc, speech_tics=list(card.speech_tics), tags=list(card.tags), relations=[r.model_dump() for r in card.relations], ) created.append(view.name) # 提交边界:网关 ledger(precheck)+ 角色写侧均只 flush → 端点末尾一次 commit。 await session.commit() log.info( "characters_ingest_done", project_id=str(project_id), request_id=request_id, created_count=len(created), ) return CharacterIngestResponse(created=created, rejected_tables=rejected) # ---- 读端点:规则列表 + 技能库(T5.6 前端)---- @router.get("/{project_id}/characters") async def list_characters( project_id: uuid.UUID, memory: MemoryReposDep, ) -> CharacterListResponse: """已入库角色全量列表(设定库 Codex 真源;复用 C5 读侧 SqlCharacterRepo)。 DB JSONB dict 列 → API list/str 反向解包(入库形变的逆向,见 `_existing_characters`)。 """ cards = await _existing_characters(memory, project_id) return CharacterListResponse(characters=[_card_to_view(c) for c in cards]) @router.get("/{project_id}/world_entities") async def list_world_entities( project_id: uuid.UUID, memory: MemoryReposDep, ) -> WorldEntityListResponse: """已入库世界观实体全量列表(设定库 Codex 真源;复用 C5 读侧 SqlWorldEntityRepo)。 DB `rules` JSONB dict `{"rules":[...]}` → 裸 list(worldbuilder 形变的逆向)。 """ views = await memory.world_entity.list_for_project(project_id) entities = [ WorldEntityCardView( type=v.type, name=v.name, rules=(list((v.rules or {}).get("rules", [])) if isinstance(v.rules, dict) else []), ) for v in views ] return WorldEntityListResponse(world_entities=entities) @router.get("/{project_id}/rules") async def list_rules( project_id: uuid.UUID, repo: RulesReadRepoDep, ) -> RuleListResponse: """规则列表(按读侧顺序)。规则页用。""" rules = await repo.all_for_project(project_id) return RuleListResponse(rules=[RuleView(level=r.level, content=r.content) for r in rules]) @skills_router.get("") async def list_skills(registry: SkillRegistryDep) -> SkillListResponse: """技能库列表(按 name 升序)。技能库 UI 用。""" skills = [ SkillView( name=spec.name, scope=spec.scope, tier=spec.tier, reads=list(spec.reads), writes=list(spec.writes), genre=spec.genre, ) # registry.names() 已排序;按名取 spec 保证确定性顺序。 for spec in (registry.get(name) for name in registry.names()) ] return SkillListResponse(skills=skills) # ---- schema <-> ww_agents 卡转换 ---- def _card_to_view(card: CharacterCard) -> CharacterCardView: return CharacterCardView( name=card.name, role=card.role, traits=list(card.traits), backstory=card.backstory, arc=card.arc, speech_tics=list(card.speech_tics), tags=list(card.tags), relations=[ CharacterRelationView(name=r.name, kind=r.kind, note=r.note) for r in card.relations ], ) def _view_to_card(view: CharacterCardView) -> CharacterCard: return CharacterCard( name=view.name, role=view.role, traits=list(view.traits), backstory=view.backstory, arc=view.arc, speech_tics=list(view.speech_tics), tags=list(view.tags), relations=[ CharacterRelation(name=r.name, kind=r.kind, note=r.note) for r in view.relations ], )