"""生成/入库端点(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, Any from fastapi import APIRouter, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from ww_agents import ( CharacterCard, CharacterRelation, ProjectPlanResult, character_gen_spec, continuity_spec, project_plan_spec, worldbuilder_spec, ) from ww_core.domain import ( CharacterWriteFields, CharacterWriteRepo, ProjectRepo, RuleWriteRepo, ) from ww_core.domain.repositories import MemoryRepos from ww_core.orchestrator import ( build_brief_context, precheck_generated_cards, run_character_gen, run_generator, 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.projects import ProjectPlanGenerateRequest, ProjectPlanView 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_plan_gateway, get_project_repo, get_rule_write_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)] RuleRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_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)] ProjectPlanGatewayDep = Annotated[Gateway, Depends(get_project_plan_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 _plan_seed_context(body: ProjectPlanGenerateRequest) -> str: """把立项向导草稿序列化为作品种子(喂 project-plan;仿 `_project_context`)。 确定性、无时间戳/UUID;只拼非空字段(有则贴合、无则不臆造)。 genre + logline 由端点种子门控保证非空。 """ lines: list[str] = [] label_fields: list[tuple[str, str | None]] = [ ("暂定书名", body.title), ("题材", body.genre), ("一句话故事", body.logline), ("立意", body.premise), ("主题", body.theme), ("故事结构", body.structure), ("基调", body.tone), ("结局取向", body.ending_type), ("叙事视角", body.narrative_pov), ] for label, value in label_fields: text = (value or "").strip() if text: lines.append(f"{label}:{text}") points = [p.strip() for p in body.selling_points if p.strip()] if points: lines.append(f"核心卖点:{'、'.join(points)}") return "\n".join(lines) def _plan_to_view(plan: ProjectPlanResult) -> ProjectPlanView: """ww_agents.ProjectPlanResult → API ProjectPlanView(字段同名直传)。""" return ProjectPlanView( title_candidates=list(plan.title_candidates), setting=plan.setting, narrative_structure=plan.narrative_structure, story_core=plan.story_core, ending_design=plan.ending_design, tone=plan.tone, ) 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) def _relations_from_jsonb(raw: list[Any]) -> list[CharacterRelation]: """`characters.relations` JSONB list(每条 {name, kind, note?})→ schema CharacterRelation。 跳过缺 name/kind 的脏条目(历史/外部数据不可信,守输入校验边界)。 """ out: list[CharacterRelation] = [] for item in raw or []: if not isinstance(item, dict): continue name = item.get("name") kind = item.get("kind") if not name or not kind: continue out.append(CharacterRelation(name=str(name), kind=str(kind), note=item.get("note"))) return out async def _existing_characters(memory: MemoryRepos, project_id: uuid.UUID) -> list[CharacterCard]: """把已有角色读侧视图转成 `CharacterCard`(喂防雷同 / precheck / 设定库读端点)。 DB JSONB dict 列 → schema list/str 反向解包(与写侧形变互逆;缺则空/占位)。 `relations` 从 JSONB list 还原(设定库 Codex 需展示关系网;precheck 不读此字段,无害)。 """ 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, appearance=v.appearance or "", motive=v.motive or "", backstory=v.backstory or "", arc=arc or "", speech_tics=tics, tags=list(v.tags or []), relations=_relations_from_jsonb(list(v.relations or [])), ) ) 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, role_mix=body.role_mix, ) 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, ) fields = [ CharacterWriteFields( name=card.name, role=card.role, traits=list(card.traits), appearance=card.appearance, motive=card.motive, 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], ) for card in allowed.get("characters", []) ] # 批量 upsert(一次 flush 落全部),保序返回逐卡 View。 created = [view.name for view in await char_repo.create_many(project_id, fields)] # 提交边界:网关 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, project_repo: ProjectRepoDep, ) -> CharacterListResponse: """已入库角色全量列表(设定库 Codex 真源;复用 C5 读侧 SqlCharacterRepo)。 项目不存在 → 404(与写端点一致,避免不存在 project 返回误导性空 200)。 DB JSONB dict 列 → API list/str 反向解包(入库形变的逆向,见 `_existing_characters`)。 """ if await project_repo.get(STUB_OWNER_ID, project_id) is None: raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found") 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, project_repo: ProjectRepoDep, ) -> WorldEntityListResponse: """已入库世界观实体全量列表(设定库 Codex 真源;复用 C5 读侧 SqlWorldEntityRepo)。 项目不存在 → 404(与写端点一致)。 DB `rules` JSONB dict `{"rules":[...]}` → 裸 list(worldbuilder 形变的逆向)。 """ if await project_repo.get(STUB_OWNER_ID, project_id) is None: raise AppError(ErrorCode.NOT_FOUND, f"project {project_id} not found") 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: RuleRepoDep, project_repo: ProjectRepoDep, ) -> RuleListResponse: """规则列表(带 id,供前端删除 handle)。项目不存在 → 404(QA MEDIUM:此前返误导性空 200)。""" if await project_repo.get(STUB_OWNER_ID, project_id) is None: raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") rules = await repo.list_for_project(project_id) return RuleListResponse( rules=[RuleView(id=r.id, 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) # ---- AI 立项方案生成(灵感⑤;不带 project 前缀,立项前 project 未建)---- @skills_router.post("/project-plan/generate") async def generate_project_plan( body: ProjectPlanGenerateRequest, request: Request, gateway: ProjectPlanGatewayDep, session: SessionDep, ) -> ProjectPlanView: """生成结构化立项方案预览(向导阶段,project 未建,不入库)。 **不带 project 前缀**——绕开 toolbox 的 `project_repo.get→404`(立项前无 project)。 **种子门控**:缺 genre 或 logline → 422(空向导不出方案,防 slop)。无凭据 → 503。 """ request_id = getattr(request.state, "request_id", None) genre = (body.genre or "").strip() logline = (body.logline or "").strip() if not genre or not logline: raise AppError( ErrorCode.VALIDATION, "生成立项方案至少需要「题材」与「一句话故事(logline)」两个种子", {"has_genre": bool(genre), "has_logline": bool(logline)}, ) project_context = _plan_seed_context(body) context = build_brief_context(brief=body.brief or "", project_context=project_context) parsed = await run_generator( project_plan_spec, context=context, gateway=gateway, user_id=STUB_OWNER_ID, project_id=None, # 向导阶段 project 未建;usage_ledger.project_id 可空 ) # 提交边界:预览不写业务表,但网关 ledger 只 flush → 末尾 commit 落 usage(否则丢失)。 await session.commit() if not isinstance(parsed, ProjectPlanResult): # run_generator 已按 output_schema 校验;防御性收窄(不应触达)。 raise AppError(ErrorCode.INTERNAL, "立项方案生成返回类型异常") log.info( "project_plan_generate_done", request_id=request_id, title_count=len(parsed.title_candidates), ) return _plan_to_view(parsed) # ---- schema <-> ww_agents 卡转换 ---- def _card_to_view(card: CharacterCard) -> CharacterCardView: return CharacterCardView( name=card.name, role=card.role, traits=list(card.traits), appearance=card.appearance, motive=card.motive, 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), appearance=view.appearance, motive=view.motive, 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 ], )