feat(toolbox): F1 拆书 teardown 落库成项目 rules

- 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
This commit is contained in:
Yaojia Wang
2026-06-23 20:00:25 +02:00
parent 44642c37b4
commit 61398a1452
8 changed files with 216 additions and 17 deletions

View File

@@ -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.outline_write_repo import OutlineWriteView
from ww_core.domain.project_repo import ProjectCreate from ww_core.domain.project_repo import ProjectCreate
from ww_core.domain.repositories import OutlineView 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_core.domain.world_entity_repo import WorldEntityWriteView
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
from ww_shared import AppError, ErrorCode 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: class _FakeOutlineReadRepo:
"""续写/按章工具读节拍用的内存替身:缺章 → None端点降级到空节拍""" """续写/按章工具读节拍用的内存替身:缺章 → None端点降级到空节拍"""
@@ -199,6 +211,7 @@ def _make_app(
outline_write_repo: _FakeOutlineWriteRepo | None = None, outline_write_repo: _FakeOutlineWriteRepo | None = None,
chapter_repo: _FakeChapterRepo | None = None, chapter_repo: _FakeChapterRepo | None = None,
outline_read_repo: _FakeOutlineReadRepo | None = None, outline_read_repo: _FakeOutlineReadRepo | None = None,
rule_write_repo: _FakeRuleWriteRepo | None = None,
memory: Any = None, memory: Any = None,
no_creds: bool = False, no_creds: bool = False,
) -> tuple[Any, FakeSession, _FakeWorldWriteRepo, _FakeOutlineWriteRepo]: ) -> tuple[Any, FakeSession, _FakeWorldWriteRepo, _FakeOutlineWriteRepo]:
@@ -210,6 +223,7 @@ def _make_app(
get_outline_read_repo, get_outline_read_repo,
get_outline_write_repo, get_outline_write_repo,
get_project_repo, get_project_repo,
get_rule_write_repo,
get_tier_gateway_builder, get_tier_gateway_builder,
get_world_entity_write_repo, get_world_entity_write_repo,
) )
@@ -220,6 +234,7 @@ def _make_app(
outline_write_repo = outline_write_repo or _FakeOutlineWriteRepo() outline_write_repo = outline_write_repo or _FakeOutlineWriteRepo()
chapter_repo = chapter_repo or _FakeChapterRepo() chapter_repo = chapter_repo or _FakeChapterRepo()
outline_read_repo = outline_read_repo or _FakeOutlineReadRepo() outline_read_repo = outline_read_repo or _FakeOutlineReadRepo()
rule_write_repo = rule_write_repo or _FakeRuleWriteRepo()
async def _build_ok(_tier: str) -> Any: async def _build_ok(_tier: str) -> Any:
return gateway return gateway
@@ -236,6 +251,7 @@ def _make_app(
app.dependency_overrides[get_outline_write_repo] = lambda: outline_write_repo app.dependency_overrides[get_outline_write_repo] = lambda: outline_write_repo
app.dependency_overrides[get_chapter_repo] = lambda: chapter_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_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_session] = lambda: session
app.dependency_overrides[get_tier_gateway_builder] = lambda: ( app.dependency_overrides[get_tier_gateway_builder] = lambda: (
_build_no_creds if no_creds else _build_ok _build_no_creds if no_creds else _build_ok
@@ -285,10 +301,13 @@ async def test_list_toolbox_contains_all_keys() -> None:
"teardown", "teardown",
} }
by_key = {t["key"]: t for t in tools} by_key = {t["key"]: t for t in tools}
# 4 个竞品生成器:非 legacy、纯预览不可入库 # 续写/扩写/降AI:非 legacy、纯预览不可入库
for key in ("continue", "expand", "de-ai", "teardown"): for key in ("continue", "expand", "de-ai"):
assert by_key[key]["is_legacy"] is False assert by_key[key]["is_legacy"] is False
assert by_key[key]["ingestable"] 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 标记。 # legacy 携 legacy_route + is_legacy新工具 ingestable 标记。
assert by_key["worldbuilding"]["is_legacy"] is True assert by_key["worldbuilding"]["is_legacy"] is True
assert by_key["worldbuilding"]["legacy_route"] 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]["beats"] == ["主角觉醒", "初遇反派"]
assert outline_repo.rows[0]["chapter_no"] == 3 assert outline_repo.rows[0]["chapter_no"] == 3
assert session.commits == 1 assert session.commits == 1
# ---- POST .../ingestF1拆书落库成 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 级 rulesowner 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 == [] # 预览不写库

