Files
writer-work-flow/apps/api/ww_api/routers/generation.py
Yaojia Wang 3868f80502 fix(qa): 修 3 个 QA MEDIUM——规则/档位路由校验收紧
- 规则 GET 不存在 project → 404(原返误导性空 200,掩盖坏 id):list_rules 加
  项目存在校验(仿 C1/H2)。
- 规则 content 纯空白 → 422(原 strip 前校验 min_length,空白行入库成垃圾):
  改 StringConstraints(strip_whitespace=True, min_length=1)。
- 档位路由 tier 限定 writer/analyst/light(Tier Literal)→ 未知档位 422
  (原接受任意字符串)。

回归测试:test_generation(GET rules 404)/ test_rules(空白 content 422)/
test_settings_providers(未知 tier 422)。门禁绿:ruff/format/mypy(210)/pytest。
2026-06-24 17:51:02 +02:00

439 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""生成/入库端点C3 扩 / ARCH §6.5 角色生成器 / §5.4 / §7.2;不变量 #1/#3/#9
三类端点 + 两个读端点M5-d 生成走即时返回:预览 → 作者确认 → 入库,非 jobs
- `POST /projects/:id/world/generate`worldbuilderwriter 网关)→ **预览**世界观实体(不入库)。
- `POST /projects/:id/characters/generate`character-genwriter 网关,群像防雷同)→ 预览角色卡。
- `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`:技能库列表(技能库 UIT5.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)
# 提交边界:网关 ledgerprecheck+ 角色写侧均只 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":[...]}` → 裸 listworldbuilder 形变的逆向)。
"""
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: RulesReadRepoDep,
project_repo: ProjectRepoDep,
) -> RuleListResponse:
"""规则列表(按读侧顺序)。规则页用。项目不存在 → 404QA 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.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
],
)