feat(web): AI 立项方案生成+预填(种子门控 + 按字段接受)

向导阶段(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。
This commit is contained in:
Yaojia Wang
2026-07-06 16:26:08 +02:00
parent 821ace5989
commit 02d19019f6
22 changed files with 1187 additions and 9 deletions

View File

@@ -26,8 +26,10 @@ 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 (
@@ -37,8 +39,10 @@ from ww_core.domain import (
)
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
@@ -64,6 +68,7 @@ from ww_api.schemas.generation import (
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 (
@@ -71,6 +76,7 @@ from ww_api.services.project_deps import (
get_character_write_repo,
get_memory_repos,
get_precheck_gateway,
get_project_plan_gateway,
get_project_repo,
get_rule_write_repo,
get_skill_registry,
@@ -90,6 +96,7 @@ 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)]
@@ -105,6 +112,46 @@ def _project_context(title: str, genre: str | None, premise: str | None, 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:
@@ -430,6 +477,53 @@ async def list_skills(registry: SkillRegistryDep) -> SkillListResponse:
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 卡转换 ----