View File

@@ -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.outline_write_repo import OutlineWriteRepo
from ww_core.domain.project_repo import ProjectRepo from ww_core.domain.project_repo import ProjectRepo
from ww_core.domain.repositories import MemoryRepos, OutlineRepo 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.domain.world_entity_repo import WorldEntityWriteRepo
from ww_core.orchestrator import run_generator from ww_core.orchestrator import run_generator
from ww_db import get_session 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.generation import IngestConflictView, WorldEntityCardView
from ww_api.schemas.toolbox import ( from ww_api.schemas.toolbox import (
OutlineSceneIngestView, OutlineSceneIngestView,
TeardownIngestView,
ToolboxListResponse, ToolboxListResponse,
ToolDescriptorView, ToolDescriptorView,
ToolGeneratePreviewResponse, ToolGeneratePreviewResponse,
@@ -53,6 +55,7 @@ from ww_api.services.project_deps import (
get_outline_read_repo, get_outline_read_repo,
get_outline_write_repo, get_outline_write_repo,
get_project_repo, get_project_repo,
get_rule_write_repo,
get_tier_gateway_builder, get_tier_gateway_builder,
get_world_entity_write_repo, get_world_entity_write_repo,
) )
@@ -79,6 +82,7 @@ OutlineReadRepoDep = Annotated[OutlineRepo, Depends(get_outline_read_repo)]
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)] ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
WorldWriteRepoDep = Annotated[WorldEntityWriteRepo, Depends(get_world_entity_write_repo)] WorldWriteRepoDep = Annotated[WorldEntityWriteRepo, Depends(get_world_entity_write_repo)]
OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_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)] GatewayBuilderDep = Annotated[TierGatewayBuilder, Depends(get_tier_gateway_builder)]
SessionDep = Annotated[AsyncSession, Depends(get_session)] SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -316,6 +320,7 @@ async def ingest_with_tool(
memory: MemoryReposDep, memory: MemoryReposDep,
world_write_repo: WorldWriteRepoDep, world_write_repo: WorldWriteRepoDep,
outline_write_repo: OutlineWriteRepoDep, outline_write_repo: OutlineWriteRepoDep,
rule_write_repo: RuleWriteRepoDep,
build_gateway: GatewayBuilderDep, build_gateway: GatewayBuilderDep,
session: SessionDep, session: SessionDep,
) -> ToolIngestResponse: ) -> ToolIngestResponse:
@@ -362,6 +367,16 @@ async def ingest_with_tool(
request_id=request_id, request_id=request_id,
tool_key=tool_key, 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 已限白名单;其余表本期通用入库未实现。 # 描述符的 IngestSpec.table 已限白名单;其余表本期通用入库未实现。
raise AppError(ErrorCode.VALIDATION, f"入库表 {table} 暂未支持通用入库", {"table": table}) raise AppError(ErrorCode.VALIDATION, f"入库表 {table} 暂未支持通用入库", {"table": table})
@@ -493,3 +508,72 @@ async def _ingest_outline(
created_count=len(created), created_count=len(created),
) )
return ToolIngestResponse(table="outline", created=created, rejected_tables=rejected) 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)

View File

