From 44642c37b4b44f71c3beee345e9e2c99f8f715a9 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 23 Jun 2026 19:49:00 +0200 Subject: [PATCH 1/8] =?UTF-8?q?docs(backlog):=20=E7=BB=AD=E8=A1=A5?= =?UTF-8?q?=E4=B8=89=E4=BB=B6=E8=AE=BE=E8=AE=A1=E5=A5=91=E7=BA=A6=EF=BC=88?= =?UTF-8?q?=E6=8B=86=E4=B9=A6=E5=85=A5=E5=BA=93/=E7=BB=AD=E5=86=99?= =?UTF-8?q?=E5=BC=8F=E9=93=BE/=E6=A8=A1=E6=9D=BF=E5=BA=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/backlog-followups.md | 54 ++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/design/backlog-followups.md diff --git a/docs/design/backlog-followups.md b/docs/design/backlog-followups.md new file mode 100644 index 0000000..40c1923 --- /dev/null +++ b/docs/design/backlog-followups.md @@ -0,0 +1,54 @@ +# 设计契约 · 续补三件(拆书入库 / 续写式链 / 模板库) + +> 分支 `feat/backlog-followups` · 实施前唯一契约源。Agent 先读本文 + CLAUDE.md 不变量。范围 = 用户选定「全部可建的续补」(排除多租户/市场/push/browse/K1)。 + +## Context +Scope B 已合并(链 UI + 4 生成器)。本批补三个**贴现有框架**的续补功能:把竞品对标的剩余可建项做完。守不变量 #1/#2/#3/#9;preview→ingest 经验收闸;单用户原型(owner_id stub)。 + +--- + +## F1 · 拆书落库成 rules(teardown → rules ingest) +现状:teardown 生成器是 preview-only。目标:可把拆书结论入库为项目 `rules`,供写章注入。 +- **@llm**(packages/agents):`teardown_spec.writes=["rules"]`(其余不动;只声明 tier #2)。 +- **@backend**(packages/skills + apps/api): + - `skill_permissions.KNOWN_TABLES` 加 `"rules"`(确认未在则加)。 + - `toolbox_registry` teardown entry 加 `ingest=IngestSpec(table="rules")`。 + - `routers/toolbox.py`:在通用 ingest dispatcher(现 `_ingest_world_entities`/`_ingest_outline`,:344-365 的 "其余表未实现" 分支)加 `_ingest_rules` handler——把 `BookTeardownResult`(themes/archetypes/structure/hooks) 转成 `rules` 行(scope=project 级,复用既有 `rules` repo/表;规则文本由结构化字段拼成可读条目)。`partition_writes` 白名单已据 spec.writes 放行;rules 无需 continuity 预检(meta 规则非设定卡)→ ingest 直接落库(仿 `_ingest_outline` 无 409 路径)。 +- **@frontend**(apps/web):teardown 现 output_kind 已可预览;因 registry 加了 ingest,`GeneratorRunner` 的入库分支应自动出现「入库为规则」按钮(复用既有 ingest UI)。确认渲染 + `pnpm gen:api`。 +- **@qa**:ingest E2E(teardown generate→ingest→`rules` 真落行;负向:预览仍不写库)。 +- DoD:后端门禁绿;teardown 可入 rules;不变量 #3(入库经白名单)守住。 + +## F2 · 续写式链(continue_volume chain) +现状:链只有 `draft_volume`(从 start 章按记忆写)。目标:新增 `continue_volume`——每章写作以**上一章 accepted 正文末尾**作前文引子(复用 `build_continuation_context`),比纯 digest 续写更顺。 +- **@llm**(packages/core/orchestrator/chain):`write_chapter` 节点支持「续写模式」——据 state 的 `chain_key`,`continue_volume` 时调 `build_continuation_context(prior_text=上一章 accepted 正文)`(经注入的 reader,仿现有 accept_op 注入;core 不 import apps/api);`draft_volume` 保持原行为。ChainState 加 `chain_key`(已有则复用)。 +- **@backend**(apps/api):`SUPPORTED_CHAINS`(chain.py:63)加 `"continue_volume"`;run 端点据 chain_key 选模式;chain_runner 注入「读上一章 accepted 正文」的 reader 给图。 +- **@frontend**(apps/web):`ChainStarter` 加链类型选择(draft_volume=从头写 / continue_volume=续写),传 chain_key。 +- **@qa**:E2E(continue_volume 两章:第二章请求上下文含第一章正文末尾;其余同链 E2E 范式,mock 网关零 token)。 +- DoD:两种链都可跑;续写模式前文注入经 E2E 断言;守不变量 #1/#5。 + +## F3 · 提示词/模板库(单用户本地版) +现状:无。目标:作者保存/复用提示词模板,可一键填入生成器的 brief/text。**不做分享/市场**(需多租户)。 +- **@db**(packages/db):新表 `prompt_templates`(`UuidPk`+`CreatedAt`+`Base` mixin;列:`owner_id`(stub)、`title:str`、`body:str`、`category:str|None`、`tool_key:str|None`(可选关联生成器))+ alembic 迁移。`alembic check` 无漂移。 +- **@backend**(packages/core/domain + apps/api):`TemplateRepo`/`SqlTemplateRepo`(list/create/delete,owner stub 过滤)+ `routers/templates.py`(`GET/POST/DELETE /templates`,POST 校验 title/body 非空→422)+ `schemas/templates.py` + main 注册。 +- **@frontend**(apps/web):`pnpm gen:api`;模板库页 `app/templates/page.tsx`(列表/新建/删除)+ nav 入口;生成器 Runner 可选「从模板填入」(把模板 body 填进 brief/text 输入)。 +- **@qa**:E2E(创建→列出→删除 真 pg;title 空→422)。 +- DoD:模板 CRUD 可用 + 可填入生成器;后端+前端门禁绿。 + +--- + +## Workflow 结构(顺序建造避免共享树写冲突 → 交叉评审 → 全门禁) +- Phase F1(1-2 agent)→ Phase F2(@llm+@backend 顺序 + @frontend)→ Phase F3(@db→@backend→@frontend,+@qa)→ 各 feature E2E。 +- 交叉评审(并行只读):python / fastapi / typescript / 不变量。 +- 全门禁(并行独立复跑):后端 ruff/format/mypy/alembic check/pytest · 前端 lint/tsc/vitest/build。 +- 我汇总 review;CRITICAL/HIGH → 派 fix agent 复绿再合并。 + +## 验证 +- 后端:`uv run ruff check . && uv run mypy packages apps && uv run alembic check && uv run pytest -q`(pg 在跑)。 +- 前端:`cd apps/web && pnpm gen:api && pnpm lint && pnpm typecheck && pnpm test && pnpm build`。 +- 实景(app 在 localhost:3000/8000,需 provider key):teardown 可入 rules;链可选 continue_volume;模板库可建/填入。 + +## 风险/取舍 +- F1 rules 入库:rules 表结构若与拆书结构化字段不匹配,则拍平为可读规则文本(一条 teardown=一/多条 rule),E2E 断真落行即可。 +- F2 续写 reader 跨层:端点读 chapter accepted 正文→注入图节点(仿 accept_op,core 不 import apps/api)。 +- F3 模板填入生成器:前端把模板 body 写进现有 brief/text 输入即可,不改生成器后端。 +- owner_id 全程 stub(单用户原型);分享/市场不做。 From 61398a1452ec3b536811ed54764bdbcacf63058b Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 23 Jun 2026 20:00:25 +0200 Subject: [PATCH 2/8] =?UTF-8?q?feat(toolbox):=20F1=20=E6=8B=86=E4=B9=A6=20?= =?UTF-8?q?teardown=20=E8=90=BD=E5=BA=93=E6=88=90=E9=A1=B9=E7=9B=AE=20rule?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - teardown_spec.writes=["rules"](只声明 tier,入库仍经白名单 gate,不变量 #3) - toolbox_registry teardown entry 加 ingest=IngestSpec(table="rules") - routers/toolbox.py 通用 ingest dispatcher 加 _ingest_rules: BookTeardownResult(themes/archetypes/structure/hooks) 拍平为可读 rules 条目, 落项目级(无 continuity 预检,仿 _ingest_outline 无 409) - schemas/toolbox.py 加 TeardownIngestView + ToolIngestRequest.teardown - 单测:teardown ingest→rules 真落行 + 预览(generate)不写库 后端门禁绿:ruff/format/mypy clean · alembic no drift · pytest 644 passed --- apps/api/tests/test_toolbox_endpoints.py | 92 ++++++++++++++++++- apps/api/ww_api/routers/toolbox.py | 84 +++++++++++++++++ apps/api/ww_api/schemas/toolbox.py | 15 ++- .../agents/tests/test_competitor_specs.py | 26 ++++-- packages/agents/ww_agents/schemas.py | 4 +- packages/agents/ww_agents/specs.py | 2 +- .../skills/tests/test_toolbox_registry.py | 7 +- packages/skills/ww_skills/toolbox_registry.py | 3 +- 8 files changed, 216 insertions(+), 17 deletions(-) diff --git a/apps/api/tests/test_toolbox_endpoints.py b/apps/api/tests/test_toolbox_endpoints.py index 06d839d..105e767 100644 --- a/apps/api/tests/test_toolbox_endpoints.py +++ b/apps/api/tests/test_toolbox_endpoints.py @@ -31,6 +31,7 @@ from ww_core.domain.chapter_repo import ChapterDraftView, ChapterView from ww_core.domain.outline_write_repo import OutlineWriteView from ww_core.domain.project_repo import ProjectCreate from ww_core.domain.repositories import OutlineView +from ww_core.domain.rule_repo import RuleWriteView from ww_core.domain.world_entity_repo import WorldEntityWriteView from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage from ww_shared import AppError, ErrorCode @@ -94,6 +95,17 @@ class _FakeOutlineWriteRepo: ) +class _FakeRuleWriteRepo: + """拆书入库 rules 用的内存替身:按 (level, content) 收行(只 flush 语义)。""" + + def __init__(self) -> None: + self.rows: list[dict[str, Any]] = [] + + async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView: + self.rows.append({"project_id": project_id, "level": level, "content": content}) + return RuleWriteView(project_id=project_id, level=level, content=content) + + class _FakeOutlineReadRepo: """续写/按章工具读节拍用的内存替身:缺章 → None(端点降级到空节拍)。""" @@ -199,6 +211,7 @@ def _make_app( outline_write_repo: _FakeOutlineWriteRepo | None = None, chapter_repo: _FakeChapterRepo | None = None, outline_read_repo: _FakeOutlineReadRepo | None = None, + rule_write_repo: _FakeRuleWriteRepo | None = None, memory: Any = None, no_creds: bool = False, ) -> tuple[Any, FakeSession, _FakeWorldWriteRepo, _FakeOutlineWriteRepo]: @@ -210,6 +223,7 @@ def _make_app( get_outline_read_repo, get_outline_write_repo, get_project_repo, + get_rule_write_repo, get_tier_gateway_builder, get_world_entity_write_repo, ) @@ -220,6 +234,7 @@ def _make_app( outline_write_repo = outline_write_repo or _FakeOutlineWriteRepo() chapter_repo = chapter_repo or _FakeChapterRepo() outline_read_repo = outline_read_repo or _FakeOutlineReadRepo() + rule_write_repo = rule_write_repo or _FakeRuleWriteRepo() async def _build_ok(_tier: str) -> Any: return gateway @@ -236,6 +251,7 @@ def _make_app( app.dependency_overrides[get_outline_write_repo] = lambda: outline_write_repo app.dependency_overrides[get_chapter_repo] = lambda: chapter_repo app.dependency_overrides[get_outline_read_repo] = lambda: outline_read_repo + app.dependency_overrides[get_rule_write_repo] = lambda: rule_write_repo app.dependency_overrides[get_session] = lambda: session app.dependency_overrides[get_tier_gateway_builder] = lambda: ( _build_no_creds if no_creds else _build_ok @@ -285,10 +301,13 @@ async def test_list_toolbox_contains_all_keys() -> None: "teardown", } by_key = {t["key"]: t for t in tools} - # 4 个竞品生成器:非 legacy、纯预览(不可入库)。 - for key in ("continue", "expand", "de-ai", "teardown"): + # 续写/扩写/降AI:非 legacy、纯预览(不可入库)。 + for key in ("continue", "expand", "de-ai"): assert by_key[key]["is_legacy"] is False assert by_key[key]["ingestable"] is False + # F1:拆书非 legacy、可入库(落 rules)。 + assert by_key["teardown"]["is_legacy"] is False + assert by_key["teardown"]["ingestable"] is True # legacy 携 legacy_route + is_legacy;新工具 ingestable 标记。 assert by_key["worldbuilding"]["is_legacy"] is True assert by_key["worldbuilding"]["legacy_route"] @@ -622,3 +641,72 @@ async def test_ingest_outline_upserts_scenes() -> None: assert outline_repo.rows[0]["beats"] == ["主角觉醒", "初遇反派"] assert outline_repo.rows[0]["chapter_no"] == 3 assert session.commits == 1 + + +# ---- POST .../ingest(F1:拆书落库成 rules)---- + + +def _teardown_payload() -> dict[str, Any]: + return { + "teardown": { + "themes": ["逆袭", "复仇"], + "archetypes": ["废柴主角", "腹黑反派"], + "structure": "黄金三章立钩→铺垫→爆发", + "hooks": ["开局即巅峰", "扮猪吃虎"], + } + } + + +@pytest.mark.asyncio +async def test_ingest_teardown_writes_rules_201() -> None: + # F1:拆书结论拍平为可读 rules 条目落库(无 continuity 预检,仿 outline 无 409)。 + repo = FakeProjectRepo() + pid = await _seed_project(repo) + rule_repo = _FakeRuleWriteRepo() + gateway = _SchemaRoutingGateway({}) + app, session, *_ = _make_app(project_repo=repo, gateway=gateway, rule_write_repo=rule_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/skills/teardown/ingest", json=_teardown_payload() + ) + + assert resp.status_code == 201 + body = resp.json() + assert body["table"] == "rules" + assert body["rejected_tables"] == [] + # 真落行:themes/archetypes/structure/hooks 各拼成可读规则条目。 + assert len(rule_repo.rows) >= 1 + contents = "\n".join(r["content"] for r in rule_repo.rows) + assert "逆袭" in contents + assert "废柴主角" in contents + assert "黄金三章立钩" in contents + assert "开局即巅峰" in contents + # 全部落到 project 级 rules(owner stub 单用户)。 + for r in rule_repo.rows: + assert r["level"] == "project" + assert r["project_id"] == pid + assert session.commits == 1 + + +@pytest.mark.asyncio +async def test_teardown_generate_preview_does_not_write_rules() -> None: + # 负向:预览(generate)只产结构化产物,绝不写 rules(不变量 #3:入库经 ingest gate)。 + repo = FakeProjectRepo() + pid = await _seed_project(repo) + rule_repo = _FakeRuleWriteRepo() + parsed = BookTeardownResult( + themes=["逆袭"], archetypes=["废柴主角"], structure="立钩", hooks=["开局巅峰"] + ) + gateway = _CaptureGateway(parsed) + app, _, *_ = _make_app(project_repo=repo, gateway=gateway, rule_write_repo=rule_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/skills/teardown/generate", + json={"kind": "某爆款", "text": "样本章节正文……"}, + ) + + assert resp.status_code == 200 + assert resp.json()["output_kind"] == "BookTeardownResult" + assert rule_repo.rows == [] # 预览不写库 diff --git a/apps/api/ww_api/routers/toolbox.py b/apps/api/ww_api/routers/toolbox.py index a4d3ef4..c7b754a 100644 --- a/apps/api/ww_api/routers/toolbox.py +++ b/apps/api/ww_api/routers/toolbox.py @@ -24,6 +24,7 @@ from ww_core.domain.chapter_repo import ChapterRepo from ww_core.domain.outline_write_repo import OutlineWriteRepo from ww_core.domain.project_repo import ProjectRepo from ww_core.domain.repositories import MemoryRepos, OutlineRepo +from ww_core.domain.rule_repo import RuleWriteRepo from ww_core.domain.world_entity_repo import WorldEntityWriteRepo from ww_core.orchestrator import run_generator from ww_db import get_session @@ -37,6 +38,7 @@ from ww_api.routers.generation import _project_context, _world_context from ww_api.schemas.generation import IngestConflictView, WorldEntityCardView from ww_api.schemas.toolbox import ( OutlineSceneIngestView, + TeardownIngestView, ToolboxListResponse, ToolDescriptorView, ToolGeneratePreviewResponse, @@ -53,6 +55,7 @@ from ww_api.services.project_deps import ( get_outline_read_repo, get_outline_write_repo, get_project_repo, + get_rule_write_repo, get_tier_gateway_builder, get_world_entity_write_repo, ) @@ -79,6 +82,7 @@ OutlineReadRepoDep = Annotated[OutlineRepo, Depends(get_outline_read_repo)] ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)] WorldWriteRepoDep = Annotated[WorldEntityWriteRepo, Depends(get_world_entity_write_repo)] OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)] +RuleWriteRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_repo)] GatewayBuilderDep = Annotated[TierGatewayBuilder, Depends(get_tier_gateway_builder)] SessionDep = Annotated[AsyncSession, Depends(get_session)] @@ -316,6 +320,7 @@ async def ingest_with_tool( memory: MemoryReposDep, world_write_repo: WorldWriteRepoDep, outline_write_repo: OutlineWriteRepoDep, + rule_write_repo: RuleWriteRepoDep, build_gateway: GatewayBuilderDep, session: SessionDep, ) -> ToolIngestResponse: @@ -362,6 +367,16 @@ async def ingest_with_tool( request_id=request_id, tool_key=tool_key, ) + if table == "rules": + return await _ingest_rules( + spec, + body=body, + project_id=project_id, + rule_write_repo=rule_write_repo, + session=session, + request_id=request_id, + tool_key=tool_key, + ) # 描述符的 IngestSpec.table 已限白名单;其余表本期通用入库未实现。 raise AppError(ErrorCode.VALIDATION, f"入库表 {table} 暂未支持通用入库", {"table": table}) @@ -493,3 +508,72 @@ async def _ingest_outline( created_count=len(created), ) return ToolIngestResponse(table="outline", created=created, rejected_tables=rejected) + + +# 拆书入库的 rules 级别(meta 规则非设定卡,落项目级;单用户原型 owner stub)。 +_TEARDOWN_RULE_LEVEL = "project" + + +def _flatten_teardown_rules(teardown: TeardownIngestView) -> list[tuple[str, str]]: + """把拆书结论拍平成可读 rules 条目:返回 (label, content) 列表(确定性顺序,跳过空字段)。 + + 主题/原型/钩子各一条(清单拼成可读文本);结构一条。空清单/空串的字段跳过,不落空规则。 + """ + entries: list[tuple[str, str]] = [] + if teardown.themes: + entries.append(("themes", f"核心主题:{';'.join(teardown.themes)}")) + if teardown.archetypes: + entries.append(("archetypes", f"人物原型:{';'.join(teardown.archetypes)}")) + if teardown.structure.strip(): + entries.append(("structure", f"叙事结构:{teardown.structure.strip()}")) + if teardown.hooks: + entries.append(("hooks", f"钩子套路:{';'.join(teardown.hooks)}")) + return entries + + +async def _ingest_rules( + spec: AgentSpec, + *, + body: ToolIngestRequest, + project_id: uuid.UUID, + rule_write_repo: RuleWriteRepo, + session: AsyncSession, + request_id: str | None, + tool_key: str, +) -> ToolIngestResponse: + """rules 入库(拆书):白名单 → 把拆书结论拍平为可读 rules 条目写入项目级 rules。 + + meta 规则非设定卡,无需 continuity 预检(仿 outline 无 409 路径);仍过 partition_writes + 白名单(teardown spec 声明 writes=["rules"])。无 teardown 产物 → VALIDATION。 + """ + if body.teardown is None: + raise AppError(ErrorCode.VALIDATION, "拆书入库需提供 teardown 产物", {"tool_key": tool_key}) + + entries = _flatten_teardown_rules(body.teardown) + + # gate:权限白名单(teardown spec 声明 writes=["rules"])。 + allowed, rejected = partition_writes(spec, {"rules": entries}) + if rejected: + log.warning( + "toolbox_ingest_rejected_over_permission", + tool_key=tool_key, + project_id=str(project_id), + request_id=request_id, + rejected_tables=rejected, + ) + + created: list[str] = [] + for label, content in allowed.get("rules", []): + await rule_write_repo.create(project_id, level=_TEARDOWN_RULE_LEVEL, content=content) + created.append(label) + + await session.commit() + log.info( + "toolbox_ingest_done", + tool_key=tool_key, + project_id=str(project_id), + request_id=request_id, + table="rules", + created_count=len(created), + ) + return ToolIngestResponse(table="rules", created=created, rejected_tables=rejected) diff --git a/apps/api/ww_api/schemas/toolbox.py b/apps/api/ww_api/schemas/toolbox.py index 6401ed5..13d7e9e 100644 --- a/apps/api/ww_api/schemas/toolbox.py +++ b/apps/api/ww_api/schemas/toolbox.py @@ -105,12 +105,22 @@ class OutlineSceneIngestView(BaseModel): hook: str = "" +class TeardownIngestView(BaseModel): + """拆书入库产物(贴 ww_agents.BookTeardownResult;落 rules 表)。F1。""" + + themes: list[str] = Field(default_factory=list, description="核心主题/立意清单") + archetypes: list[str] = Field(default_factory=list, description="人物原型/角色模板清单") + structure: str = Field(default="", description="叙事结构概述") + hooks: list[str] = Field(default_factory=list, description="抓人钩子/爽点套路清单") + + class ToolIngestRequest(BaseModel): """通用入库请求:待持久化的产物 + 冲突确认。 仅声明了 `ingest` 的工具可用。按入库表取用对应字段: - world_entities(金手指/词条)→ `world_entities`(贴 WorldEntityCardView:type/name/rules); - - outline(细纲)→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView)。 + - outline(细纲)→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView); + - rules(拆书)→ `teardown`(贴 TeardownIngestView:themes/archetypes/structure/hooks)。 `acknowledge_conflicts`:作者已查看并接受 continuity 预检冲突时置 true 放行(仿 accept gate)。 """ @@ -123,6 +133,9 @@ class ToolIngestRequest(BaseModel): scenes: list[OutlineSceneIngestView] = Field( default_factory=list, description="outline 入库的场景行(细纲)" ) + teardown: TeardownIngestView | None = Field( + default=None, description="rules 入库的拆书结论(F1;拍平为可读规则条目)" + ) acknowledge_conflicts: bool = Field( default=False, description="作者已知悉并接受 continuity 冲突 → 放行入库" ) diff --git a/packages/agents/tests/test_competitor_specs.py b/packages/agents/tests/test_competitor_specs.py index 39bc6b8..c0b42ce 100644 --- a/packages/agents/tests/test_competitor_specs.py +++ b/packages/agents/tests/test_competitor_specs.py @@ -1,7 +1,8 @@ """Scope B 竞品快赢 · 4 个生成器的 spec/schema 契约测试。 校验每个 spec 的 name/tier/reads/writes/output_schema/scope/input_schema, -对齐不变量 #2(只声明 tier)/ #3(4 生成器一律纯预览 writes=[])。不联网、无 DB。 +对齐不变量 #2(只声明 tier)/ #3(续写/扩写/降AI 纯预览 writes=[];拆书 F1 落库 rules)。 +不联网、无 DB。 """ from __future__ import annotations @@ -19,17 +20,25 @@ from ww_agents import ( teardown_spec, ) -# (spec, name, tier, reads, output_schema) +# (spec, name, tier, reads, writes, output_schema) _COMPETITOR_SPECS = [ - (continue_spec, "continue", "writer", ["projects", "outline", "chapters"], ContinuationResult), - (expand_spec, "expand", "writer", ["projects"], PolishResult), - (de_ai_spec, "de-ai", "analyst", ["projects"], DeAiResult), - (teardown_spec, "teardown", "analyst", ["projects"], BookTeardownResult), + ( + continue_spec, + "continue", + "writer", + ["projects", "outline", "chapters"], + [], + ContinuationResult, + ), + (expand_spec, "expand", "writer", ["projects"], [], PolishResult), + (de_ai_spec, "de-ai", "analyst", ["projects"], [], DeAiResult), + # F1:拆书结论可落库为项目 rules(writes=["rules"];其余三者仍纯预览)。 + (teardown_spec, "teardown", "analyst", ["projects"], ["rules"], BookTeardownResult), ] @pytest.mark.parametrize( - ("spec", "name", "tier", "reads", "output_schema"), + ("spec", "name", "tier", "reads", "writes", "output_schema"), _COMPETITOR_SPECS, ) def test_competitor_spec_declares_expected_contract( @@ -37,12 +46,13 @@ def test_competitor_spec_declares_expected_contract( name: str, tier: str, reads: list[str], + writes: list[str], output_schema: type, ) -> None: assert spec.name == name assert spec.tier == tier assert spec.reads == reads - assert spec.writes == [] # 不变量 #3:4 生成器一律纯预览 + assert spec.writes == writes # 不变量 #3:续写/扩写/降AI 纯预览;拆书落 rules assert spec.output_schema is output_schema assert spec.scope == "builtin" assert spec.input_schema is None # 注入材料为序列化文本,非结构化入参 diff --git a/packages/agents/ww_agents/schemas.py b/packages/agents/ww_agents/schemas.py index f2f58c4..20c857c 100644 --- a/packages/agents/ww_agents/schemas.py +++ b/packages/agents/ww_agents/schemas.py @@ -571,8 +571,8 @@ class DeAiResult(BaseModel): class BookTeardownResult(BaseModel): """拆书生成器结构化产出:样本作品的结构化拆解(Scope B 竞品快赢)。 - 纯预览产物——不映射任何业务表、不入库(`teardown_spec.writes=[]`,不变量 #3)。 - 拆书「落库成 rules」为可选 follow-up(需 `KNOWN_TABLES` + `_ingest_rules`),本期不做。 + F1:可经 ingest 端点把结论拍平为项目 `rules` 条目落库(`teardown_spec.writes=["rules"]`); + 入库仍经白名单 gate(不变量 #3),预览(generate)不写库。 """ themes: list[str] = Field( diff --git a/packages/agents/ww_agents/specs.py b/packages/agents/ww_agents/specs.py index 6a5c2a6..2bb854d 100644 --- a/packages/agents/ww_agents/specs.py +++ b/packages/agents/ww_agents/specs.py @@ -799,6 +799,6 @@ teardown_spec = AgentSpec( input_schema=None, # 注入材料为序列化文本(设定 + 样本 + 需求),非结构化入参 output_schema=BookTeardownResult, reads=["projects"], - writes=[], # 纯预览,不写库(不变量 #3) + writes=["rules"], # F1:拆书结论可落库为项目 rules(入库仍经 ingest 白名单 gate,#3) scope="builtin", ) diff --git a/packages/skills/tests/test_toolbox_registry.py b/packages/skills/tests/test_toolbox_registry.py index d1a0df2..a9bb288 100644 --- a/packages/skills/tests/test_toolbox_registry.py +++ b/packages/skills/tests/test_toolbox_registry.py @@ -19,13 +19,13 @@ _NEW_KEYS = { "glossary", "opening", "fine-outline", - # 竞品快赢(Scope B):续写 / 扩写 / 降AI率 / 拆书(全 preview-only)。 + # 竞品快赢(Scope B):续写/扩写/降AI率 preview-only;拆书 F1 可落库 rules。 "continue", "expand", "de-ai", "teardown", } -_INGEST_KEYS = {"golden-finger", "glossary", "fine-outline"} +_INGEST_KEYS = {"golden-finger", "glossary", "fine-outline", "teardown"} def test_toolbox_has_all_tools() -> None: @@ -70,6 +70,9 @@ def test_ingest_tools_declare_known_table() -> None: assert TOOLBOX["glossary"].ingest.table == "world_entities" assert TOOLBOX["fine-outline"].ingest is not None assert TOOLBOX["fine-outline"].ingest.table == "outline" + # F1:拆书结论落库为项目 rules。 + assert TOOLBOX["teardown"].ingest is not None + assert TOOLBOX["teardown"].ingest.table == "rules" def test_chapter_tools_have_chapter_no_field() -> None: diff --git a/packages/skills/ww_skills/toolbox_registry.py b/packages/skills/ww_skills/toolbox_registry.py index 675e549..fe25a0a 100644 --- a/packages/skills/ww_skills/toolbox_registry.py +++ b/packages/skills/ww_skills/toolbox_registry.py @@ -197,7 +197,7 @@ TOOLBOX: dict[str, GeneratorTool] = { input_fields=[_chapter_no_field(), _BRIEF_FIELD], ingest=IngestSpec(table="outline"), ), - # ---- 竞品快赢(Scope B):续写 / 扩写 / 降AI率 / 拆书(全 preview-only,writes=[])---- + # ---- 竞品快赢(Scope B):续写/扩写/降AI率 preview-only;拆书 F1 可落库 rules ---- "continue": GeneratorTool( key="continue", title="续写生成器", @@ -252,6 +252,7 @@ TOOLBOX: dict[str, GeneratorTool] = { _SOURCE_TEXT_FIELD, _BRIEF_FIELD, ], + ingest=IngestSpec(table="rules"), # F1:拆书结论拍平为项目 rules 条目落库 ), } From 18aa87d751b96fbe410f65da6d67365f00df26ef Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 23 Jun 2026 20:09:08 +0200 Subject: [PATCH 3/8] =?UTF-8?q?feat(chain):=20F2=20=E7=BB=AD=E5=86=99?= =?UTF-8?q?=E5=BC=8F=E9=93=BE=20continue=5Fvolume=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=B8=8A=E4=B8=80=E7=AB=A0=20accepted=20=E6=AD=A3=E6=96=87?= =?UTF-8?q?=E4=BD=9C=E5=89=8D=E6=96=87=E5=BC=95=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core write_chapter 支持续写模式:chain_key==continue_volume 时经注入的 chapter_repo.latest_accepted 读上一章已验收终稿,build_continuation_context 改写 volatile(仅断点后块,守不变量 #9;core 不 import apps/api)。 - ChapterDraftRepo 协议加 latest_accepted;draft_volume 行为不变(无回归)。 - apps/api SUPPORTED_CHAINS 加 continue_volume;run 端点透传 chain_key 选模式 (chain_runner 经 initial_chain_state 把 chain_key 落入 ChainState)。 - 单测(mock 网关+MemorySaver+fake session):续写第二章请求含第一章正文; draft_volume 不注入前文;端点 202 透传 chain_key。守不变量 #1/#5。 --- apps/api/tests/test_chain.py | 22 ++++ apps/api/ww_api/routers/chain.py | 5 +- packages/core/tests/test_chain_graph.py | 101 +++++++++++++++++- .../core/ww_core/orchestrator/chain/nodes.py | 39 ++++++- 4 files changed, 157 insertions(+), 10 deletions(-) diff --git a/apps/api/tests/test_chain.py b/apps/api/tests/test_chain.py index 9cb2948..a2635c4 100644 --- a/apps/api/tests/test_chain.py +++ b/apps/api/tests/test_chain.py @@ -252,6 +252,28 @@ async def test_run_chain_returns_202_and_schedules_job( assert len(_noop_run_chain_job) == 1 +async def test_run_continue_volume_chain_returns_202_and_forwards_chain_key( + _noop_run_chain_job: list[dict[str, Any]], +) -> None: + """F2:continue_volume 链受支持 → 202,且 chain_key 透传给 run_chain_job(续写模式)。""" + project_repo = FakeProjectRepo() + job_repo = FakeJobRepo() + pid = await _seed_project(project_repo) + app = _app(project_repo, job_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/chains/continue_volume/run", + json={"start_chapter_no": 2, "count": 2}, + ) + + assert resp.status_code == 202 + body = resp.json() + assert body["chain_key"] == "continue_volume" + assert len(_noop_run_chain_job) == 1 + assert _noop_run_chain_job[0]["kwargs"]["chain_key"] == "continue_volume" + + async def test_run_chain_unknown_key_returns_404( _noop_run_chain_job: list[dict[str, Any]], ) -> None: diff --git a/apps/api/ww_api/routers/chain.py b/apps/api/ww_api/routers/chain.py index 73ceea3..cde2885 100644 --- a/apps/api/ww_api/routers/chain.py +++ b/apps/api/ww_api/routers/chain.py @@ -59,8 +59,9 @@ log = get_logger("ww.api.chain") router = APIRouter(prefix="/projects", tags=["chain"]) -# 本期唯一内置链(§1.3/§3)。未知 key → 404(系统边界,fail fast)。 -SUPPORTED_CHAINS = frozenset({"draft_volume"}) +# 内置链(§1.3/§3):draft_volume=从 start 章按记忆量产;continue_volume=每章以上一章已验收 +# 正文末尾作前文引子续写(F2)。未知 key → 404(系统边界,fail fast)。 +SUPPORTED_CHAINS = frozenset({"draft_volume", "continue_volume"}) ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)] diff --git a/packages/core/tests/test_chain_graph.py b/packages/core/tests/test_chain_graph.py index f004e31..b4f6166 100644 --- a/packages/core/tests/test_chain_graph.py +++ b/packages/core/tests/test_chain_graph.py @@ -64,13 +64,17 @@ class FakeChainGateway: self._by_schema = by_schema self.write_calls = 0 self.review_calls = 0 + self.write_inputs: list[str] = [] async def run(self, req: LlmRequest) -> LlmResponse: schema: type[BaseModel] | None = req.output_schema if schema is None: self.write_calls += 1 + self.write_inputs.append(req.input if isinstance(req.input, str) else "") + # 每次写章产不同正文(便于断言下一章续写注入了上一章正文)。 + draft = f"{self._draft}#{self.write_calls}" return LlmResponse( - text=self._draft, + text=draft, parsed=None, usage=_usage(), served_by=ServedBy(provider="fake", model="fake"), @@ -93,10 +97,11 @@ class FakeDraftView: class FakeChapterRepo: - """内存章草稿 repo:save_draft 存正文,get_draft 回读。""" + """内存章草稿 repo:save_draft 存正文,get_draft 回读,latest_accepted 读已验收正文。""" - def __init__(self, store: dict[int, str]) -> None: + def __init__(self, store: dict[int, str], accepted: dict[int, str] | None = None) -> None: self._store = store + self._accepted = accepted if accepted is not None else {} async def save_draft( self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1 @@ -108,6 +113,10 @@ class FakeChapterRepo: content = self._store.get(chapter_no) return FakeDraftView(content) if content is not None else None + async def latest_accepted(self, project_id: uuid.UUID, chapter_no: int) -> Any: + content = self._accepted.get(chapter_no) + return FakeDraftView(content) if content is not None else None + class FakeReviewRecord: def __init__(self, chapter_no: int, conflicts: list[dict[str, Any]]) -> None: @@ -177,6 +186,7 @@ def _empty_by_schema() -> dict[type, BaseModel]: def _make_harness(*, conflicts: list[Conflict]) -> dict[str, Any]: """造一套共享内存态 + 工厂闭包 + accept 间谍,供图/节点测试复用。""" draft_store: dict[int, str] = {} + accepted_store: dict[int, str] = {} review_store: dict[int, list[FakeReviewRecord]] = {} accepted: list[dict[str, Any]] = [] @@ -192,7 +202,7 @@ def _make_harness(*, conflicts: list[Conflict]) -> dict[str, Any]: return object() # assemble 被 fake 替换,不实际用 repos def chapter_repo_factory(session: Any) -> Any: - return FakeChapterRepo(draft_store) + return FakeChapterRepo(draft_store, accepted_store) def review_repo_factory(session: Any) -> Any: return FakeReviewRepo(review_store) @@ -208,6 +218,9 @@ def _make_harness(*, conflicts: list[Conflict]) -> dict[str, Any]: decisions: list[Any], ) -> None: accepted.append({"chapter_no": chapter_no, "decisions": list(decisions)}) + # 仿真验收落库:把本章草稿晋升为 accepted(供下一章续写读前文)。 + if chapter_no in draft_store: + accepted_store[chapter_no] = draft_store[chapter_no] return { "gateway": gateway, @@ -220,6 +233,7 @@ def _make_harness(*, conflicts: list[Conflict]) -> dict[str, Any]: "accept_op": accept_op, "accepted": accepted, "draft_store": draft_store, + "accepted_store": accepted_store, "review_store": review_store, } @@ -330,7 +344,7 @@ async def test_write_chapter_saves_collected_draft() -> None: ) assert out == {} # state 不变(正文在 DB) - assert h["draft_store"][1] == "第 N 章正文。" + assert h["draft_store"][1] == "第 N 章正文。#1" # 收集版:write 走非流式 run assert h["gateway"].write_calls == 1 @@ -378,3 +392,80 @@ async def test_chain_interrupts_on_conflict_then_resumes() -> None: assert final["written"] == [1] assert [a["chapter_no"] for a in h["accepted"]] == [1] assert h["accepted"][0]["decisions"] == decisions # 裁决透传给 accept_op + + +# ---- F2 续写式链 continue_volume ---- + + +async def test_write_chapter_draft_volume_omits_prior_text() -> None: + """draft_volume(默认)写章不注入前文——仍走 assemble 的 volatile(无回归)。""" + h = _make_harness(conflicts=[]) + h["accepted_store"][0] = "上一章正文末尾片段。" # 即使有前文也不应注入 + state = initial_chain_state( + project_id=PROJECT, user_id=USER, start_chapter_no=1, count=1 + ) # 默认 chain_key=draft_volume + + await write_chapter( + state, + gateway_builder=h["gateway_builder"], + session_factory=h["session_factory"], + memory_repos_factory=h["memory_repos_factory"], + chapter_repo_factory=h["chapter_repo_factory"], + assemble=h["assemble"], + ) + + write_input = h["gateway"].write_inputs[0] + assert write_input == "## 写作指令\n写第 N 章" # 原 assemble volatile,未改写 + assert "上一章正文末尾片段" not in write_input + + +async def test_write_chapter_continue_volume_injects_prior_accepted_text() -> None: + """continue_volume 写第 2 章时,注入第 1 章 accepted 正文(经 build_continuation_context)。""" + h = _make_harness(conflicts=[]) + h["accepted_store"][1] = "第一章已验收的正文末尾。" + state = initial_chain_state( + project_id=PROJECT, + user_id=USER, + start_chapter_no=2, + count=1, + chain_key="continue_volume", + ) + + await write_chapter( + state, + gateway_builder=h["gateway_builder"], + session_factory=h["session_factory"], + memory_repos_factory=h["memory_repos_factory"], + chapter_repo_factory=h["chapter_repo_factory"], + assemble=h["assemble"], + ) + + write_input = h["gateway"].write_inputs[0] + assert "第一章已验收的正文末尾。" in write_input + assert "前文正文" in write_input # build_continuation_context 的小节标题 + + +async def test_continue_volume_chain_second_chapter_sees_first_chapter_text() -> None: + """全图 E2E:continue_volume 两章——第 2 章请求上下文含第 1 章正文(不变量 #1/#5)。""" + h = _make_harness(conflicts=[]) + graph = _build(h, MemorySaver()) + config: RunnableConfig = {"configurable": {"thread_id": "chain-continue"}} + initial = initial_chain_state( + project_id=PROJECT, + user_id=USER, + start_chapter_no=1, + count=2, + chain_key="continue_volume", + ) + + final = await graph.ainvoke(initial, config=config) + + assert "__interrupt__" not in final + assert final["written"] == [1, 2] + # 第 1 章写出的正文(accept_op 晋升为 accepted)应出现在第 2 章的写章请求里。 + first_chapter_text = h["accepted_store"][1] + second_write_input = h["gateway"].write_inputs[1] + assert first_chapter_text in second_write_input + # 第 1 章请求无前文(前一章 0 不存在)→ 降级占位,不报错。 + first_write_input = h["gateway"].write_inputs[0] + assert "前文正文" in first_write_input diff --git a/packages/core/ww_core/orchestrator/chain/nodes.py b/packages/core/ww_core/orchestrator/chain/nodes.py index 2926c94..15bccf0 100644 --- a/packages/core/ww_core/orchestrator/chain/nodes.py +++ b/packages/core/ww_core/orchestrator/chain/nodes.py @@ -20,6 +20,7 @@ from ww_agents import AgentSpec from .._protocols import GatewayRun from ..collect import collect_reviews +from ..generation_node import build_continuation_context from ..review_node import build_review_context, run_review from ..state import ChapterState from ..write_node import build_write_request @@ -27,6 +28,9 @@ from .state import ChainState log = structlog.get_logger(__name__) +#: 续写式链 key:写章以上一章已验收正文末尾作前文引子(F2)。 +CHAIN_CONTINUE_VOLUME = "continue_volume" + # ---- 注入缝(图工厂经默认参绑定,单测直接注 fake)---- #: `session -> Repo`:节点自建短事务里从 session 造 repo(仿端点依赖工厂)。 @@ -62,7 +66,10 @@ class DraftView(Protocol): class ChapterDraftRepo(Protocol): - """章草稿 repo 最小依赖:write 落稿 `save_draft` + review 重读 `get_draft`(只 flush)。""" + """章草稿 repo 最小依赖:write 落稿 `save_draft` + review 重读 `get_draft`(只 flush)。 + + 续写式链(`continue_volume`)写章另需 `latest_accepted`——读上一章已验收终稿正文作前文引子。 + """ async def save_draft( self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1 @@ -70,6 +77,8 @@ class ChapterDraftRepo(Protocol): async def get_draft(self, project_id: uuid.UUID, chapter_no: int) -> DraftView | None: ... + async def latest_accepted(self, project_id: uuid.UUID, chapter_no: int) -> DraftView | None: ... + class ReviewRecordRepo(Protocol): """review/accept 节点对审稿 repo 的最小依赖:collect 留痕 + accept 重读最近审稿。""" @@ -126,20 +135,30 @@ async def write_chapter( project_id = state["project_id"] chapter_no = state["current_chapter_no"] user_id = state["user_id"] + is_continuation = state.get("chain_key") == CHAIN_CONTINUE_VOLUME async with session_factory() as session: gateway = await gateway_builder(session) repos = memory_repos_factory(session) context = await assemble(repos, project_id, chapter_no) + chapter_repo = chapter_repo_factory(session) + # 续写模式:volatile 改写为「设定 + 上一章已验收正文末尾」(不变量 #9:仅改断点后块, + # stable_core 仍进缓存前缀)。前文经注入的 chapter_repo 重读(core 不 import apps/api)。 + if is_continuation: + prior_text = await _read_prior_accepted(chapter_repo, project_id, chapter_no) + volatile = build_continuation_context( + project_context=context.volatile, prior_text=prior_text + ) + else: + volatile = context.volatile req = build_write_request( stable_core=context.stable_core, - volatile=context.volatile, + volatile=volatile, user_id=user_id, project_id=project_id, ) # 收集版:非流式,一次 run 拿全文(链批量量产不走 SSE)。 collect_req = req.model_copy(update={"stream": False}) resp = await gateway.run(collect_req) - chapter_repo = chapter_repo_factory(session) await chapter_repo.save_draft(project_id, chapter_no, text=resp.text) await _commit(session) log.info( @@ -295,6 +314,19 @@ async def _read_draft( return view.content if view is not None else "" +async def _read_prior_accepted( + chapter_repo: ChapterDraftRepo, project_id: uuid.UUID, chapter_no: int +) -> str: + """续写式链:读上一章(chapter_no-1)已验收终稿正文作前文引子(不变量 #1/#4)。 + + 第一章(前一章号 < 1)或前一章未验收 → 空串,由 `build_continuation_context` 降级占位。 + """ + if chapter_no <= 1: + return "" + view = await chapter_repo.latest_accepted(project_id, chapter_no - 1) + return view.content if view is not None else "" + + async def _commit(session: CommitSession) -> None: """提交短事务(session 满足 `CommitSession`:只需 `commit()`)。""" await session.commit() @@ -307,6 +339,7 @@ def has_conflicts(state: ChainState) -> bool: __all__ = [ + "CHAIN_CONTINUE_VOLUME", "AcceptChapterOp", "AssembleContext", "AssembleFn", From 9bb453acc84ce9c7e24aaab71a4cc040b27209ed Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 23 Jun 2026 20:11:25 +0200 Subject: [PATCH 4/8] =?UTF-8?q?feat(db):=20F3a=20prompt=5Ftemplates=20?= =?UTF-8?q?=E8=A1=A8=20+=20=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 PromptTemplate 模型(UuidPk+CreatedAt+Base;owner_id stub、title、body、 category、tool_key)+ alembic 迁移。alembic check 无漂移;ruff/mypy packages/db 干净。 --- .../59a9198d4604_prompt_templates_table.py | 53 +++++++++++++++++++ packages/db/ww_db/models.py | 14 +++++ 2 files changed, 67 insertions(+) create mode 100644 packages/db/migrations/versions/59a9198d4604_prompt_templates_table.py diff --git a/packages/db/migrations/versions/59a9198d4604_prompt_templates_table.py b/packages/db/migrations/versions/59a9198d4604_prompt_templates_table.py new file mode 100644 index 0000000..b3f7696 --- /dev/null +++ b/packages/db/migrations/versions/59a9198d4604_prompt_templates_table.py @@ -0,0 +1,53 @@ +"""prompt_templates table + +Revision ID: 59a9198d4604 +Revises: d3e4f5a6b7c8 +Create Date: 2026-06-23 20:10:37.869872 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "59a9198d4604" +down_revision: str | None = "d3e4f5a6b7c8" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "prompt_templates", + sa.Column("owner_id", sa.Uuid(), nullable=False), + sa.Column("title", sa.Text(), nullable=False), + sa.Column("body", sa.Text(), nullable=False), + sa.Column("category", sa.Text(), nullable=True), + sa.Column("tool_key", sa.Text(), nullable=True), + sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["owner_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_prompt_templates_owner_id"), "prompt_templates", ["owner_id"], unique=False + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_prompt_templates_owner_id"), table_name="prompt_templates") + op.drop_table("prompt_templates") + # ### end Alembic commands ### diff --git a/packages/db/ww_db/models.py b/packages/db/ww_db/models.py index e7ca933..045408e 100644 --- a/packages/db/ww_db/models.py +++ b/packages/db/ww_db/models.py @@ -284,6 +284,20 @@ class Skill(UuidPk, CreatedAt, Base): examples: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'")) +class PromptTemplate(UuidPk, CreatedAt, Base): + """作者保存/复用的提示词模板(单用户本地版;无分享/市场)。 + + owner_id 为单用户原型 stub;tool_key 可选关联某生成器(NULL=通用)。 + """ + + __tablename__ = "prompt_templates" + owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False, index=True) + title: Mapped[str] = mapped_column(Text, nullable=False) + body: Mapped[str] = mapped_column(Text, nullable=False) + category: Mapped[str | None] = mapped_column(Text) + tool_key: Mapped[str | None] = mapped_column(Text) + + class Job(UuidPk, TimestampedMixin, Base): __tablename__ = "jobs" __table_args__ = ( From cfe6a535b09fd97f8a9df61b50d9e9131a2da1cb Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 23 Jun 2026 20:17:32 +0200 Subject: [PATCH 5/8] =?UTF-8?q?feat(templates):=20F3b=20=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E5=BA=93=20repo=20+=20=E7=AB=AF=E7=82=B9=EF=BC=88GET/POST/DELE?= =?UTF-8?q?TE=20/templates=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TemplateRepo/SqlTemplateRepo(list/create/delete,owner stub 过滤,只 flush 端点 commit) - schemas/templates.py(title/body min_length=1→422)+ routers/templates.py(删不存在→404)+ main 注册 - 单测:repo CRUD/owner 隔离/frozen + 端点 201/列出/204/422×2/404 --- apps/api/tests/test_templates.py | 127 ++++++++++++++++++ apps/api/ww_api/main.py | 2 + apps/api/ww_api/routers/templates.py | 90 +++++++++++++ apps/api/ww_api/schemas/templates.py | 31 +++++ apps/api/ww_api/services/project_deps.py | 11 ++ packages/core/tests/test_template_repo.py | 104 ++++++++++++++ packages/core/ww_core/domain/__init__.py | 10 ++ packages/core/ww_core/domain/template_repo.py | 105 +++++++++++++++ 8 files changed, 480 insertions(+) create mode 100644 apps/api/tests/test_templates.py create mode 100644 apps/api/ww_api/routers/templates.py create mode 100644 apps/api/ww_api/schemas/templates.py create mode 100644 packages/core/tests/test_template_repo.py create mode 100644 packages/core/ww_core/domain/template_repo.py diff --git a/apps/api/tests/test_templates.py b/apps/api/tests/test_templates.py new file mode 100644 index 0000000..b96ca6f --- /dev/null +++ b/apps/api/tests/test_templates.py @@ -0,0 +1,127 @@ +"""F3b GET/POST/DELETE /templates 端点(内存替身,无 DB/无网络)。 + +覆盖: +- POST 201 + 回显 + 端点 commit;GET 列出;DELETE 204 + commit; +- 空 title → 422、空 body → 422(schema `min_length=1`,FastAPI 422); +- 删不存在 → 404(repo.delete 返 False → AppError NOT_FOUND)。 +""" + +from __future__ import annotations + +import os +import uuid + +import httpx +import pytest +from cryptography.fernet import Fernet +from fakes_projects import FakeSession +from ww_core.domain.template_repo import TemplateCreate, TemplateView + + +class _FakeTemplateRepo: + def __init__(self) -> None: + self.rows: dict[uuid.UUID, TemplateView] = {} + + async def create(self, owner_id: uuid.UUID, data: TemplateCreate) -> TemplateView: + view = TemplateView( + id=uuid.uuid4(), + title=data.title, + body=data.body, + category=data.category, + tool_key=data.tool_key, + ) + self.rows[view.id] = view + return view + + async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]: + return list(self.rows.values()) + + async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool: + if template_id in self.rows: + del self.rows[template_id] + return True + return False + + +def _make_client() -> tuple[httpx.AsyncClient, _FakeTemplateRepo, 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_template_repo + from ww_db import get_session + + repo = _FakeTemplateRepo() + session = FakeSession() + + app = create_app() + app.dependency_overrides[get_template_repo] = lambda: repo + app.dependency_overrides[get_session] = lambda: session + transport = httpx.ASGITransport(app=app) + client = httpx.AsyncClient(transport=transport, base_url="http://test") + return client, repo, session + + +@pytest.mark.asyncio +async def test_create_template_returns_201_and_commits() -> None: + client, repo, session = _make_client() + async with client: + resp = await client.post( + "/templates", + json={"title": "爽文开局", "body": "主角穿越...", "tool_key": "expand"}, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["title"] == "爽文开局" + assert body["body"] == "主角穿越..." + assert body["tool_key"] == "expand" + assert session.commits == 1 + assert len(repo.rows) == 1 + + +@pytest.mark.asyncio +async def test_list_templates_returns_created() -> None: + client, repo, _session = _make_client() + async with client: + await client.post("/templates", json={"title": "a", "body": "b"}) + resp = await client.get("/templates") + assert resp.status_code == 200 + items = resp.json() + assert len(items) == 1 + assert items[0]["title"] == "a" + + +@pytest.mark.asyncio +async def test_create_template_empty_title_returns_422() -> None: + client, _repo, _session = _make_client() + async with client: + resp = await client.post("/templates", json={"title": "", "body": "b"}) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_create_template_empty_body_returns_422() -> None: + client, _repo, _session = _make_client() + async with client: + resp = await client.post("/templates", json={"title": "a", "body": ""}) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_delete_template_returns_204_and_commits() -> None: + client, repo, session = _make_client() + async with client: + created = await client.post("/templates", json={"title": "a", "body": "b"}) + tid = created.json()["id"] + resp = await client.delete(f"/templates/{tid}") + assert resp.status_code == 204 + assert session.commits == 2 + assert repo.rows == {} + + +@pytest.mark.asyncio +async def test_delete_missing_template_returns_404() -> None: + client, _repo, session = _make_client() + async with client: + resp = await client.delete(f"/templates/{uuid.uuid4()}") + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == "NOT_FOUND" + assert session.commits == 0 diff --git a/apps/api/ww_api/main.py b/apps/api/ww_api/main.py index e64b5f6..e2e78c1 100644 --- a/apps/api/ww_api/main.py +++ b/apps/api/ww_api/main.py @@ -27,6 +27,7 @@ from ww_api.routers import ( rules, settings_providers, style, + templates, toolbox, ) from ww_api.security.credentials import _fernet @@ -115,6 +116,7 @@ def create_app() -> FastAPI: app.include_router(outline.router) app.include_router(rules.router) app.include_router(style.router) + app.include_router(templates.router) app.include_router(generation.router) app.include_router(generation.skills_router) app.include_router(toolbox.router) diff --git a/apps/api/ww_api/routers/templates.py b/apps/api/ww_api/routers/templates.py new file mode 100644 index 0000000..f3c9a2a --- /dev/null +++ b/apps/api/ww_api/routers/templates.py @@ -0,0 +1,90 @@ +"""模板库端点(F3 / 契约 §F3)。 + +单用户本地版:作者保存/复用提示词模板,可一键填入生成器的 brief/text。 +**不做分享/市场**(需多租户)。owner_id 全程 stub(单用户原型)。 + +- GET /templates 列出当前用户的模板。 +- POST /templates 新建模板(201;title/body 空 → 422)。 +- DELETE /templates/:id 删除模板(204;不存在 → 404)。 + +提交边界:`TemplateRepo.create`/`delete` 只 `flush()`,端点写后 `await session.commit()` +(仿 rules/foreshadow 写侧,见 memory/gotchas)。 +""" + +from __future__ import annotations + +import uuid +from typing import Annotated + +from fastapi import APIRouter, Depends, Request, Response +from sqlalchemy.ext.asyncio import AsyncSession +from ww_core.domain import TemplateCreate, TemplateRepo +from ww_db import get_session +from ww_shared import AppError, ErrorCode + +from ww_api.logging_config import get_logger +from ww_api.schemas.templates import TemplateCreateRequest, TemplateResponse +from ww_api.services.credentials import STUB_OWNER_ID +from ww_api.services.project_deps import get_template_repo + +log = get_logger("ww.api.templates") + +router = APIRouter(prefix="/templates", tags=["templates"]) + +TemplateRepoDep = Annotated[TemplateRepo, Depends(get_template_repo)] +SessionDep = Annotated[AsyncSession, Depends(get_session)] + + +@router.get("") +async def list_templates(repo: TemplateRepoDep) -> list[TemplateResponse]: + """列出当前用户(stub)的模板。""" + views = await repo.list_for_owner(STUB_OWNER_ID) + return [ + TemplateResponse( + id=v.id, title=v.title, body=v.body, category=v.category, tool_key=v.tool_key + ) + for v in views + ] + + +@router.post("", status_code=201) +async def create_template( + body: TemplateCreateRequest, + request: Request, + repo: TemplateRepoDep, + session: SessionDep, +) -> TemplateResponse: + """新建模板(201)。title/body 空 → FastAPI 422(schema `min_length=1`)。""" + request_id = getattr(request.state, "request_id", None) + view = await repo.create( + STUB_OWNER_ID, + TemplateCreate( + title=body.title, body=body.body, category=body.category, tool_key=body.tool_key + ), + ) + await session.commit() + log.info("template_created", template_id=str(view.id), request_id=request_id) + return TemplateResponse( + id=view.id, + title=view.title, + body=view.body, + category=view.category, + tool_key=view.tool_key, + ) + + +@router.delete("/{template_id}", status_code=204) +async def delete_template( + template_id: uuid.UUID, + request: Request, + repo: TemplateRepoDep, + session: SessionDep, +) -> Response: + """删除模板(204)。不存在 → 404 NOT_FOUND。""" + request_id = getattr(request.state, "request_id", None) + deleted = await repo.delete(STUB_OWNER_ID, template_id) + if not deleted: + raise AppError(ErrorCode.NOT_FOUND, f"template not found: {template_id}") + await session.commit() + log.info("template_deleted", template_id=str(template_id), request_id=request_id) + return Response(status_code=204) diff --git a/apps/api/ww_api/schemas/templates.py b/apps/api/ww_api/schemas/templates.py new file mode 100644 index 0000000..24dc604 --- /dev/null +++ b/apps/api/ww_api/schemas/templates.py @@ -0,0 +1,31 @@ +"""模板库端点的请求/响应 schema(F3 / 契约 §F3)。 + +snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。 +单用户本地版:作者保存/复用提示词模板,可一键填入生成器的 brief/text。 +`title`/`body` 非空(`min_length=1`,空 → FastAPI 422)。`category`/`tool_key` 可选。 +""" + +from __future__ import annotations + +import uuid + +from pydantic import BaseModel, Field + + +class TemplateCreateRequest(BaseModel): + """POST /templates:新建一条提示词模板。""" + + title: str = Field(min_length=1, description="模板标题") + body: str = Field(min_length=1, description="模板正文(一键填入生成器的 brief/text)") + category: str | None = Field(default=None, description="可选分类") + tool_key: str | None = Field(default=None, description="可选关联生成器(NULL=通用)") + + +class TemplateResponse(BaseModel): + """模板视图(列出/创建后回显;snake_case)。""" + + id: uuid.UUID + title: str + body: str + category: str | None = None + tool_key: str | None = None diff --git a/apps/api/ww_api/services/project_deps.py b/apps/api/ww_api/services/project_deps.py index 3c8af1e..41bec52 100644 --- a/apps/api/ww_api/services/project_deps.py +++ b/apps/api/ww_api/services/project_deps.py @@ -29,6 +29,7 @@ from ww_core.domain.repositories import MemoryRepos, OutlineRepo, RulesRepo from ww_core.domain.review_repo import ReviewRepo, SqlReviewRepo from ww_core.domain.rule_repo import RuleWriteRepo, SqlRuleWriteRepo from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo, StyleFingerprintWriteRepo +from ww_core.domain.template_repo import SqlTemplateRepo, TemplateRepo from ww_core.domain.world_entity_repo import SqlWorldEntityWriteRepo, WorldEntityWriteRepo from ww_core.memory.sql_repositories import SqlOutlineRepo, SqlRulesRepo, sql_memory_repos from ww_db import get_session, get_sessionmaker @@ -159,6 +160,16 @@ def get_rule_write_repo( return SqlRuleWriteRepo(session) +async def get_template_repo( + session: Annotated[AsyncSession, Depends(get_session)], +) -> TemplateRepo: + """模板库 repo(GET/POST/DELETE /templates;create/delete 只 flush,端点提交)。 + + 测试经 `app.dependency_overrides` 注入内存 fake。 + """ + return SqlTemplateRepo(session) + + async def get_skill_registry( session: Annotated[AsyncSession, Depends(get_session)], ) -> SkillRegistry: diff --git a/packages/core/tests/test_template_repo.py b/packages/core/tests/test_template_repo.py new file mode 100644 index 0000000..57be064 --- /dev/null +++ b/packages/core/tests/test_template_repo.py @@ -0,0 +1,104 @@ +"""F3b 模板库 repo 单测(契约 §F3)。 + +`TemplateRepo` = 单用户提示词模板的 list/create/delete,统一按 `owner_id` 过滤 +(单用户原型 stub)。`create`/`delete` 只 `flush()` 不 `commit()`——提交交端点事务 +(与项目其它写侧 repo 一致,见 memory/gotchas)。`delete` 返回是否删到行(端点据此 404)。 +纯内存 fake,无 DB。 +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field + +import pytest +from pydantic import ValidationError +from ww_core.domain.template_repo import TemplateCreate, TemplateRepo, TemplateView + +OWNER = uuid.UUID(int=1) +OTHER_OWNER = uuid.UUID(int=2) + + +@dataclass +class _FakeTemplateRepo: + rows: dict[uuid.UUID, TemplateView] = field(default_factory=dict) + owners: dict[uuid.UUID, uuid.UUID] = field(default_factory=dict) + flushed: int = 0 + + async def create(self, owner_id: uuid.UUID, data: TemplateCreate) -> TemplateView: + view = TemplateView( + id=uuid.uuid4(), + title=data.title, + body=data.body, + category=data.category, + tool_key=data.tool_key, + ) + self.rows[view.id] = view + self.owners[view.id] = owner_id + self.flushed += 1 + return view + + async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]: + return [v for tid, v in self.rows.items() if self.owners[tid] == owner_id] + + async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool: + if self.rows.get(template_id) is None or self.owners.get(template_id) != owner_id: + return False + del self.rows[template_id] + del self.owners[template_id] + self.flushed += 1 + return True + + +@pytest.mark.asyncio +async def test_create_then_list_returns_view() -> None: + repo: TemplateRepo = _FakeTemplateRepo() + + view = await repo.create( + OWNER, TemplateCreate(title="爽文开局", body="主角穿越...", category=None, tool_key=None) + ) + listed = await repo.list_for_owner(OWNER) + + assert view.title == "爽文开局" + assert view.body == "主角穿越..." + assert [v.id for v in listed] == [view.id] + + +@pytest.mark.asyncio +async def test_list_filters_by_owner() -> None: + repo: TemplateRepo = _FakeTemplateRepo() + await repo.create(OWNER, TemplateCreate(title="a", body="b")) + + assert await repo.list_for_owner(OTHER_OWNER) == [] + + +@pytest.mark.asyncio +async def test_delete_existing_returns_true() -> None: + repo: TemplateRepo = _FakeTemplateRepo() + view = await repo.create(OWNER, TemplateCreate(title="a", body="b")) + + deleted = await repo.delete(OWNER, view.id) + + assert deleted is True + assert await repo.list_for_owner(OWNER) == [] + + +@pytest.mark.asyncio +async def test_delete_missing_returns_false() -> None: + repo: TemplateRepo = _FakeTemplateRepo() + + assert await repo.delete(OWNER, uuid.uuid4()) is False + + +@pytest.mark.asyncio +async def test_delete_other_owner_returns_false() -> None: + repo: TemplateRepo = _FakeTemplateRepo() + view = await repo.create(OWNER, TemplateCreate(title="a", body="b")) + + assert await repo.delete(OTHER_OWNER, view.id) is False + + +def test_template_view_is_frozen() -> None: + view = TemplateView(id=uuid.uuid4(), title="a", body="b", category=None, tool_key=None) + with pytest.raises(ValidationError): + view.title = "x" diff --git a/packages/core/ww_core/domain/__init__.py b/packages/core/ww_core/domain/__init__.py index 906c1c9..6a9f4a5 100644 --- a/packages/core/ww_core/domain/__init__.py +++ b/packages/core/ww_core/domain/__init__.py @@ -60,6 +60,12 @@ from ww_core.domain.style_repo import ( StyleFingerprintView, StyleFingerprintWriteRepo, ) +from ww_core.domain.template_repo import ( + SqlTemplateRepo, + TemplateCreate, + TemplateRepo, + TemplateView, +) from ww_core.domain.world_entity_repo import ( SqlWorldEntityWriteRepo, WorldEntityWriteRepo, @@ -116,4 +122,8 @@ __all__ = [ "StyleFingerprintWriteRepo", "StyleFingerprintView", "SqlStyleFingerprintWriteRepo", + "TemplateCreate", + "TemplateRepo", + "TemplateView", + "SqlTemplateRepo", ] diff --git a/packages/core/ww_core/domain/template_repo.py b/packages/core/ww_core/domain/template_repo.py new file mode 100644 index 0000000..72b6833 --- /dev/null +++ b/packages/core/ww_core/domain/template_repo.py @@ -0,0 +1,105 @@ +"""提示词/模板库 Repository(F3 / 契约 §F3)。 + +单用户本地版:作者保存/复用提示词模板,可一键填入生成器的 brief/text。**不做分享/市场** +(需多租户)。统一按 `owner_id` 过滤(单用户原型 stub,多租户化时由认证主体替换)。 + +提交边界:`create`/`delete` 只 `flush()` 不 `commit()`——提交交端点事务(与项目其它写侧 +repo 一致,见 memory/gotchas)。`delete` 返回是否删到行(端点据此映射 404)。 +视图是与 ORM 解耦的只读 Pydantic 快照(frozen)——路由不碰 SQLAlchemy 行。 +""" + +from __future__ import annotations + +import uuid +from typing import Protocol + +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from ww_db.models import PromptTemplate + + +class TemplateView(BaseModel): + """模板只读快照(snake_case,frozen)。""" + + model_config = {"frozen": True} + + id: uuid.UUID + title: str + body: str + category: str | None = None + tool_key: str | None = None + + +class TemplateCreate(BaseModel): + """新建模板写入字段(owner_id 由服务层补 stub)。""" + + title: str + body: str + category: str | None = None + tool_key: str | None = None + + +class TemplateRepo(Protocol): + """模板读写接口(按 owner_id 隔离;create/delete 只 flush)。""" + + async def create(self, owner_id: uuid.UUID, data: TemplateCreate) -> TemplateView: ... + + async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]: ... + + async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool: ... + + +def _to_view(row: PromptTemplate) -> TemplateView: + return TemplateView( + id=row.id, + title=row.title, + body=row.body, + category=row.category, + tool_key=row.tool_key, + ) + + +class SqlTemplateRepo: + """SQLAlchemy 实现:写/读/删 `prompt_templates`,按 owner_id 过滤(只 flush 不 commit)。""" + + def __init__(self, session: AsyncSession) -> None: + self._s = session + + async def create(self, owner_id: uuid.UUID, data: TemplateCreate) -> TemplateView: + row = PromptTemplate( + owner_id=owner_id, + title=data.title, + body=data.body, + category=data.category, + tool_key=data.tool_key, + ) + self._s.add(row) + await self._s.flush() + await self._s.refresh(row) + return _to_view(row) + + async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]: + rows = ( + await self._s.execute( + select(PromptTemplate) + .where(PromptTemplate.owner_id == owner_id) + .order_by(PromptTemplate.created_at) + ) + ).scalars() + return [_to_view(r) for r in rows] + + async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool: + row = ( + await self._s.execute( + select(PromptTemplate).where( + PromptTemplate.owner_id == owner_id, + PromptTemplate.id == template_id, + ) + ) + ).scalar_one_or_none() + if row is None: + return False + await self._s.delete(row) + await self._s.flush() + return True From 5d8e619408593175e2ae192016bacc65e2a0a871 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 23 Jun 2026 20:24:24 +0200 Subject: [PATCH 6/8] =?UTF-8?q?test(qa):=20F1/F2/F3=20E2E=20=E2=80=94=20?= =?UTF-8?q?=E6=8B=86=E4=B9=A6=E5=85=A5=20rules=20/=20=E7=BB=AD=E5=86=99?= =?UTF-8?q?=E5=BC=8F=E9=93=BE=E5=89=8D=E6=96=87=E6=B3=A8=E5=85=A5=20/=20?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E5=BA=93=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F1: teardown generate→ingest→rules 真落行 + 负向预览不写库(不变量 #3) - F2: continue_volume 两章第二章写章请求含第一章 accepted 正文(前文注入)+ draft_volume 回归守卫 - F3: templates POST→GET→DELETE 真 pg 回环 + title/body 空→422 真 pg + mock 网关零 token,镜像现有 tests/test_*_e2e.py 范式 --- tests/test_chain_continue_volume_e2e.py | 302 ++++++++++++++++++++++++ tests/test_teardown_ingest_e2e.py | 216 +++++++++++++++++ tests/test_templates_e2e.py | 137 +++++++++++ 3 files changed, 655 insertions(+) create mode 100644 tests/test_chain_continue_volume_e2e.py create mode 100644 tests/test_teardown_ingest_e2e.py create mode 100644 tests/test_templates_e2e.py diff --git a/tests/test_chain_continue_volume_e2e.py b/tests/test_chain_continue_volume_e2e.py new file mode 100644 index 0000000..be27a7f --- /dev/null +++ b/tests/test_chain_continue_volume_e2e.py @@ -0,0 +1,302 @@ +"""F2 续写式链 continue_volume 端到端(真 pg + mock 网关零 token)。 + +证明续写式链闭环(不变量 #1/#5):`continue_volume` 每章写作以**上一章 accepted 正文末尾** +作前文引子(复用 `build_continuation_context`)。两章无冲突全自动: +- 第二章写作请求的上下文须含第一章 accepted 正文(断前文注入); +- DB 真源:两章 accepted + 两 digest 行。 +回归守卫:`draft_volume`(非续写)第二章写作请求**不应**含第一章正文(区分两模式)。 + +镜像 `tests/test_chain_workflow_e2e.py`:真 `Gateway` + 假适配器(据 `req.output_schema` 分支 +返 parsed/text,绝不联网)+ 真 `SqlAlchemyLedgerSink`;MemorySaver 检查点。无 pg → skip。 + +关键差异:写章假适配器**逐章返不同正文**(含章号标记),并**记录每次写章请求的 input**—— +故可断言第二章写章请求的 input 含第一章 accepted 正文标记(前文注入),而 draft_volume 不含。 +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from typing import Annotated, Any + +import httpx +import pytest +from asgi_lifespan import LifespanManager +from fastapi import Depends +from langgraph.checkpoint.memory import MemorySaver +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from ww_agents import ( + ContinuityReview, + ForeshadowReview, + PaceReview, + StyleDriftReview, +) +from ww_api.services.digest_extraction import ChapterDigestFacts +from ww_db import get_session, get_sessionmaker +from ww_db.models import ( + Chapter, + ChapterDigest, + ChapterReview, + Job, + Project, + UsageLedger, +) +from ww_llm_gateway import Gateway, SqlAlchemyLedgerSink, resolve_route +from ww_llm_gateway.adapters.base import ( + Capabilities, + ProviderResult, + ProviderUsage, + StreamChunk, +) +from ww_llm_gateway.types import LlmRequest + +_PROVIDER = "deepseek" +_USAGE = ProviderUsage(input_tokens=13, output_tokens=7) + +# 每章正文含唯一章号标记,供「前文注入」断言(第二章请求须含第一章标记)。 +_CHAPTER_MARK = "【第{n}章正文END】" + +_FORESHADOW_REVIEW = ForeshadowReview(planted=[], resolved=[]) +_PACE_REVIEW = PaceReview(water=[], hook=True, beat_map=[1, 2, 3]) +_STYLE_DRIFT = StyleDriftReview(score=88, segments=[]) + + +class _RecordingChainAdapter: + """实现 `ProviderAdapter`:写章逐章返带章号标记的正文 + 记录每次写章请求 input。 + + - `output_schema is None`(写章 `gateway.run`):自增计数返第 N 章正文,记录 req.input。 + - 四审 schema → 无冲突 parsed(让图全自动跑完)。 + - 其余(ChapterDigestFacts)→ 终稿 digest 提炼。 + """ + + provider = _PROVIDER + + def __init__(self) -> None: + self._write_count = 0 + # (request_input, returned_text) 每次写章一条,按写章顺序。 + self.write_calls: list[tuple[str, str]] = [] + + def capabilities(self) -> Capabilities: + return Capabilities(structured_output=True) + + async def complete(self, req: LlmRequest, model: str) -> ProviderResult: + schema = req.output_schema + if schema is None: + self._write_count += 1 + text = f"灵气如潮{_CHAPTER_MARK.format(n=self._write_count)}" + self.write_calls.append((str(req.input), text)) + return ProviderResult(text=text, parsed=None, usage=_USAGE) + if schema is ContinuityReview: + review = ContinuityReview(conflicts=[]) + return ProviderResult(text=review.model_dump_json(), parsed=review, usage=_USAGE) + if schema is ForeshadowReview: + return ProviderResult( + text=_FORESHADOW_REVIEW.model_dump_json(), parsed=_FORESHADOW_REVIEW, usage=_USAGE + ) + if schema is PaceReview: + return ProviderResult( + text=_PACE_REVIEW.model_dump_json(), parsed=_PACE_REVIEW, usage=_USAGE + ) + if schema is StyleDriftReview: + return ProviderResult( + text=_STYLE_DRIFT.model_dump_json(), parsed=_STYLE_DRIFT, usage=_USAGE + ) + facts = ChapterDigestFacts(summary="终稿摘要", events=["少年出山"], locations=["山门"]) + return ProviderResult(text=facts.model_dump_json(), parsed=facts, usage=_USAGE) + + async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]: + yield StreamChunk(usage=_USAGE) + + +@pytest.fixture +async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]: + """真 DB session 工厂;无 pg 时跳过。""" + get_sessionmaker.cache_clear() + maker = get_sessionmaker() + try: + async with maker() as probe: + await probe.execute(select(1)) + except Exception: + pytest.skip("postgres not reachable") + yield maker + await maker.kw["bind"].dispose() + get_sessionmaker.cache_clear() + + +def _gateway_for(adapter: _RecordingChainAdapter, session: AsyncSession) -> Gateway: + return Gateway( + adapters={_PROVIDER: adapter}, + ledger=SqlAlchemyLedgerSink(session), + resolver=resolve_route, + ) + + +def _chain_gateway_override( + adapter: _RecordingChainAdapter, +) -> Callable[[AsyncSession], Gateway]: + def _override(session: Annotated[AsyncSession, Depends(get_session)]) -> Gateway: + return _gateway_for(adapter, session) + + return _override + + +def _builder_override( + adapter: _RecordingChainAdapter, +) -> Callable[[], Callable[[AsyncSession], Awaitable[Gateway]]]: + def _get_builder() -> Callable[[AsyncSession], Awaitable[Gateway]]: + async def _build(session: AsyncSession) -> Gateway: + return _gateway_for(adapter, session) + + return _build + + return _get_builder + + +def _memsaver_override( + saver: MemorySaver, +) -> Callable[[], Callable[[], AbstractAsyncContextManager[MemorySaver]]]: + @asynccontextmanager + async def _ctx() -> AsyncIterator[MemorySaver]: + yield saver + + return lambda: lambda: _ctx() + + +async def _cleanup(e2e_sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> None: + async with e2e_sm() as cleanup: + await cleanup.execute(delete(UsageLedger).where(UsageLedger.project_id == project_uuid)) + await cleanup.execute(delete(Job).where(Job.project_id == project_uuid)) + await cleanup.execute(delete(ChapterDigest).where(ChapterDigest.project_id == project_uuid)) + await cleanup.execute(delete(ChapterReview).where(ChapterReview.project_id == project_uuid)) + await cleanup.execute(delete(Chapter).where(Chapter.project_id == project_uuid)) + await cleanup.execute(delete(Project).where(Project.id == project_uuid)) + await cleanup.commit() + + +def _build_app(adapter: _RecordingChainAdapter, saver: MemorySaver, e2e_sm: Any) -> Any: + from ww_api.main import create_app + from ww_api.services.chain_deps import get_checkpointer_factory + from ww_api.services.project_deps import ( + get_chain_gateway, + get_chain_gateway_builder, + get_digest_gateway_builder, + get_session_factory, + ) + + app = create_app() + app.dependency_overrides[get_chain_gateway] = _chain_gateway_override(adapter) + app.dependency_overrides[get_chain_gateway_builder] = _builder_override(adapter) + app.dependency_overrides[get_session_factory] = lambda: e2e_sm + app.dependency_overrides[get_checkpointer_factory] = _memsaver_override(saver) + app.dependency_overrides[get_digest_gateway_builder] = _builder_override(adapter) + return app + + +async def _run_two_chapter_chain( + adapter: _RecordingChainAdapter, + e2e_sm: async_sessionmaker[AsyncSession], + chain_key: str, +) -> tuple[uuid.UUID, dict[str, Any]]: + """跑两章链(无冲突全自动)→ 返 (project_uuid, job result)。调用方负责 cleanup。""" + saver = MemorySaver() + app = _build_app(adapter, saver, e2e_sm) + transport = httpx.ASGITransport(app=app) + async with LifespanManager(app): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + create_resp = await client.post("/projects", json={"title": f"F2 {chain_key} 作品"}) + assert create_resp.status_code == 201 + project_id = create_resp.json()["id"] + project_uuid = uuid.UUID(project_id) + + run_resp = await client.post( + f"/projects/{project_id}/chains/{chain_key}/run", + json={"start_chapter_no": 1, "count": 2}, + ) + assert run_resp.status_code == 202, run_resp.text + assert run_resp.json()["chain_key"] == chain_key + job_id = run_resp.json()["job_id"] + + job = (await client.get(f"/jobs/{job_id}")).json() + assert job["status"] == "done", job + return project_uuid, job["result"] + + +async def test_continue_volume_injects_prior_chapter_into_second_write( + e2e_sm: async_sessionmaker[AsyncSession], +) -> None: + """用例 1:continue_volume 两章 → 第二章写章请求含第一章 accepted 正文(前文注入)。""" + adapter = _RecordingChainAdapter() + project_uuid: uuid.UUID | None = None + try: + project_uuid, result = await _run_two_chapter_chain(adapter, e2e_sm, "continue_volume") + assert result["written"] == [1, 2] + assert result["completed"] is True + + # 两次写章请求被记录(按章序)。 + assert len(adapter.write_calls) == 2 + first_input, first_text = adapter.write_calls[0] + second_input, _second_text = adapter.write_calls[1] + + # 第一章正文标记(accepted 后即此文本)。 + chapter1_mark = _CHAPTER_MARK.format(n=1) + + # 核心断言:第二章写章请求上下文含第一章 accepted 正文(前文引子注入,不变量 #1/#5)。 + assert chapter1_mark in second_input, second_input + # 续写上下文结构标记(build_continuation_context)也应在第二章请求中。 + assert "前文正文" in second_input + # 第一章无前文 → 其请求不含「第1章标记」(首章占位降级,证明非凭空注入)。 + assert chapter1_mark not in first_input + + # DB 真源:两章 accepted + 两 digest 行。 + async with e2e_sm() as verify: + accepted = ( + ( + await verify.execute( + select(Chapter).where( + Chapter.project_id == project_uuid, Chapter.status == "accepted" + ) + ) + ) + .scalars() + .all() + ) + assert {c.chapter_no for c in accepted} == {1, 2} + digests = ( + ( + await verify.execute( + select(ChapterDigest).where(ChapterDigest.project_id == project_uuid) + ) + ) + .scalars() + .all() + ) + assert {d.chapter_no for d in digests} == {1, 2} + finally: + if project_uuid is not None: + await _cleanup(e2e_sm, project_uuid) + + +async def test_draft_volume_does_not_inject_prior_chapter_into_second_write( + e2e_sm: async_sessionmaker[AsyncSession], +) -> None: + """用例 2(回归守卫):draft_volume 第二章写章请求**不含**第一章正文(区分两模式)。""" + adapter = _RecordingChainAdapter() + project_uuid: uuid.UUID | None = None + try: + project_uuid, result = await _run_two_chapter_chain(adapter, e2e_sm, "draft_volume") + assert result["written"] == [1, 2] + + assert len(adapter.write_calls) == 2 + _first_input, _first_text = adapter.write_calls[0] + second_input, _ = adapter.write_calls[1] + + chapter1_mark = _CHAPTER_MARK.format(n=1) + # draft_volume 非续写:第二章写章请求不应含第一章正文(仅按记忆/digest 量产)。 + assert chapter1_mark not in second_input + # 亦无续写上下文结构标记。 + assert "前文正文(续写须无缝承接)" not in second_input + finally: + if project_uuid is not None: + await _cleanup(e2e_sm, project_uuid) diff --git a/tests/test_teardown_ingest_e2e.py b/tests/test_teardown_ingest_e2e.py new file mode 100644 index 0000000..71beebd --- /dev/null +++ b/tests/test_teardown_ingest_e2e.py @@ -0,0 +1,216 @@ +"""F1 拆书 teardown 落库成 rules 端到端(真 pg + mock 网关零 token)。 + +证明拆书入库闭环(不变量 #3:入库经白名单 gate;预览不写库): +1. teardown `generate` → 200 预览(BookTeardownResult);断言 `rules` 表零新增(预览不写库)。 +2. teardown `ingest`(teardown 产物)→ 201;`rules` 表真落行(拆书结论拍平成可读规则条目)。 + +镜像 `tests/test_t6_toolbox_e2e.py`:真 `Gateway` + 据 `req.output_schema` 分支返 parsed 的 +假适配器(绝不联网)+ 真 `SqlAlchemyLedgerSink`。注入缝 `get_tier_gateway_builder`(generate +调 `build_gateway(spec.tier)`;rules ingest 无 continuity 预检故不调网关)。无 pg → skip。 +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import Annotated + +import httpx +import pytest +from asgi_lifespan import LifespanManager +from fastapi import Depends +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from ww_agents import BookTeardownResult +from ww_db import get_session, get_sessionmaker +from ww_db.models import Project, Rule, UsageLedger +from ww_llm_gateway import Gateway, SqlAlchemyLedgerSink, resolve_route +from ww_llm_gateway.adapters.base import ( + Capabilities, + ProviderResult, + ProviderUsage, + StreamChunk, +) +from ww_llm_gateway.types import LlmRequest, Tier + +_PROVIDER = "deepseek" +_USAGE = ProviderUsage(input_tokens=29, output_tokens=13) + +# teardown 假产物:themes/archetypes/structure/hooks 全非空(拍平成 4 条 rules)。 +_TEARDOWN = BookTeardownResult( + themes=["逆袭打脸", "守护苍生"], + archetypes=["扮猪吃虎的主角", "口嫌体正的女主"], + structure="开篇废柴受辱 → 奇遇得宝 → 步步攀升 → 终战封神", + hooks=["每章末留悬念", "三章一小高潮"], +) + + +class _FakeTeardownAdapter: + """实现 `ProviderAdapter`:仅 BookTeardownResult 一路(拆书 generate),绝不联网。""" + + provider = _PROVIDER + + def capabilities(self) -> Capabilities: + return Capabilities(structured_output=True) + + async def complete(self, req: LlmRequest, model: str) -> ProviderResult: + if req.output_schema is BookTeardownResult: + return ProviderResult(text=_TEARDOWN.model_dump_json(), parsed=_TEARDOWN, usage=_USAGE) + raise AssertionError(f"unexpected output_schema in F1 fake adapter: {req.output_schema!r}") + + async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]: + yield StreamChunk(usage=_USAGE) + raise AssertionError("teardown generation must not stream") + + +@pytest.fixture +async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]: + """真 DB session 工厂;无 pg 时跳过。""" + get_sessionmaker.cache_clear() + maker = get_sessionmaker() + try: + async with maker() as probe: + await probe.execute(select(1)) + except Exception: + pytest.skip("postgres not reachable") + yield maker + await maker.kw["bind"].dispose() + get_sessionmaker.cache_clear() + + +def _tier_builder_override( + adapter: _FakeTeardownAdapter, +) -> Callable[[AsyncSession], Callable[[Tier], Awaitable[Gateway]]]: + def _override( + session: Annotated[AsyncSession, Depends(get_session)], + ) -> Callable[[Tier], Awaitable[Gateway]]: + async def _build(_tier: Tier) -> Gateway: + return Gateway( + adapters={adapter.provider: adapter}, + ledger=SqlAlchemyLedgerSink(session), + resolver=resolve_route, + ) + + return _build + + return _override + + +async def _rule_count(sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> int: + async with sm() as s: + return int( + ( + await s.execute( + select(func.count()).select_from(Rule).where(Rule.project_id == project_uuid) + ) + ).scalar_one() + ) + + +async def _cleanup(sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> None: + async with sm() as cleanup: + await cleanup.execute(delete(UsageLedger).where(UsageLedger.project_id == project_uuid)) + await cleanup.execute(delete(Rule).where(Rule.project_id == project_uuid)) + await cleanup.execute(delete(Project).where(Project.id == project_uuid)) + await cleanup.commit() + + +async def test_teardown_generate_preview_does_not_write_rules( + e2e_sm: async_sessionmaker[AsyncSession], +) -> None: + """用例 1(负向):teardown generate → 200 预览;rules 表零新增(不变量 #3 预览不写库)。""" + from ww_api.main import create_app + from ww_api.services.project_deps import get_tier_gateway_builder + + app = create_app() + app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override( + _FakeTeardownAdapter() + ) + transport = httpx.ASGITransport(app=app) + project_uuid: uuid.UUID | None = None + try: + async with LifespanManager(app): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + create_resp = await client.post("/projects", json={"title": "F1 拆书预览作品"}) + assert create_resp.status_code == 201 + project_id = create_resp.json()["id"] + project_uuid = uuid.UUID(project_id) + + gen_resp = await client.post( + f"/projects/{project_id}/skills/teardown/generate", + json={"text": "第一章……(待拆解的样章正文)", "kind": "某爆款玄幻"}, + ) + assert gen_resp.status_code == 200, gen_resp.text + body = gen_resp.json() + assert body["tool_key"] == "teardown" + assert body["output_kind"] == "BookTeardownResult" + assert body["preview"]["themes"] == ["逆袭打脸", "守护苍生"] + + # DB 真源:预览不写 rules 业务表(守不变量 #3)。 + assert project_uuid is not None + assert await _rule_count(e2e_sm, project_uuid) == 0 + finally: + if project_uuid is not None: + await _cleanup(e2e_sm, project_uuid) + + +async def test_teardown_ingest_persists_rules( + e2e_sm: async_sessionmaker[AsyncSession], +) -> None: + """用例 2:teardown ingest → 201;rules 表真落行(拆书结论拍平成可读规则条目)。""" + from ww_api.main import create_app + from ww_api.services.project_deps import get_tier_gateway_builder + + app = create_app() + # ingest(rules 无 continuity 预检)不触达网关;override 防真凭据探测路径。 + app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override( + _FakeTeardownAdapter() + ) + transport = httpx.ASGITransport(app=app) + project_uuid: uuid.UUID | None = None + try: + async with LifespanManager(app): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + create_resp = await client.post("/projects", json={"title": "F1 拆书入库作品"}) + assert create_resp.status_code == 201 + project_id = create_resp.json()["id"] + project_uuid = uuid.UUID(project_id) + + assert await _rule_count(e2e_sm, project_uuid) == 0 + + ingest_resp = await client.post( + f"/projects/{project_id}/skills/teardown/ingest", + json={ + "teardown": { + "themes": _TEARDOWN.themes, + "archetypes": _TEARDOWN.archetypes, + "structure": _TEARDOWN.structure, + "hooks": _TEARDOWN.hooks, + } + }, + ) + assert ingest_resp.status_code == 201, ingest_resp.text + ok = ingest_resp.json() + assert ok["table"] == "rules" + # 四个非空字段 → 四条 rules(确定性顺序)。 + assert ok["created"] == ["themes", "archetypes", "structure", "hooks"] + assert ok["rejected_tables"] == [] + + # DB 真源:rules 行真落 pg(拍平成可读条目,内容含拆书结论)。 + assert project_uuid is not None + assert await _rule_count(e2e_sm, project_uuid) == 4 + async with e2e_sm() as verify: + rows = ( + (await verify.execute(select(Rule).where(Rule.project_id == project_uuid))) + .scalars() + .all() + ) + blob = "\n".join(r.content for r in rows) + # 拍平内容须含拆书结论(断真落行 + 形变保留)。 + assert "逆袭打脸" in blob + assert "扮猪吃虎的主角" in blob + assert "开篇废柴受辱" in blob + assert "每章末留悬念" in blob + finally: + if project_uuid is not None: + await _cleanup(e2e_sm, project_uuid) diff --git a/tests/test_templates_e2e.py b/tests/test_templates_e2e.py new file mode 100644 index 0000000..ae39785 --- /dev/null +++ b/tests/test_templates_e2e.py @@ -0,0 +1,137 @@ +"""F3 提示词/模板库 端到端(真 pg;无网关——模板库不调 LLM)。 + +证明单用户本地模板库 CRUD 闭环:创建→列出→删除真 pg 回环 + 输入校验(title/body 空 → 422)。 +owner_id 全程 stub(单用户原型)。无 pg → skip。 + +镜像现有 `tests/test_*_e2e.py` 范式:真 `get_sessionmaker`(无 pg skip)+ `LifespanManager` + +`ASGITransport`,DB 真源逐项断言。模板库不触达 LLM 网关,故无需 mock 适配器。 +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator + +import httpx +import pytest +from asgi_lifespan import LifespanManager +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from ww_db import get_sessionmaker +from ww_db.models import PromptTemplate + + +@pytest.fixture +async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]: + """真 DB session 工厂;无 pg 时跳过(每测试清缓存重建 engine、结束 dispose)。""" + get_sessionmaker.cache_clear() + maker = get_sessionmaker() + try: + async with maker() as probe: + await probe.execute(select(1)) + except Exception: + pytest.skip("postgres not reachable") + yield maker + await maker.kw["bind"].dispose() + get_sessionmaker.cache_clear() + + +async def _cleanup(sm: async_sessionmaker[AsyncSession], ids: list[uuid.UUID]) -> None: + if not ids: + return + async with sm() as cleanup: + await cleanup.execute(delete(PromptTemplate).where(PromptTemplate.id.in_(ids))) + await cleanup.commit() + + +async def _template_count(sm: async_sessionmaker[AsyncSession], template_id: uuid.UUID) -> int: + async with sm() as s: + return len( + (await s.execute(select(PromptTemplate).where(PromptTemplate.id == template_id))) + .scalars() + .all() + ) + + +async def test_templates_create_list_delete_roundtrip( + e2e_sm: async_sessionmaker[AsyncSession], +) -> None: + """用例 1:POST → GET → DELETE 真 pg 回环(行真落 pg;删除后表无行)。""" + from ww_api.main import create_app + + app = create_app() + transport = httpx.ASGITransport(app=app) + created_id: uuid.UUID | None = None + try: + async with LifespanManager(app): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + # 1) POST → 201,模板真落 pg。 + create_resp = await client.post( + "/templates", + json={ + "title": "玄幻开篇模板", + "body": "请按照黄金三章节奏,开篇即上钩……", + "category": "开篇", + "tool_key": "opening", + }, + ) + assert create_resp.status_code == 201, create_resp.text + created = create_resp.json() + assert created["title"] == "玄幻开篇模板" + assert created["body"] == "请按照黄金三章节奏,开篇即上钩……" + assert created["category"] == "开篇" + assert created["tool_key"] == "opening" + created_id = uuid.UUID(created["id"]) + + # DB 真源:模板行真落 pg(owner stub)。 + assert await _template_count(e2e_sm, created_id) == 1 + + # 2) GET → 列表含刚建模板。 + list_resp = await client.get("/templates") + assert list_resp.status_code == 200 + listed = list_resp.json() + assert created["id"] in [t["id"] for t in listed] + mine = next(t for t in listed if t["id"] == created["id"]) + assert mine["title"] == "玄幻开篇模板" + assert mine["body"] == "请按照黄金三章节奏,开篇即上钩……" + + # 3) DELETE → 204;DB 真源行被删。 + del_resp = await client.delete(f"/templates/{created['id']}") + assert del_resp.status_code == 204 + assert await _template_count(e2e_sm, created_id) == 0 + + # 删除后再列出不含该模板。 + after = (await client.get("/templates")).json() + assert created["id"] not in [t["id"] for t in after] + + # 4) 删除不存在 → 404 NOT_FOUND envelope。 + missing = await client.delete(f"/templates/{uuid.uuid4()}") + assert missing.status_code == 404 + assert missing.json()["error"]["code"] == "NOT_FOUND" + finally: + if created_id is not None: + await _cleanup(e2e_sm, [created_id]) + + +async def test_templates_create_blank_title_returns_422( + e2e_sm: async_sessionmaker[AsyncSession], +) -> None: + """用例 2:title 空 → 422(schema min_length=1);DB 无行落库。""" + from ww_api.main import create_app + + app = create_app() + transport = httpx.ASGITransport(app=app) + async with LifespanManager(app): + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + before = len((await client.get("/templates")).json()) + + resp = await client.post("/templates", json={"title": "", "body": "非空正文"}) + assert resp.status_code == 422, resp.text + + # body 空亦 422(守输入校验纪律)。 + resp_body = await client.post("/templates", json={"title": "有标题", "body": ""}) + assert resp_body.status_code == 422 + + # 两次 422 均未落库(列表数量不变)。 + after = len((await client.get("/templates")).json()) + assert after == before From 1a402f5ccc67c076b3f40081bfc49d04da3ce466 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Tue, 23 Jun 2026 20:29:48 +0200 Subject: [PATCH 7/8] =?UTF-8?q?feat(web):=20F1/F2/F3=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=8B=86=E4=B9=A6=E5=85=A5=E5=BA=93=E4=B8=BA?= =?UTF-8?q?=E8=A7=84=E5=88=99=20/=20=E7=BB=AD=E5=86=99=E5=BC=8F=E9=93=BE?= =?UTF-8?q?=E9=80=89=E6=8B=A9=20/=20=E6=A8=A1=E6=9D=BF=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gen:api 纳入 /templates、chain continue_volume、teardown rules ingest 端点 - F1:GeneratorRunner 识别拆书为单对象入库(整 preview 作一条 teardown), ingestTable(BookTeardownResult)=rules,buildIngestRequest 组装 teardown 体, 按 isSingleObjectIngest 隐藏勾选框、改文案「入库为规则」 - F2:ChainStarter 加链类型单选(draft_volume 从头写 / continue_volume 续写), chain_key 作 path 参传给 run(CHAIN_KINDS 对齐后端 SUPPORTED_CHAINS) - F3:模板库页 app/templates + 全局 nav 入口 + TemplatesManager(列/建/删,乐观更新回滚); GeneratorRunner 加 TemplateFiller「从模板填入」(body 填进 brief/text,纯前端) - vitest 覆盖 templates/ingest teardown/templateFillTarget/CHAIN_KINDS 纯逻辑 --- apps/web/app/templates/page.tsx | 32 +++ apps/web/components/chain/ChainPage.tsx | 17 +- apps/web/components/chain/ChainStarter.tsx | 37 +++- .../components/templates/TemplatesManager.tsx | 181 ++++++++++++++++ .../components/toolbox/GeneratorRunner.tsx | 44 +++- .../web/components/toolbox/TemplateFiller.tsx | 102 +++++++++ apps/web/lib/api/schema.d.ts | 203 +++++++++++++++++- apps/web/lib/api/server.ts | 11 + apps/web/lib/api/types.ts | 8 + apps/web/lib/chain/chain.test.ts | 17 ++ apps/web/lib/chain/chain.ts | 23 ++ apps/web/lib/nav/items.ts | 1 + apps/web/lib/templates/templates.test.ts | 62 ++++++ apps/web/lib/templates/templates.ts | 39 ++++ apps/web/lib/toolbox/ingest.test.ts | 49 ++++- apps/web/lib/toolbox/ingest.ts | 29 +++ apps/web/lib/toolbox/toolbox.test.ts | 18 ++ apps/web/lib/toolbox/toolbox.ts | 15 ++ 18 files changed, 869 insertions(+), 19 deletions(-) create mode 100644 apps/web/app/templates/page.tsx create mode 100644 apps/web/components/templates/TemplatesManager.tsx create mode 100644 apps/web/components/toolbox/TemplateFiller.tsx create mode 100644 apps/web/lib/templates/templates.test.ts create mode 100644 apps/web/lib/templates/templates.ts diff --git a/apps/web/app/templates/page.tsx b/apps/web/app/templates/page.tsx new file mode 100644 index 0000000..596d083 --- /dev/null +++ b/apps/web/app/templates/page.tsx @@ -0,0 +1,32 @@ +import { AppShell } from "@/components/AppShell"; +import { TemplatesManager } from "@/components/templates/TemplatesManager"; +import { fetchTemplates } from "@/lib/api/server"; +import type { TemplateResponse } from "@/lib/api/types"; + +// 提示词/模板库(F3,全局单用户本地版)。Server Component 预取列表;CRUD 在客户端组件。 +export default async function TemplatesPage() { + let initial: TemplateResponse[] = []; + let loadError = false; + try { + initial = await fetchTemplates(); + } catch { + loadError = true; + } + + return ( + +
+

