From 51bfb6e3205376e15b253140a5ec24d0b30e74ee Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Mon, 6 Jul 2026 17:57:31 +0200 Subject: [PATCH] =?UTF-8?q?test(qa):=20craft=20=E6=88=90=E7=A8=BF=E8=B4=A8?= =?UTF-8?q?=E9=87=8F=E5=89=8D=E5=90=8E=E5=AF=B9=E7=85=A7=E8=AF=84=E4=BC=B0?= =?UTF-8?q?=E5=9B=9E=E8=B7=AF=EF=BC=88=E7=81=B5=E6=84=9F=20F2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 固定 3 题材金标准输入(仙侠/都市/悬疑,各含最小 spec+world+outline 及开篇章), 经真实 assemble()+build_write_request() 组装 craft-on / craft-off 两版写章请求: - dry 模式(默认,零成本、免真实 LLM,可进 CI):断言 craft 教条块、genre 片段、 开篇黄金三章 marker 确实按 fixture 注入正确内容,且 craft-off 基线不含这三者; 并把两版将发送给模型的 prompt 并排 dump 供人眼看。这些断言即 tests/test_craft_eval.py, 是 craft/审查注入路径的回归门禁。 - --live 模式(真实付费 LLM,绝不进 CI):经 gateway 各生成一章 + analyst 五维裁判, 凭据读 DB、路由走 tier_routing,与生产写章同一网关路径。 入口 `uv run python -m tests.craft_eval [--live]`;用法/成本/门禁说明见 README.md。 --- tests/craft_eval/README.md | 59 ++++++ tests/craft_eval/__init__.py | 38 ++++ tests/craft_eval/__main__.py | 104 ++++++++++ tests/craft_eval/fixtures.py | 158 +++++++++++++++ tests/craft_eval/harness.py | 363 +++++++++++++++++++++++++++++++++++ tests/craft_eval/live.py | 127 ++++++++++++ tests/test_craft_eval.py | 121 ++++++++++++ 7 files changed, 970 insertions(+) create mode 100644 tests/craft_eval/README.md create mode 100644 tests/craft_eval/__init__.py create mode 100644 tests/craft_eval/__main__.py create mode 100644 tests/craft_eval/fixtures.py create mode 100644 tests/craft_eval/harness.py create mode 100644 tests/craft_eval/live.py create mode 100644 tests/test_craft_eval.py diff --git a/tests/craft_eval/README.md b/tests/craft_eval/README.md new file mode 100644 index 0000000..42da193 --- /dev/null +++ b/tests/craft_eval/README.md @@ -0,0 +1,59 @@ +# craft A/B 成稿质量前后对照评估回路(灵感 F2) + +把「加 craft 杠杆」与「验证 craft 有效」绑成一件事的评估回路。每引入或改动一个 craft +杠杆(① 写章教条 / F1 genre 片段 / 开篇黄金三章 / 后续 ② 反哺、③ 人物塑造审查等), +就跑一次这里,做 craft-on vs craft-off 的前后对照——**没有它,craft 改动都是盲飞, +很可能把文风做得更 AI 公式化而不自知**(见计划 §5 风险 #13)。 + +## 它是什么 + +- 固定一组**金标准题材输入**(`fixtures.py`:仙侠 / 都市 / 悬疑,各含最小 spec+world+outline, + 且都是开篇章以覆盖黄金三章 marker)。 +- 对每份 fixture 走**真实生产组装路径**(`assemble()` + `build_write_request()`)产出两版 + 写章请求: + - **craft-on** = 生产路径:craft 教条置顶 system 块 + genre 片段进 stable_core 缓存前缀 + + 开篇 marker 进 volatile。 + - **craft-off** = 同一 fixture 抹掉 craft 层的基线(剥掉 genre/开篇两节、不加教条块)。 + 两版**唯一差异即 craft 贡献**,便于并排 diff 与 LLM 裁判对比。 + +## 两种模式 + +### dry(默认,零成本、免真实 LLM) + +```bash +export PATH="$HOME/.local/bin:$PATH" +uv run python -m tests.craft_eval # 全部题材 +uv run python -m tests.craft_eval --genre 仙侠 +``` + +只做**确定性校验 + prompt 并排 dump**:断言 craft 教条块、genre 片段、开篇 marker 确实按 +fixture 注入了正确内容(且 craft-off 基线不含这三者),并把两版**将发送给模型的 prompt** +并排写进报告供人眼看。**不碰 LLM**,任一断言失败退出码非零——这些断言即 +`tests/test_craft_eval.py`,随常规 `pytest` / CI 跑,是 craft 注入路径的回归门禁。 + +### live(真实付费生成 + LLM 五维裁判) + +```bash +uv run python -m tests.craft_eval --live +``` + +**成本警告**:`--live` **真实调用付费 LLM**——每个题材跑 writer 生成 craft-on/craft-off 两章 ++ analyst 五维裁判 1 次(3 题材 ≈ 6 次写章 + 3 次裁判)。凭据从 DB 读、路由走 `tier_routing`, +与生产写章同一条网关路径(需 pg 在跑且已配好 provider 凭据;`--live` 前会先跑 dry 校验, +未全绿则拒绝进入付费生成)。**绝不进 CI**:live 路径在 `live.py`,pytest 不导入它。 + +报告默认写系统临时目录(`--out PATH` 自定义);含每题材的 dry 校验清单、prompt 并排、 +以及 live 模式下的两版成稿与五维裁判结论。 + +## 加了新 craft 杠杆后怎么用它把关 + +1. 改 craft(教条 `write_craft.md` / `GENRE_CRAFT` / 新审查维度……)。 +2. 跑 `pytest tests/test_craft_eval.py` 确认注入路径没被改坏(dry 门禁)。 +3. 跑 `--live` 生成前后对照,人眼 + LLM 五维裁判判断**是更对味还是更公式化**—— + 据此决定该杠杆保留 / 回退(计划 §5 通用回滚:每杠杆可独立回退)。 + +## 加题材 / 调 fixture + +在 `fixtures.py` 追加一个 `FixtureCase`(`genre` 必须命中 `ww_config.GENRE_CRAFT` 才有 +片段可注入)。`harness.py` 的 craft-off 靠 `assemble` 的节标题(`本作题材写法` / `开篇黄金三章`) +剥离——上游若改节名,剥离失配会被 dry 断言当场炸出(craft-off 里仍含片段 → 断言失败)。 diff --git a/tests/craft_eval/__init__.py b/tests/craft_eval/__init__.py new file mode 100644 index 0000000..bbf35c7 --- /dev/null +++ b/tests/craft_eval/__init__.py @@ -0,0 +1,38 @@ +"""craft A/B 成稿质量前后对照评估回路(灵感 F2)。 + +固定金标准题材输入 → 组装 craft-on / craft-off 两版写章请求 → dry 确定性校验(零成本、 +可进 CI)或 `--live` 真实生成 + LLM 五维裁判。用法见 `README.md` / `python -m tests.craft_eval -h`。 +""" + +from __future__ import annotations + +from .fixtures import FIXTURES, FixtureCase +from .harness import ( + OPENING_CHAPTER_LIMIT, + CaseReport, + Check, + PromptPair, + all_passed, + build_prompt_pair, + input_text, + render_report, + system_text, + verify_case, + write_report, +) + +__all__ = [ + "FIXTURES", + "OPENING_CHAPTER_LIMIT", + "CaseReport", + "Check", + "FixtureCase", + "PromptPair", + "all_passed", + "build_prompt_pair", + "input_text", + "render_report", + "system_text", + "verify_case", + "write_report", +] diff --git a/tests/craft_eval/__main__.py b/tests/craft_eval/__main__.py new file mode 100644 index 0000000..e81cfb8 --- /dev/null +++ b/tests/craft_eval/__main__.py @@ -0,0 +1,104 @@ +"""craft A/B 评估脚本入口(灵感 F2)。 + + # dry(默认,零成本、免真实 LLM)——组装+校验+并排 dump prompt + uv run python -m tests.craft_eval + + # live(真实付费生成 + LLM 五维裁判) + uv run python -m tests.craft_eval --live + +选项:`--genre 仙侠` 只跑某题材;`--out PATH` 指定报告路径;`--quiet` 少打印。 +退出码:dry 任一断言失败 → 非零(可当回归门禁)。详见 README.md。 +""" + +from __future__ import annotations + +import argparse +import asyncio +import sys +import tempfile +from pathlib import Path + +from .fixtures import FIXTURES, FixtureCase +from .harness import ( + CaseReport, + all_passed, + build_prompt_pair, + render_report, + verify_case, + write_report, +) + + +def _select(genre: str | None) -> list[FixtureCase]: + if genre is None: + return list(FIXTURES) + picked = [c for c in FIXTURES if c.genre == genre] + if not picked: + raise SystemExit(f"没有题材为 {genre!r} 的 fixture;可选:{[c.genre for c in FIXTURES]}") + return picked + + +def _default_out(live: bool) -> Path: + name = "craft_ab_live.md" if live else "craft_ab_dry.md" + return Path(tempfile.gettempdir()) / name + + +async def _run(args: argparse.Namespace) -> int: + cases = _select(args.genre) + + reports: list[CaseReport] = [] + for case in cases: + pair = await build_prompt_pair(case) + reports.append(CaseReport(case=case, pair=pair, checks=verify_case(case, pair))) + + failed = [(r.case.slug, c) for r in reports for c in r.checks if not c.passed] + if not args.quiet: + for r in reports: + status = "OK" if all_passed(r.checks) else "FAIL" + print(f"[dry] {r.case.slug:10s} {r.case.genre} 第{r.pair.chapter_no}章 {status}") + for slug, chk in failed: + print(f"[dry][FAIL] {slug} · {chk.name}: {chk.detail}", file=sys.stderr) + + if args.live: + if failed: + print("[live] dry 校验未全绿,拒绝进入付费生成——先修正注入。", file=sys.stderr) + return 1 + from .live import run_live, summarize + + print("[live] 开始真实生成(付费)……", flush=True) + reports = await run_live(reports) + print("\n[live] 摘要:\n" + summarize(reports)) + + out = Path(args.out) if args.out else _default_out(args.live) + write_report(render_report(reports, live=args.live), out) + print(f"\n[out] 报告写至 {out}") + + return 1 if failed else 0 + + +def main() -> None: + parser = argparse.ArgumentParser( + prog="python -m tests.craft_eval", + description="craft A/B 成稿质量前后对照评估(灵感 F2)", + ) + parser.add_argument( + "--live", + action="store_true", + help="真实调用付费 LLM 生成两版正文 + 五维裁判(默认 dry,免真实 LLM)", + ) + parser.add_argument("--genre", default=None, help="只跑指定题材(如 仙侠/都市/悬疑)") + parser.add_argument("--out", default=None, help="报告输出路径(默认写系统临时目录)") + parser.add_argument("--quiet", action="store_true", help="减少 stdout 打印") + args = parser.parse_args() + + if args.live: + # get_settings() 按 cwd 读 .env;确保 cwd = 仓库根(本文件在 tests/craft_eval/ 下三层)。 + import os + + os.chdir(Path(__file__).resolve().parents[2]) + + raise SystemExit(asyncio.run(_run(args))) + + +if __name__ == "__main__": + main() diff --git a/tests/craft_eval/fixtures.py b/tests/craft_eval/fixtures.py new file mode 100644 index 0000000..0863227 --- /dev/null +++ b/tests/craft_eval/fixtures.py @@ -0,0 +1,158 @@ +"""金标准输入 fixtures(灵感 F2)——3 个不同题材的最小 spec+world+outline。 + +每个 fixture 是一份**自造、不依赖 DB** 的最小设定:书级蓝本(含 genre)+ 世界观硬规则 + +定型主角 + 本章大纲节拍。评估脚本据此组装 craft-on / craft-off 两版写章请求。 +新增题材只需追加一个 `FixtureCase`(genre 必须命中 `ww_config.GENRE_CRAFT` 才有片段可注)。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from ww_core.domain.repositories import CharacterView, ProjectSpecView, WorldEntityView + + +@dataclass(frozen=True) +class FixtureCase: + """一份金标准写章输入(题材 / 章号 / 书级蓝本 / 世界观 / 主角 / 本章节拍)。""" + + slug: str + genre: str + chapter_no: int + spec: ProjectSpecView + world: tuple[WorldEntityView, ...] + characters: tuple[CharacterView, ...] + beats: dict[str, Any] = field(default_factory=dict) + + +_XIANXIA = FixtureCase( + slug="xianxia", + genre="仙侠", + chapter_no=3, # 开篇章(≤3)——覆盖黄金三章 marker 注入 + spec=ProjectSpecView( + title="逆脉证道", + genre="仙侠", + logline="废脉少年叶尘为救绝症妹妹寻回春灵药,滴血觉醒上古逆天功法,逆修九境搅动仙门风云。", + premise="天地灵气稀薄,修行看灵脉品阶;废脉者纳灵极慢,被视作修行绝路。", + theme="卑微者的不屈与救赎——修的是道,护的是人。", + tone="热血隐忍", + narrative_pov="第三人称限知", + ending_type="正剧", + ), + world=( + WorldEntityView( + type="设定", + name="九境修为", + rules={ + "境界": ["淬体", "开脉", "灵动", "纳灵", "凝魂", "化虚", "通玄", "渡劫", "证道"], + "每境": "分前中后三重", + }, + ), + WorldEntityView( + type="设定", + name="灵脉", + rules={"作用": "决定纳灵速度", "废脉": "纳灵极慢,被视作修行绝路"}, + ), + ), + characters=( + CharacterView( + name="叶尘", + role="主角", + motive="为身患寒髓之症、命不久矣的妹妹叶暖寻回春灵药", + backstory="青云宗外门弟子,天生废脉、常年受人轻慢,性子沉静隐忍。", + speech_tics={"风格": "话少、点到为止,逼到绝境时语气反而更冷更稳"}, + ), + ), + beats={ + "summary": "叶尘被同门构陷逐出青云宗,独行荒山避雨于古洞," + "滴血觉醒《逆脉真解》,一招震退追杀者。", + "entities": ["叶尘"], + "text": "叶尘 古洞 滴血 觉醒 逆脉真解 震退 追杀者", + }, +) + +_URBAN = FixtureCase( + slug="urban", + genre="都市", + chapter_no=1, # 开篇第一章 + spec=ProjectSpecView( + title="重启我的黄金时代", + genre="都市", + logline="落魄程序员林川一夜重生回到二十年前,凭前世记忆在时代风口逆天改命。", + premise="重生回到世纪初互联网草莽年代,机会遍地、格局未定。", + theme="不甘平庸者抓住的第二次机会。", + tone="爽快励志", + narrative_pov="第一人称", + ending_type="HE", + ), + world=( + WorldEntityView( + type="设定", + name="重生规则", + rules={ + "回溯": "重生回到 2003 年的大学时代", + "记忆": "完整保留前世记忆与二十年行业趋势", + }, + ), + ), + characters=( + CharacterView( + name="林川", + role="主角", + motive="改写前世潦倒猝死的结局,护住家人、抓住时代机遇翻身", + backstory="前世三十五岁猝死于出租屋的落魄外包程序员。", + speech_tics={"风格": "表面随和、心里门儿清,说话带点自嘲"}, + ), + ), + beats={ + "summary": "林川在出租屋猝死后睁眼回到 2003 年的大学宿舍," + "认出这是重来一次的机会,立下第一个目标。", + "entities": ["林川"], + "text": "林川 重生 2003 大学宿舍 认清机会", + }, +) + +_SUSPENSE = FixtureCase( + slug="suspense", + genre="悬疑", + chapter_no=2, # 开篇第二章 + spec=ProjectSpecView( + title="雾隐镇失踪案", + genre="悬疑", + logline="刑警周砚回到多雾的故乡小镇调查连环失踪案,线索却一步步指向自己尘封的童年。", + premise="常年浓雾笼罩的封闭小镇,二十年前也发生过同样的失踪,无人被起诉。", + theme="逼近真相与自我和解的代价。", + tone="冷峻压抑", + narrative_pov="第三人称限知", + ending_type="开放", + ), + world=( + WorldEntityView( + type="设定", + name="雾隐镇", + rules={ + "环境": "常年浓雾、信号时断时续", + "旧案": "二十年前发生过同类失踪,卷宗离奇缺页", + }, + ), + ), + characters=( + CharacterView( + name="周砚", + role="主角", + motive="查清连环失踪真相,也为解开自己童年缺失的那段记忆", + backstory="市局刑警,幼年从雾隐镇被送走,对故乡的记忆断片。", + speech_tics={"风格": "话短、爱用反问,审讯时不动声色"}, + ), + ), + beats={ + "summary": "周砚勘查第二名失踪者的住所," + "在墙缝里发现一张与二十年前旧案笔迹相同的纸条,心头一凛。", + "entities": ["周砚"], + "text": "周砚 现场勘查 墙缝 纸条 二十年前 旧案", + }, +) + +# 金标准题材集:仙侠 / 都市 / 悬疑(各含开篇章,覆盖黄金三章 marker)。 +FIXTURES: tuple[FixtureCase, ...] = (_XIANXIA, _URBAN, _SUSPENSE) diff --git a/tests/craft_eval/harness.py b/tests/craft_eval/harness.py new file mode 100644 index 0000000..2b6ab77 --- /dev/null +++ b/tests/craft_eval/harness.py @@ -0,0 +1,363 @@ +"""craft A/B 评估回路核心(灵感 F2)——组装请求 + dry 确定性校验 + 报告渲染。 + +**零 LLM 依赖**:本模块只走确定性组装(真 `assemble()` + 真 `build_write_request()`)与 +纯字符串校验,可安全进 CI。真实生成/裁判在 `live.py`(惰性导入,不进本模块)。 + +craft-on = 生产路径(`assemble` 注入 genre 片段进 stable_core + 开篇 marker 进 volatile, +`build_write_request` 把 craft 教条置为最前 system 块)。 +craft-off = 同一 fixture 抹掉 craft 层的基线:剥掉 genre/开篇两节 + 不加教条块—— +两版**唯一差异即 craft 贡献**,便于人眼并排 diff 与 LLM 裁判对比。 +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from typing import Any + +from ww_core.domain.repositories import ( + CharacterView, + DigestView, + ForeshadowView, + MemoryRepos, + OutlineView, + ProjectSpecView, + RuleView, + StyleView, + WorldEntityView, +) +from ww_core.memory.assemble import OPENING_CHAPTER_LIMIT, assemble +from ww_core.orchestrator.write_node import build_write_request +from ww_llm_gateway.types import Block, LlmRequest, Scope + +from .fixtures import FixtureCase + +# 单用户原型 stub(dry 路径不落库,取值无关紧要,仅为满足 Scope)。 +PROJECT_ID = uuid.UUID("00000000-0000-0000-0000-0000000c7a17") +USER_ID = uuid.UUID(int=1) + +# assemble 内联的 craft 节标题(assemble.py:97 `本作题材写法` / :141 `开篇黄金三章`)。 +# craft-off 据此剥离;若上游改名,本表失配 → craft-off 仍含片段 → 校验断言会当场炸出漂移。 +_GENRE_SECTION_TITLE = "本作题材写法" +_OPENING_SECTION_TITLE = "开篇黄金三章" + +__all__ = [ + "OPENING_CHAPTER_LIMIT", + "PROJECT_ID", + "USER_ID", + "CaseReport", + "Check", + "PromptPair", + "all_passed", + "build_prompt_pair", + "input_text", + "render_report", + "system_text", + "verify_case", + "write_report", +] + + +# ---- 内存 fake repositories(只喂 assemble 所需,其余返回空)---- + + +@dataclass +class _FakeOutlineRepo: + """按查询章号合成 OutlineView(复用同一份 beats),支持任意 chapter_no 的边界测试。""" + + volume: int + beats: dict[str, Any] + + async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None: + return OutlineView(volume=self.volume, chapter_no=chapter_no, beats=dict(self.beats)) + + async def list_for_project( + self, project_id: uuid.UUID + ) -> list[OutlineView]: # pragma: no cover + return [] + + +@dataclass +class _FakeCharacterRepo: + rows: tuple[CharacterView, ...] + + async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]: + return list(self.rows) + + +@dataclass +class _FakeWorldEntityRepo: + rows: tuple[WorldEntityView, ...] + + async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]: + return list(self.rows) + + +@dataclass +class _FakeDigestRepo: + async def recent(self, project_id: uuid.UUID, k: int) -> list[DigestView]: + return [] + + +@dataclass +class _FakeForeshadowRepo: + async def list_for_codes(self, project_id: uuid.UUID, codes: list[str]) -> list[ForeshadowView]: + return [] + + +@dataclass +class _FakeStyleRepo: + async def latest(self, project_id: uuid.UUID) -> StyleView | None: + return None + + +@dataclass +class _FakeRulesRepo: + async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]: + return [] + + +@dataclass +class _FakeProjectSpecRepo: + row: ProjectSpecView + + async def spec(self, project_id: uuid.UUID) -> ProjectSpecView | None: + return self.row + + +def _build_repos(case: FixtureCase) -> MemoryRepos: + volume = 1 + return MemoryRepos( + outline=_FakeOutlineRepo(volume=volume, beats=case.beats), + character=_FakeCharacterRepo(case.characters), + world_entity=_FakeWorldEntityRepo(case.world), + digest=_FakeDigestRepo(), + foreshadow=_FakeForeshadowRepo(), + style=_FakeStyleRepo(), + rules=_FakeRulesRepo(), + project=_FakeProjectSpecRepo(case.spec), + review=None, # 冲突裁决反哺不在 craft A/B 范围;优雅降级不反哺 + ) + + +# ---- craft-on / craft-off 请求组装 ---- + + +@dataclass(frozen=True) +class PromptPair: + craft_on: LlmRequest + craft_off: LlmRequest + chapter_no: int + + +def _strip_section(text: str, title: str) -> str: + """按 `## {title}` 剥掉一节(节以 "\\n\\n" 分隔,节体无空行——见 assemble._section)。""" + header = f"## {title}" + kept = [chunk for chunk in text.split("\n\n") if not chunk.startswith(header)] + return "\n\n".join(kept) + + +async def build_prompt_pair(case: FixtureCase, *, chapter_no: int | None = None) -> PromptPair: + """据 fixture 组装 craft-on(生产路径)与 craft-off(抹除 craft 层的基线)两版请求。""" + ch = chapter_no if chapter_no is not None else case.chapter_no + ctx = await assemble(_build_repos(case), PROJECT_ID, ch) + + craft_on = build_write_request( + stable_core=ctx.stable_core, + volatile=ctx.volatile, + user_id=USER_ID, + project_id=PROJECT_ID, + ) + + off_stable = _strip_section(ctx.stable_core, _GENRE_SECTION_TITLE) + off_volatile = _strip_section(ctx.volatile, _OPENING_SECTION_TITLE) + craft_off = LlmRequest( + tier="writer", + system=[Block(text=off_stable, cache=True)], + input=off_volatile, + stream=True, + scope=Scope(user_id=USER_ID, project_id=PROJECT_ID), + ) + return PromptPair(craft_on=craft_on, craft_off=craft_off, chapter_no=ch) + + +# ---- prompt 文本抽取(供校验与报告)---- + + +def system_text(req: LlmRequest) -> str: + return "\n\n".join(b.text for b in req.system) + + +def input_text(req: LlmRequest) -> str: + if isinstance(req.input, str): + return req.input + return "\n\n".join(b.text for b in req.input) + + +# ---- dry 确定性校验(无 LLM,可进 CI)---- + + +@dataclass(frozen=True) +class Check: + name: str + passed: bool + detail: str + + +def _load_doctrine() -> str: + # 惰性导入避免 fixtures 模块与本模块的导入顺序耦合。 + from ww_agents import load_craft_doctrine + + return load_craft_doctrine() + + +def _genre_fragment(genre: str) -> str: + from ww_config import genre_craft_fragment + + fragment = genre_craft_fragment(genre) + if fragment is None: # pragma: no cover - fixtures 已保证命中 + raise ValueError(f"题材 {genre!r} 无 GENRE_CRAFT 片段") + return fragment + + +def verify_case(case: FixtureCase, pair: PromptPair) -> list[Check]: + """对一份 fixture 的 craft-on/off 请求做确定性断言(craft 改动的回归门禁)。""" + on, off = pair.craft_on, pair.craft_off + doctrine = _load_doctrine() + fragment = _genre_fragment(case.genre) + on_sys, on_in = system_text(on), input_text(on) + off_sys, off_in = system_text(off), input_text(off) + is_opening = pair.chapter_no <= OPENING_CHAPTER_LIMIT + + checks: list[Check] = [ + Check( + "craft_on_doctrine_first_block", + len(on.system) >= 2 and on.system[0].text == doctrine and on.system[0].cache, + "教条须为首个 system 块、字节等于 load_craft_doctrine()、cache=True(#9)", + ), + Check( + "craft_on_genre_in_stable_not_doctrine_not_input", + fragment in on.system[1].text + and fragment not in on.system[0].text + and fragment not in on_in, + f"genre 片段须进 stable_core 缓存块、不进教条块/volatile({case.genre})", + ), + Check( + "craft_on_opening_marker", + (_OPENING_SECTION_TITLE in on_in and _OPENING_SECTION_TITLE not in on_sys) + if is_opening + else (_OPENING_SECTION_TITLE not in on_in), + f"开篇 marker:第 {pair.chapter_no} 章" + + ("须进 volatile、不进缓存 system" if is_opening else "非开篇章须不注入"), + ), + Check( + "craft_on_tier_writer", + on.tier == "writer", + "不变量 #2:只声明 tier=writer,不写具体 model", + ), + Check( + "craft_off_no_doctrine", + doctrine not in off_sys and doctrine not in off_in, + "craft-off 基线不含教条块", + ), + Check( + "craft_off_no_genre_fragment", + fragment not in off_sys and fragment not in off_in, + "craft-off 基线不含 genre 片段", + ), + Check( + "craft_off_no_opening_marker", + _OPENING_SECTION_TITLE not in off_in, + "craft-off 基线不含开篇 marker", + ), + Check( + "craft_off_retains_body", + case.spec.title in off_sys and "请创作第" in off_in, + "剥离未误伤本体:书名与写作指令仍在", + ), + ] + return checks + + +def all_passed(checks: list[Check]) -> bool: + return all(c.passed for c in checks) + + +# ---- 报告渲染 ---- + + +@dataclass(frozen=True) +class CaseReport: + case: FixtureCase + pair: PromptPair + checks: list[Check] + live: Any = None # live.LiveResult | None(惰性,避免 dry 路径导入 live) + + +def _fence(label: str, body: str) -> str: + return f"**{label}**\n\n```\n{body}\n```" + + +def _render_pair(pair: PromptPair) -> str: + on, off = pair.craft_on, pair.craft_off + return "\n\n".join( + [ + "#### craft-on(发送给模型的 prompt)", + _fence("system(缓存前缀)", system_text(on)), + _fence("input(volatile)", input_text(on)), + "#### craft-off(基线 prompt)", + _fence("system(缓存前缀)", system_text(off)), + _fence("input(volatile)", input_text(off)), + ] + ) + + +def _render_live(live: Any) -> str: + return "\n\n".join( + [ + "#### 生成结果(--live)", + f"served_by: `{live.served_by}`", + _fence(f"craft-on 成稿({live.chars_on} 字)", live.chapter_on), + _fence(f"craft-off 成稿({live.chars_off} 字)", live.chapter_off), + "#### LLM 五维裁判", + live.judge_text, + ] + ) + + +def render_report(reports: list[CaseReport], *, live: bool) -> str: + total_checks = sum(len(r.checks) for r in reports) + failed = sum(1 for r in reports for c in r.checks if not c.passed) + lines: list[str] = [ + "# craft A/B 评估报告(灵感 F2)", + "", + f"模式:{'live(真实生成+裁判)' if live else 'dry(零成本确定性校验)'}", + f"题材:{len(reports)} 个 · 断言:{total_checks - failed}/{total_checks} 通过", + "", + ] + for r in reports: + c = r.case + lines.append(f"## {c.slug} · {c.genre} · 《{c.spec.title}》 · 第 {r.pair.chapter_no} 章") + lines.append("") + lines.append("### dry 校验") + for chk in r.checks: + mark = "✅" if chk.passed else "❌" + lines.append(f"- {mark} `{chk.name}` — {chk.detail}") + lines.append("") + lines.append("### prompt 并排") + lines.append("") + lines.append(_render_pair(r.pair)) + lines.append("") + if r.live is not None: + lines.append(_render_live(r.live)) + lines.append("") + return "\n".join(lines) + + +def write_report(text: str, path: Any) -> Any: + from pathlib import Path + + out = Path(path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(text, encoding="utf-8") + return out diff --git a/tests/craft_eval/live.py b/tests/craft_eval/live.py new file mode 100644 index 0000000..f13328e --- /dev/null +++ b/tests/craft_eval/live.py @@ -0,0 +1,127 @@ +"""`--live` 真实生成 + LLM 五维裁判(灵感 F2)。 + +**成本警告**:本模块**真实调用付费 LLM**(每题材:writer 生成 2 章 + analyst 裁判 1 次)。 +仅供人工手动跑,**绝不进 pytest / CI**(故与 dry 校验分文件,pytest 不导入本模块)。 +凭据从 DB 读(`SqlCredentialStore`),路由从 `tier_routing` 取——与生产写章同一条网关路径。 + +用法见 `README.md`:`uv run python -m tests.craft_eval --live`。 +""" + +from __future__ import annotations + +import dataclasses +from dataclasses import dataclass + +from ww_api.services.credentials import STUB_OWNER_ID, SqlCredentialStore +from ww_api.services.project_deps import build_gateway_for_tier +from ww_db import get_sessionmaker +from ww_llm_gateway.types import Block, LlmRequest, Scope + +from .harness import CaseReport, PromptPair + +# 五维对比裁判(对齐 craft_ab 实验的口径)——只就文本本身逐维对比,不复述原文。 +JUDGE_SYSTEM = """你是资深中文网络小说主编,阅稿无数,眼光毒辣、直言不讳。 +现在给你同一章大纲下、由两套不同写作指令产出的两版正文(A=craft-on,B=craft-off)。 +请仅就文本本身,逐维度对比谁更好,每个维度给出「A更好 / B更好 / 难分」并附一句依据: +① show-don't-tell(情绪是否靠动作细节外化,而非旁白直说) +② 对话是否有角色声音/口癖 +③ 章末钩子是否成立(是否让读者想追下一章) +④ 是否注水/AI 腔(空泛排比、翻译腔、无信息量的辞藻堆砌) +⑤ 开篇代入感(前几段是否立住冲突/身份钩子、抓人) +最后给出总判:「A 更好 / B 更好 / 难分」,并用 1-2 句话说明理由。 +保持简洁、务实,不要复述原文。""" + +# 裁判每稿截断字数(省 token)。 +_JUDGE_EXCERPT = 1500 + + +@dataclass(frozen=True) +class LiveResult: + chapter_on: str + chapter_off: str + chars_on: int + chars_off: int + judge_text: str + served_by: str + + +def char_count(text: str) -> int: + """中文成稿近似字数:去空白后的字符数。""" + return len("".join(text.split())) + + +async def _stream_text(gateway: object, req: LlmRequest) -> str: + parts: list[str] = [] + async for delta in gateway.stream(req): # type: ignore[attr-defined] + parts.append(delta.text) + return "".join(parts) + + +def _judge_request(pair: PromptPair, text_on: str, text_off: str) -> LlmRequest: + body = ( + "以下是两版正文(各截取前若干字以省 token)。\n\n" + f"===== A 稿 / craft-on(前 {_JUDGE_EXCERPT} 字)=====\n" + + text_on[:_JUDGE_EXCERPT] + + f"\n\n===== B 稿 / craft-off(前 {_JUDGE_EXCERPT} 字)=====\n" + + text_off[:_JUDGE_EXCERPT] + ) + return LlmRequest( + tier="analyst", + system=[Block(text=JUDGE_SYSTEM, cache=True)], + input=body, + stream=False, + scope=Scope(user_id=STUB_OWNER_ID, project_id=None), + ) + + +async def run_live(reports: list[CaseReport]) -> list[CaseReport]: + """对每个 report 的 craft-on/off 请求真实生成一章 + 五维裁判,返回附 live 的新 report 列表。 + + 不可变更新:用 `dataclasses.replace` 产出新 `CaseReport`,不原地改冻结实例。 + """ + sm = get_sessionmaker() + out: list[CaseReport] = [] + async with sm() as session: + store = SqlCredentialStore(session) + # writer / analyst 各建一次网关,跨题材复用(省装配开销)。 + writer_gw = await build_gateway_for_tier(session, store, "writer") + analyst_gw = await build_gateway_for_tier(session, store, "analyst") + + for report in reports: + pair = report.pair + print(f"[live] {report.case.slug}:生成 craft-on……", flush=True) + text_on = await _stream_text(writer_gw, _as_scoped(pair.craft_on)) + print(f"[live] {report.case.slug}:生成 craft-off……", flush=True) + text_off = await _stream_text(writer_gw, _as_scoped(pair.craft_off)) + print(f"[live] {report.case.slug}:analyst 五维裁判……", flush=True) + resp = await analyst_gw.run(_judge_request(pair, text_on, text_off)) + + result = LiveResult( + chapter_on=text_on, + chapter_off=text_off, + chars_on=char_count(text_on), + chars_off=char_count(text_off), + judge_text=resp.text, + served_by=f"{resp.served_by.provider}:{resp.served_by.model}" + + (" (fell_back)" if resp.served_by.fell_back else ""), + ) + out.append(dataclasses.replace(report, live=result)) + return out + + +def _as_scoped(req: LlmRequest) -> LlmRequest: + """把请求 scope 换成 stub owner(usage_ledger.owner_id FK 须指向已 seed 的用户行)。""" + scoped = Scope(user_id=STUB_OWNER_ID, project_id=None) + return req.model_copy(update={"scope": scoped}) + + +def summarize(reports: list[CaseReport]) -> str: + """live 结果的一行式摘要(stdout 用)。""" + lines: list[str] = [] + for r in reports: + if r.live is None: # pragma: no cover - live 路径必有结果 + continue + lines.append( + f"{r.case.slug}: craft-on {r.live.chars_on}字 / craft-off {r.live.chars_off}字" + ) + return "\n".join(lines) diff --git a/tests/test_craft_eval.py b/tests/test_craft_eval.py new file mode 100644 index 0000000..afce799 --- /dev/null +++ b/tests/test_craft_eval.py @@ -0,0 +1,121 @@ +"""craft A/B 评估回路的 dry 模式确定性断言(灵感 F2)。 + +这些断言 = 评估脚本 dry 模式的回归门禁:验证 craft 教条块 / genre 片段 / 开篇 marker +**确实按 fixture 注入了正确内容**,且 craft-off 基线**不含**这三者。dry 路径不碰 LLM, +故可进常规 pytest / CI(`--live` 真实生成路径不在此、不进 CI,见 tests/craft_eval/README.md)。 +""" + +from __future__ import annotations + +import pytest +from ww_agents import load_craft_doctrine +from ww_config import genre_craft_fragment + +from tests.craft_eval import ( + FIXTURES, + FixtureCase, + all_passed, + build_prompt_pair, + input_text, + render_report, + system_text, + verify_case, + write_report, +) +from tests.craft_eval.harness import OPENING_CHAPTER_LIMIT, CaseReport + +_OPENING_HEADER = "开篇黄金三章" + + +def test_fixtures_cover_three_distinct_genres() -> None: + genres = {c.genre for c in FIXTURES} + assert {"仙侠", "都市", "悬疑"} <= genres + assert len(FIXTURES) == len(genres), "每个 fixture 应是不同题材" + for genre in genres: + assert genre_craft_fragment(genre), f"题材 {genre!r} 缺 GENRE_CRAFT 片段" + + +@pytest.mark.parametrize("case", FIXTURES, ids=lambda c: c.slug) +async def test_dry_verification_all_pass(case: FixtureCase) -> None: + """每个金标准 fixture 的 dry 校验全绿——这是 craft 改动的回归门禁本体。""" + pair = await build_prompt_pair(case) + checks = verify_case(case, pair) + failed = [c for c in checks if not c.passed] + assert not failed, "dry 校验失败:\n" + "\n".join(f" {c.name}: {c.detail}" for c in failed) + assert all_passed(checks) + + +@pytest.mark.parametrize("case", FIXTURES, ids=lambda c: c.slug) +async def test_craft_on_injects_doctrine_genre_and_marker(case: FixtureCase) -> None: + pair = await build_prompt_pair(case) + on = pair.craft_on + + # 教条块:第一个 system 块、字节等于 load_craft_doctrine()、cache=True(不变量 #9)。 + assert on.system[0].text == load_craft_doctrine() + assert on.system[0].cache is True + assert len(on.system) >= 2, "craft-on 应有 [教条, stable_core] 两块" + + # genre 片段:进 stable_core 缓存块(book-stable → 缓存前缀),不进易变 input(#9)。 + fragment = genre_craft_fragment(case.genre) + assert fragment is not None + assert fragment in on.system[1].text + assert fragment not in on.system[0].text, "genre 片段不应污染教条块" + assert fragment not in input_text(on), "genre 片段是 book-stable,不应进 volatile input" + + # 开篇 marker:开篇章进 volatile input、不进缓存 system(含章号→易变,#9)。 + if case.chapter_no <= OPENING_CHAPTER_LIMIT: + assert _OPENING_HEADER in input_text(on) + assert _OPENING_HEADER not in system_text(on) + + # 不变量 #2:只声明 tier,不写 model。 + assert on.tier == "writer" + + +@pytest.mark.parametrize("case", FIXTURES, ids=lambda c: c.slug) +async def test_craft_off_baseline_excludes_craft(case: FixtureCase) -> None: + pair = await build_prompt_pair(case) + off = pair.craft_off + + assert load_craft_doctrine() not in system_text(off) + fragment = genre_craft_fragment(case.genre) + assert fragment is not None + assert fragment not in system_text(off) + assert fragment not in input_text(off) + assert _OPENING_HEADER not in input_text(off) + # 基线仍是可用写章请求:保留书名与写作指令(stripping 未误伤本体)。 + assert case.spec.title in system_text(off) + assert "请创作第" in input_text(off) + + +async def test_opening_marker_absent_beyond_opening_limit() -> None: + """章号 > 开篇上限时 craft-on 不注入开篇 marker(黄金三章边界)。""" + case = FIXTURES[0] + pair = await build_prompt_pair(case, chapter_no=OPENING_CHAPTER_LIMIT + 1) + assert _OPENING_HEADER not in input_text(pair.craft_on) + assert _OPENING_HEADER not in system_text(pair.craft_on) + + +async def test_render_and_write_report(tmp_path) -> None: # type: ignore[no-untyped-def] + reports: list[CaseReport] = [] + for case in FIXTURES: + pair = await build_prompt_pair(case) + reports.append(CaseReport(case=case, pair=pair, checks=verify_case(case, pair))) + text = render_report(reports, live=False) + assert "craft-on" in text and "craft-off" in text + for case in FIXTURES: + assert case.slug in text + out = write_report(text, tmp_path / "report.md") + assert out.exists() + assert out.read_text("utf-8") == text + + +def test_build_prompt_pair_is_deterministic_across_runs() -> None: + """同一 fixture 两次组装字节一致(缓存前缀稳定 / 无时间戳-UUID,#9)。""" + import asyncio + + case = FIXTURES[0] + a = asyncio.run(build_prompt_pair(case)) + b = asyncio.run(build_prompt_pair(case)) + assert system_text(a.craft_on) == system_text(b.craft_on) + assert input_text(a.craft_on) == input_text(b.craft_on) + assert system_text(a.craft_off) == system_text(b.craft_off)