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:
197
apps/api/tests/test_project_plan_endpoint.py
Normal file
197
apps/api/tests/test_project_plan_endpoint.py
Normal 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
|
||||
Reference in New Issue
Block a user