feat: T6.1 创作工具箱通用生成器框架地基 — 脑洞生成器 + run_generator

- schemas: Idea/IdeaListResult(脑洞生成器结构化产出,纯预览不入库)
- specs: brainstorm_spec(light 档,reads=[projects] writes=[])
- orchestrator: build_brief_context(brief_only 策略)+ 通用 run_generator
  (据 spec.output_schema 校验 parsed,不绑定具体类型,任一声明式生成器共用)
- 新增 13 测;两包 ruff/format/mypy 干净、252 passed 无回归

P1 第一波(@llm):证实「加生成器=加一份声明」。@backend T6.2/T6.3 待续。
This commit is contained in:
Yaojia Wang
2026-06-20 11:31:59 +02:00
parent 9b893a6c61
commit e8cccf7389
8 changed files with 308 additions and 1 deletions

View File

@@ -0,0 +1,91 @@
"""T6 通用生成器执行器单测:`run_generator` + `build_brief_context`。
注入 mock 网关(产 IdeaListResult parsed无真 LLM、无真 Postgres。通用执行器据
`spec.output_schema` 校验 parsed不绑定具体类型只产结构化产物**不写库**(纯预览,
不变量 #3。asyncio_mode=auto。
"""
from __future__ import annotations
import uuid
import pytest
from fakes_orchestrator import FailingRunGateway, FakeRunGateway
from ww_agents import Idea, IdeaListResult, brainstorm_spec
from ww_core.orchestrator import build_brief_context, run_generator
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
_IDEAS = IdeaListResult(
ideas=[
Idea(premise="废柴觉醒吞噬瞳", hook="升级要付记忆", genre_fit="玄幻"),
Idea(premise="末世种粮农场主", hook="粮食成硬通货"),
]
)
# ---- run_generator返回结构化产物据 output_schema 校验,只读不写库 ----
async def test_run_generator_returns_structured_result() -> None:
gateway = FakeRunGateway(_IDEAS)
result = await run_generator(
brainstorm_spec,
context=build_brief_context(brief="来点逆袭流", project_context="题材:玄幻"),
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
assert isinstance(result, IdeaListResult)
assert len(result.ideas) == 2
assert result.ideas[0].premise == "废柴觉醒吞噬瞳"
async def test_run_generator_passes_tier_and_schema_to_gateway() -> None:
# 不变量 #2只透传档位light不传 modeloutput_schema 据 spec 绑定
gateway = FakeRunGateway(_IDEAS)
await run_generator(
brainstorm_spec,
context="x",
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
assert gateway.last_request is not None
assert gateway.last_request.tier == "light"
assert gateway.last_request.output_schema is IdeaListResult
async def test_run_generator_raises_on_gateway_failure() -> None:
# 独立生成:网关失败直接上抛(端点处理),不静默吞
gateway = FailingRunGateway(RuntimeError("boom"))
with pytest.raises(RuntimeError, match="boom"):
await run_generator(
brainstorm_spec,
context="x",
gateway=gateway,
user_id=USER,
project_id=PROJECT,
)
# ---- build_brief_context确定性、空需求降级占位 ----
def test_build_brief_context_is_deterministic() -> None:
a = build_brief_context(brief="逆袭", project_context="题材:玄幻")
b = build_brief_context(brief="逆袭", project_context="题材:玄幻")
assert a == b
assert "题材:玄幻" in a
assert "逆袭" in a
def test_build_brief_context_empty_brief_falls_back() -> None:
ctx = build_brief_context(brief=" ", project_context="题材:都市")
assert "按题材自由发散" in ctx

View File

@@ -21,11 +21,13 @@ from .collect import (
extract_style,
)
from .generation_node import (
build_brief_context,
build_character_gen_context,
build_precheck_context,
build_worldbuilder_context,
precheck_generated_cards,
run_character_gen,
run_generator,
run_worldbuilder,
)
from .graph import (
@@ -102,6 +104,7 @@ __all__ = [
"GatewayStream",
"ReviewRecorder",
"SseEvent",
"build_brief_context",
"build_character_gen_context",
"build_outline_request",
"build_precheck_context",
@@ -128,6 +131,7 @@ __all__ = [
"pace_event",
"precheck_generated_cards",
"run_character_gen",
"run_generator",
"run_outline",
"run_review",
"run_style_extraction",

View File

@@ -28,6 +28,7 @@ from collections.abc import Sequence
from typing import Protocol
import structlog
from pydantic import BaseModel
from ww_agents import (
AgentSpec,
CharacterCard,
@@ -73,6 +74,49 @@ def _build_request(
)
# ---- 通用生成器执行器T6 · 创作工具箱通用框架)----
def build_brief_context(*, brief: str, project_context: str) -> str:
"""通用「brief_only」上下文作品设定 + 作者一句话需求(确定性,无时间戳)。
覆盖最简生成器(脑洞/书名…):仅需作品设定 + 一句话方向。需求为空时给降级占位,
交由 system_prompt 的「按题材自由发散」纪律处理。
"""
brief_block = brief.strip() or "(作者未填具体方向,按题材自由发散)"
return f"## 作品设定\n{project_context or '(暂无设定)'}\n\n## 创作需求\n{brief_block}"
async def run_generator(
spec: AgentSpec,
*,
context: str,
gateway: GatewayRun,
user_id: uuid.UUID,
project_id: uuid.UUID,
) -> BaseModel:
"""通用生成器执行器T6构请求 → `gateway.run` → 按 `spec.output_schema` 校验 parsed。
任一声明式生成器共用此路径(不绑定具体产物类型,据 spec 的 output_schema 校验)。
**只读、不写库**(纯预览,不变量 #3独立生成——网关失败直接上抛端点处理不静默吞。
"""
expected = spec.output_schema
if expected is None:
raise ValueError(f"generator spec {spec.name!r} has no output_schema")
req = _build_request(spec, context=context, user_id=user_id, project_id=project_id)
resp = await gateway.run(req)
parsed = resp.parsed
if not isinstance(parsed, expected):
log.error(
"generator_node_missing_parsed",
spec=spec.name,
project_id=str(project_id),
parsed_type=type(parsed).__name__,
)
raise ValueError(f"gateway returned no parsed {expected.__name__} for {spec.name} request")
return parsed
# ---- worldbuilder ----