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:
@@ -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 卡转换 ----
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user