+ 保存常用提示词,复用时一键填入生成器的 brief / 原文输入。仅本地、单用户,不做分享。 +

+ {loadError ? ( +

+ 无法连接后端服务,请确认 API 已启动后刷新。 +

+ ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/components/chain/ChainPage.tsx b/apps/web/components/chain/ChainPage.tsx index 0ba9178..33716b8 100644 --- a/apps/web/components/chain/ChainPage.tsx +++ b/apps/web/components/chain/ChainPage.tsx @@ -5,7 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { AppShell } from "@/components/AppShell"; import { useToast } from "@/components/Toast"; import { api } from "@/lib/api/client"; -import { chainPhase, chainResultView } from "@/lib/chain/chain"; +import { chainPhase, chainResultView, type ChainKind } from "@/lib/chain/chain"; import { friendlyError } from "@/lib/errors/messages"; import { useJobPoll } from "@/lib/jobs/useJobPoll"; import type { ProjectResponse } from "@/lib/api/types"; @@ -17,8 +17,6 @@ interface ChainPageProps { project: ProjectResponse; } -const CHAIN_KEY = "draft_volume"; - // 多章工作流链页(Scope B B1):发起链(POST run)→job 轮询进度→interrupt 命中则读该章冲突裁决→ // POST resume 续跑。复用 useJobPoll(轮询)+ ConflictCard/decisions(裁决)+ friendlyError(文案)。 // 链是后台任务,走 job 轮询而非 SSE。awaiting 时停轮询(后端把 job 置 awaiting_input, @@ -43,14 +41,18 @@ export function ChainPage({ project }: ChainPageProps) { }, [phase, poll]); const onStart = useCallback( - async (startChapterNo: number, count: number): Promise => { + async ( + chainKey: ChainKind, + startChapterNo: number, + count: number, + ): Promise => { setStarting(true); try { const { data, error } = await api.POST( "/projects/{project_id}/chains/{chain_key}/run", { params: { - path: { project_id: project.id, chain_key: CHAIN_KEY }, + path: { project_id: project.id, chain_key: chainKey }, }, body: { start_chapter_no: startChapterNo, count }, }, @@ -89,7 +91,10 @@ export function ChainPage({ project }: ChainPageProps) { activeNav="chains" >
- void onStart(s, c)} disabled={busy} /> + void onStart(k, s, c)} + disabled={busy} + /> {showProgress && result ? ( diff --git a/apps/web/components/chain/ChainStarter.tsx b/apps/web/components/chain/ChainStarter.tsx index 5ae691d..cc0fd52 100644 --- a/apps/web/components/chain/ChainStarter.tsx +++ b/apps/web/components/chain/ChainStarter.tsx @@ -2,9 +2,11 @@ import { useState } from "react"; +import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain"; + interface ChainStarterProps { - // 发起一条链(起始章 + 章数);父层负责 POST run + 轮询。 - onStart: (startChapterNo: number, count: number) => void; + // 发起一条链(链类型 + 起始章 + 章数);父层负责 POST run + 轮询。 + onStart: (chainKey: ChainKind, startChapterNo: number, count: number) => void; // 链正在运行/等待裁决时禁用(避免重复发起)。 disabled: boolean; } @@ -12,10 +14,12 @@ interface ChainStarterProps { const DEFAULT_START = 1; const DEFAULT_COUNT = 3; const MAX_COUNT = 50; +const DEFAULT_CHAIN: ChainKind = "draft_volume"; // 链发起表单(净新):选起始章号 + 连续写几章 → 调 onStart。 // count 1..50(对齐后端 ChainRunRequest Field 约束;前端先拦一道,越界后端 422 兜底)。 export function ChainStarter({ onStart, disabled }: ChainStarterProps) { + const [chainKey, setChainKey] = useState(DEFAULT_CHAIN); const [start, setStart] = useState(String(DEFAULT_START)); const [count, setCount] = useState(String(DEFAULT_COUNT)); @@ -34,7 +38,7 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) { aria-label="发起多章链" onSubmit={(e) => { e.preventDefault(); - if (valid && !disabled) onStart(startNo, countNo); + if (valid && !disabled) onStart(chainKey, startNo, countNo); }} >
@@ -43,6 +47,33 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) { 从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。

+
+ 链类型 +
+ {CHAIN_KINDS.map((kind) => ( + + ))} +
+