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

@@ -0,0 +1,197 @@
"""AI 立项方案生成端点测试(灵感⑤;内存替身,无 DB/无网络)。
覆盖 `POST /skills/project-plan/generate`(不带 project 前缀):
- 无既有 project 也能生成(向导阶段 project 未建)→ 200 结构化预览 + 落 ledger。
- 种子门控:缺 genre / logline → 422不触达网关、不 commit。
- 无凭据 → 503。
- 向导草稿序列化进网关 context种子 + 已填字段透传)。
LLM 一律 mock绝不联网。
"""
from __future__ import annotations
import os
from typing import Any
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeSession
from ww_agents import ProjectPlanResult
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
from ww_shared import AppError, ErrorCode
class _CaptureGateway:
"""记录最近一次 req.input验证向导草稿进了 context返固定 ProjectPlanResult。"""
def __init__(self, parsed: ProjectPlanResult) -> None:
self._parsed = parsed
self.last_input: str | None = None
self.calls = 0
async def run(self, req: LlmRequest) -> LlmResponse:
self.calls += 1
self.last_input = req.input if isinstance(req.input, str) else str(req.input)
return LlmResponse(
text=self._parsed.model_dump_json(),
parsed=self._parsed,
usage=Usage(
provider="fake",
model="fake",
input_tokens=1,
output_tokens=1,
cost_minor=0,
currency="USD",
),
served_by=ServedBy(provider="fake", model="fake"),
)
def _plan() -> ProjectPlanResult:
return ProjectPlanResult(
title_candidates=["逐光而行", "剑试九霄"],
setting="上古仙门世界",
narrative_structure="黄金三章立钩 → 升级",
story_core="废柴逆袭封神的代价",
ending_design="归隐收束(正剧)",
tone="热血",
)
def _make_app(
*,
gateway: Any,
session: FakeSession | None = None,
no_creds: bool = False,
) -> tuple[Any, FakeSession]:
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import get_project_plan_gateway
from ww_db import get_session
session = session or FakeSession()
async def _ok() -> Any:
return gateway
async def _no_creds() -> Any:
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
app = create_app()
app.dependency_overrides[get_project_plan_gateway] = _no_creds if no_creds else _ok
app.dependency_overrides[get_session] = lambda: session
return app, session
def _client(app: Any) -> httpx.AsyncClient:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
return httpx.AsyncClient(transport=transport, base_url="http://test")
@pytest.mark.asyncio
async def test_generate_without_existing_project() -> None:
# 向导阶段 project 未建:无 project 前缀端点直接生成,返结构化方案 + 落 ledger。
gateway = _CaptureGateway(_plan())
app, session = _make_app(gateway=gateway)
async with _client(app) as client:
resp = await client.post(
"/skills/project-plan/generate",
json={"genre": "仙侠", "logline": "废柴少年觉醒禁忌血脉,逆势封神。"},
)
assert resp.status_code == 200
body = resp.json()
assert body["title_candidates"] == ["逐光而行", "剑试九霄"]
assert body["story_core"] == "废柴逆袭封神的代价"
assert body["ending_design"].endswith("(正剧)")
assert body["tone"] == "热血"
assert session.commits == 1 # 预览不写业务表,但落 ledger
@pytest.mark.asyncio
async def test_generate_serializes_wizard_draft_into_context() -> None:
# 种子 + 已填字段序列化进网关 context有则贴合
gateway = _CaptureGateway(_plan())
app, _ = _make_app(gateway=gateway)
async with _client(app) as client:
resp = await client.post(
"/skills/project-plan/generate",
json={
"genre": "都市",
"logline": "外卖骑手觉醒读心术。",
"title": "听风者",
"tone": "轻松",
"selling_points": ["逆袭", "系统流"],
"brief": "偏悬疑向",
},
)
assert resp.status_code == 200
assert gateway.last_input is not None
assert "题材:都市" in gateway.last_input
assert "一句话故事:外卖骑手觉醒读心术。" in gateway.last_input
assert "暂定书名:听风者" in gateway.last_input
assert "核心卖点:逆袭、系统流" in gateway.last_input
assert "偏悬疑向" in gateway.last_input # brief 进 context
@pytest.mark.asyncio
async def test_requires_seed_missing_logline_422() -> None:
# 种子门控:缺 logline → 422不触达网关、不 commit。
gateway = _CaptureGateway(_plan())
app, session = _make_app(gateway=gateway)
async with _client(app) as client:
resp = await client.post("/skills/project-plan/generate", json={"genre": "仙侠"})
assert resp.status_code == 422
assert resp.json()["error"]["code"] == ErrorCode.VALIDATION
assert gateway.calls == 0
assert session.commits == 0
@pytest.mark.asyncio
async def test_requires_seed_missing_genre_422() -> None:
gateway = _CaptureGateway(_plan())
app, session = _make_app(gateway=gateway)
async with _client(app) as client:
resp = await client.post("/skills/project-plan/generate", json={"logline": "一句话故事。"})
assert resp.status_code == 422
assert gateway.calls == 0
assert session.commits == 0
@pytest.mark.asyncio
async def test_requires_seed_blank_strings_422() -> None:
# 纯空白种子也拒strip 后为空)。
gateway = _CaptureGateway(_plan())
app, session = _make_app(gateway=gateway)
async with _client(app) as client:
resp = await client.post(
"/skills/project-plan/generate", json={"genre": " ", "logline": " "}
)
assert resp.status_code == 422
assert gateway.calls == 0
assert session.commits == 0
@pytest.mark.asyncio
async def test_generate_no_credentials_503() -> None:
app, session = _make_app(gateway=object(), no_creds=True)
async with _client(app) as client:
resp = await client.post(
"/skills/project-plan/generate",
json={"genre": "仙侠", "logline": "一句话故事。"},
)
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
assert session.commits == 0

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 卡转换 ----

