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:
@@ -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 .../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 == [] # 预览不写库
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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`(贴 WorldEntityCardView:type/name/rules);
|
- 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)。
|
`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 冲突 → 放行入库"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)/ #3(4 生成器一律纯预览 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:拆书结论可落库为项目 rules(writes=["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 == [] # 不变量 #3:4 生成器一律纯预览
|
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 # 注入材料为序列化文本,非结构化入参
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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-only,writes=[])----
|
# ---- 竞品快赢(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 条目落库
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user