向导阶段(project 未建)一键推演结构化书级蓝图预填立项向导。 - 新 SPEC project_plan_spec(analyst 档,纯预览 reads/writes=())+ schema ProjectPlanResult(书名候选 + 时空背景/叙事结构/故事核心/结局设计/基调, 全字段默认值守解析韧性)+ 注册 SCHEMA_CATALOG;计数三处 21→22 + regen golden。 - 端点 POST /skills/project-plan/generate(不带 project 前缀,绕开 toolbox 404): 请求体序列化向导草稿为 project_context,复用 build_brief_context + run_generator; 种子门控(缺 genre/logline→422);请求 schema 加 max_length 上界。 - run_generator/_build_request 的 project_id 放宽 uuid.UUID|None(向导无 project)。 - 前端 wizard.ts 加 mapPlanResultToWizardForm(错配字段折进 premise 不静默丢)+ applyPlanPatch(仅回填空字段、保护已编辑)+ useProjectPlan hook + PlanAssistant。
562 lines
21 KiB
Python
562 lines
21 KiB
Python
"""生成/入库端点(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 (
|
||
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,
|
||
)
|
||
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),
|
||
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],
|
||
)
|
||
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,
|
||
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
|
||
],
|
||
)
|