View File

@@ -52,6 +52,64 @@ class ProjectListResponse(BaseModel):
projects: list[ProjectResponse] = Field(default_factory=list)
# ---- AI 立项方案生成(灵感⑤;向导阶段 project 未建,纯预览不写库)----
# 请求字段长度上界CR-H9防超长向导草稿撑爆上下文/成本)。短字段=标签级,长字段=段落级。
_PLAN_SHORT_MAX = 200
_PLAN_LONG_MAX = 4000
_PLAN_LIST_MAX = 20
class ProjectPlanGenerateRequest(BaseModel):
"""POST /skills/project-plan/generate向导草稿种子不带 project 前缀,立项前 project 未建)。
**种子门控**`genre` + `logline` 为必给种子(端点校验非空,否则 422——空向导不出方案
防 slop。其余字段有则贴合、无则不臆造序列化进 project_context仿 `_project_context`)。
全字段带 `max_length` 上界CR-H9`brief` = 作者一句话方向(可空)。
"""
genre: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="题材(种子)")
logline: str | None = Field(
default=None, max_length=_PLAN_LONG_MAX, description="一句话故事(种子)"
)
title: str | None = Field(
default=None, max_length=_PLAN_SHORT_MAX, description="暂定书名(可空)"
)
premise: str | None = Field(default=None, max_length=_PLAN_LONG_MAX, description="立意(可空)")
theme: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="主题(可空)")
structure: str | None = Field(
default=None, max_length=_PLAN_SHORT_MAX, description="故事结构(可空)"
)
tone: str | None = Field(default=None, max_length=_PLAN_SHORT_MAX, description="基调(可空)")
ending_type: str | None = Field(
default=None, max_length=_PLAN_SHORT_MAX, description="结局取向(可空)"
)
narrative_pov: str | None = Field(
default=None, max_length=_PLAN_SHORT_MAX, description="叙事视角(可空)"
)
selling_points: list[str] = Field(
default_factory=list, max_length=_PLAN_LIST_MAX, description="核心卖点(可空)"
)
# `str | None`(非 `str=""`codegen 渲染为可选字段前端种子请求可省略brief 本就可空)。
brief: str | None = Field(
default=None, max_length=_PLAN_LONG_MAX, description="作者一句话方向/需求(可空)"
)
class ProjectPlanView(BaseModel):
"""立项方案生成结果(贴 ww_agents.ProjectPlanResult结构化预览不入库
全字段有默认值(守解析韧性)——前端按字段回填立项向导,只填非空交集、不覆盖作者已编辑值。
"""
title_candidates: list[str] = Field(default_factory=list, description="书名候选清单")
setting: str = Field(default="", description="时空背景")
narrative_structure: str = Field(default="", description="叙事结构")
story_core: str = Field(default="", description="故事核心")
ending_design: str = Field(default="", description="结局设计")
tone: str = Field(default="", description="基调")
class DraftStreamRequest(BaseModel):
"""POST /projects/:id/chapters/:no/draft本章生成的可选输入T4-b

View File

@@ -268,6 +268,14 @@ async def get_precheck_gateway(
return await build_gateway_for_tier(session, store, "analyst")
async def get_project_plan_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""AI 立项方案生成analyst 档位)的可注入网关缝。测试注 mock产 ProjectPlanResult"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst")
def get_session_factory() -> SessionFactory:
"""验收后到期扫描的**独立 session 工厂**缝。