@@ -105,12 +105,22 @@ class OutlineSceneIngestView(BaseModel):
hook: str = "" 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): class ToolIngestRequest(BaseModel):
"""通用入库请求:待持久化的产物 + 冲突确认。 """通用入库请求:待持久化的产物 + 冲突确认。
仅声明了 `ingest` 的工具可用。按入库表取用对应字段: 仅声明了 `ingest` 的工具可用。按入库表取用对应字段:
- world_entities金手指/词条)→ `world_entities`(贴 WorldEntityCardViewtype/name/rules - world_entities金手指/词条)→ `world_entities`(贴 WorldEntityCardViewtype/name/rules
- outline细纲→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView - outline细纲→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView
- rules拆书→ `teardown`(贴 TeardownIngestViewthemes/archetypes/structure/hooks
`acknowledge_conflicts`:作者已查看并接受 continuity 预检冲突时置 true 放行(仿 accept gate `acknowledge_conflicts`:作者已查看并接受 continuity 预检冲突时置 true 放行(仿 accept gate
""" """
@@ -123,6 +133,9 @@ class ToolIngestRequest(BaseModel):
scenes: list[OutlineSceneIngestView] = Field( scenes: list[OutlineSceneIngestView] = Field(
default_factory=list, description="outline 入库的场景行(细纲)" default_factory=list, description="outline 入库的场景行(细纲)"
) )
teardown: TeardownIngestView | None = Field(
default=None, description="rules 入库的拆书结论F1拍平为可读规则条目"
)
acknowledge_conflicts: bool = Field( acknowledge_conflicts: bool = Field(
default=False, description="作者已知悉并接受 continuity 冲突 → 放行入库" default=False, description="作者已知悉并接受 continuity 冲突 → 放行入库"
) )

View File

@@ -1,7 +1,8 @@
"""Scope B 竞品快赢 · 4 个生成器的 spec/schema 契约测试。 """Scope B 竞品快赢 · 4 个生成器的 spec/schema 契约测试。
校验每个 spec 的 name/tier/reads/writes/output_schema/scope/input_schema 校验每个 spec 的 name/tier/reads/writes/output_schema/scope/input_schema
对齐不变量 #2只声明 tier/ #34 生成器一律纯预览 writes=[])。不联网、无 DB 对齐不变量 #2只声明 tier/ #3续写/扩写/降AI 纯预览 writes=[];拆书 F1 落库 rules
不联网、无 DB。
""" """
from __future__ import annotations from __future__ import annotations
@@ -19,17 +20,25 @@ from ww_agents import (
teardown_spec, teardown_spec,
) )
# (spec, name, tier, reads, output_schema) # (spec, name, tier, reads, writes, output_schema)
_COMPETITOR_SPECS = [ _COMPETITOR_SPECS = [
(continue_spec, "continue", "writer", ["projects", "outline", "chapters"], ContinuationResult), (
(expand_spec, "expand", "writer", ["projects"], PolishResult), continue_spec,
(de_ai_spec, "de-ai", "analyst", ["projects"], DeAiResult), "continue",
(teardown_spec, "teardown", "analyst", ["projects"], BookTeardownResult), "writer",
["projects", "outline", "chapters"],
[],
ContinuationResult,
),
(expand_spec, "expand", "writer", ["projects"], [], PolishResult),
(de_ai_spec, "de-ai", "analyst", ["projects"], [], DeAiResult),
# F1拆书结论可落库为项目 ruleswrites=["rules"];其余三者仍纯预览)。
(teardown_spec, "teardown", "analyst", ["projects"], ["rules"], BookTeardownResult),
] ]
@pytest.mark.parametrize( @pytest.mark.parametrize(
("spec", "name", "tier", "reads", "output_schema"), ("spec", "name", "tier", "reads", "writes", "output_schema"),
_COMPETITOR_SPECS, _COMPETITOR_SPECS,
) )
def test_competitor_spec_declares_expected_contract( def test_competitor_spec_declares_expected_contract(
@@ -37,12 +46,13 @@ def test_competitor_spec_declares_expected_contract(
name: str, name: str,
tier: str, tier: str,
reads: list[str], reads: list[str],
writes: list[str],
output_schema: type, output_schema: type,
) -> None: ) -> None:
assert spec.name == name assert spec.name == name
assert spec.tier == tier assert spec.tier == tier
assert spec.reads == reads assert spec.reads == reads
assert spec.writes == [] # 不变量 #34 生成器一律纯预览 assert spec.writes == writes # 不变量 #3续写/扩写/降AI 纯预览;拆书落 rules
assert spec.output_schema is output_schema assert spec.output_schema is output_schema
assert spec.scope == "builtin" assert spec.scope == "builtin"
assert spec.input_schema is None # 注入材料为序列化文本,非结构化入参 assert spec.input_schema is None # 注入材料为序列化文本,非结构化入参

View File

@@ -571,8 +571,8 @@ class DeAiResult(BaseModel):
class BookTeardownResult(BaseModel): class BookTeardownResult(BaseModel):
"""拆书生成器结构化产出样本作品的结构化拆解Scope B 竞品快赢)。 """拆书生成器结构化产出样本作品的结构化拆解Scope B 竞品快赢)。
纯预览产物——不映射任何业务表、不入库(`teardown_spec.writes=[]`,不变量 #3 F1可经 ingest 端点把结论拍平为项目 `rules` 条目落库(`teardown_spec.writes=["rules"]`
拆书「落库成 rules」为可选 follow-up需 `KNOWN_TABLES` + `_ingest_rules`),本期不做 入库仍经白名单 gate不变量 #3预览generate不写库
""" """
themes: list[str] = Field( themes: list[str] = Field(

View File

@@ -799,6 +799,6 @@ teardown_spec = AgentSpec(
input_schema=None, # 注入材料为序列化文本(设定 + 样本 + 需求),非结构化入参 input_schema=None, # 注入材料为序列化文本(设定 + 样本 + 需求),非结构化入参
output_schema=BookTeardownResult, output_schema=BookTeardownResult,
reads=["projects"], reads=["projects"],
writes=[], # 纯预览,不写库(不变量 #3 writes=["rules"], # F1拆书结论可落库为项目 rules入库仍经 ingest 白名单 gate#3
scope="builtin", scope="builtin",
) )

View File

@@ -19,13 +19,13 @@ _NEW_KEYS = {
"glossary", "glossary",
"opening", "opening",
"fine-outline", "fine-outline",
# 竞品快赢Scope B续写 / 扩写 / 降AI率 / 拆书(全 preview-only # 竞品快赢Scope B续写/扩写/降AI率 preview-only拆书 F1 可落库 rules
"continue", "continue",
"expand", "expand",
"de-ai", "de-ai",
"teardown", "teardown",
} }
_INGEST_KEYS = {"golden-finger", "glossary", "fine-outline"} _INGEST_KEYS = {"golden-finger", "glossary", "fine-outline", "teardown"}
def test_toolbox_has_all_tools() -> None: 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["glossary"].ingest.table == "world_entities"
assert TOOLBOX["fine-outline"].ingest is not None assert TOOLBOX["fine-outline"].ingest is not None
assert TOOLBOX["fine-outline"].ingest.table == "outline" 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: def test_chapter_tools_have_chapter_no_field() -> None:

View File

@@ -197,7 +197,7 @@ TOOLBOX: dict[str, GeneratorTool] = {
input_fields=[_chapter_no_field(), _BRIEF_FIELD], input_fields=[_chapter_no_field(), _BRIEF_FIELD],
ingest=IngestSpec(table="outline"), ingest=IngestSpec(table="outline"),
), ),
# ---- 竞品快赢Scope B续写 / 扩写 / 降AI率 / 拆书(全 preview-onlywrites=[]---- # ---- 竞品快赢Scope B续写/扩写/降AI率 preview-only拆书 F1 可落库 rules ----
"continue": GeneratorTool( "continue": GeneratorTool(
key="continue", key="continue",
title="续写生成器", title="续写生成器",
@@ -252,6 +252,7 @@ TOOLBOX: dict[str, GeneratorTool] = {
_SOURCE_TEXT_FIELD, _SOURCE_TEXT_FIELD,
_BRIEF_FIELD, _BRIEF_FIELD,
], ],
ingest=IngestSpec(table="rules"), # F1拆书结论拍平为项目 rules 条目落库
), ),
} }