diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fb5dea..bc76609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,11 @@ ## [Unreleased] -_本区记录 `[1.0.0]` 之后、尚未发布的所有改动;发布时把本区内容并入新的版本号。_ +_本区记录 `[1.1.0]` 之后、尚未发布的所有改动;发布时把本区内容并入新的版本号。_ + +## [1.1.0] - 2026-10-24 + +自 `[1.0.0]` 以来的全部改动(含 2026-09-22 的 runner/测试批次与 2026-10-24 的第一性原理用户视角修复)。 ### Added @@ -12,6 +16,9 @@ _本区记录 `[1.0.0]` 之后、尚未发布的所有改动;发布时把本 - `tests/test_fetch_aihot.py`:覆盖 `fetch_aihot.py` 的 CLI 与 `fetch_json` 重试逻辑 - `tests/test_gate_adversarial.py`:8 个对抗式用例,固化“试图绕过用户确认闸门”的几种走法(跳过步骤 03、只传 `--confirm`、过期确认、过期活人感门禁、损坏的运行状态文件),任一步被改坏都会红 - `skills/tryworld-paper/references/workflow.md` §1.4:写明闸门的技术边界——`.prose_pass` / `.confirmed` 只绑 draft 的 sha256,防漂移不防同用户伪造,因此“等用户确认”必须是对话里真实发生的动作 +- `check_prose.py --whitelist` 正式接入流水线:`step_02_check_prose.py` 与 `pipeline.runner` 新增 `--whitelist` 透传(路径按调用方 cwd 解析为绝对路径)。此前该参数只存在于脚本内、无任何文档、也无法从流水线使用。豁免是**可见退出**——命中内容不被检查,运行会打印 `NOTE:` 行 +- 技术记法标点豁免:数字间的 `16:9`、`4:3`、`12:30`、`2024–2026` 不再判违规(降为提示)。此前这类写法被标点禁令硬拦(exit 1),而它们是这一内容品类绕不开的写法;`:` 提示性冒号、`——` 破折号、带空格的 `12 :30`、词间 `–` 照旧拦截 +- `tests/test_prose.py`:新增 8 例覆盖豁免边界(比例/时间/区间放行;提示性冒号、破折号、词间连接号、带空格形式仍拦)与白名单行为(含非法正则不崩溃) ### Changed @@ -21,6 +28,10 @@ _本区记录 `[1.0.0]` 之后、尚未发布的所有改动;发布时把本 - `README.md` / `README.zh-CN.md` / `CONTRIBUTING.md` / 各 `SKILL.md` / `references/`:统一指向 `.py` 脚本,移除 `.ps1` 作为规范路径的表述 - `pipeline/steps/step_04_tts.py`:澄清 `--theme-content` 帮助文本(品牌/视觉主题,默认 `themes/paper-algorithm.json`),并提示与 step 01 的“内容主题”同名不同义 - `CHANGELOG.md`:将此前 `[Unreleased]` 内容并入 `[1.0.0]`,改为真正的“未发布”区 +- `pipeline/steps/step_01_optimize.py` → `step_01_ingest.py`:该步骤一直逐字复制输入,却命名为 optimize 并让用户“审阅 draft.md”,暗示稿子已被润色。改名并改正 docstring、argparse 描述与 `ACTION REQUIRED` 输出,明确“这是逐字副本,优化与净化由 agent 按 SKILL.md 完成,改完重跑步骤 02”;`step_02` 的提示语与 README×2 步骤清单同步为 `01 ingest` +- README(中英)「Pipeline Runner」:如实说明 Runner 是步骤 2–4 的门禁记账器,不写稿也不渲染;步骤 05/06 依赖没有任何步骤会生成的构图,只在项目已存在时执行 +- README(中英)「活人感门禁」:删去“禁的是修辞动作不是字面,换词仍算命中”这一与实现不符的说法,改为写清——约 22 类信号中只有 5 类能判失败(标点、硬停词、绝对黑话、模型路标、翻案句式),其余为交给人判断的提示;规则是字面正则与词表,换词改写可能漏网;真正的活人感来自 §1.2.1 七遍改稿指令,无脚本强制 +- `references/workflow.md` §1.2.1 第 5 条与 `references/titles.md`:标点口径与检查器同步(技术记法豁免、白名单用法),避免散文与代码漂移 ### Fixed @@ -29,6 +40,7 @@ _本区记录 `[1.0.0]` 之后、尚未发布的所有改动;发布时把本 - `pipeline/runner.py`:在仓库根目录以外启动时,用户写的相对路径不再解析错位(步骤以 `cwd=REPO` 运行,runner 现在先把 `--project-dir` / `--script` / `--theme-content` / `--video` 解析为绝对路径);`--steps` 容忍空格与空 token(如 `"1, 2,"`),非法 token 的报错直接指出问题值 - `fetch_aihot.py`:`--base-url` 归一化去掉尾部斜杠,避免拼出 `//api/...` - `notify_delivery.ps1` / `fetch_aihot.ps1`:DEPRECATED 头中的规范路径改为仓库相对路径,避免与 `scripts/` 混淆 +- `skills/tryworld-koubo/SKILL.md:21` 与 `skills/tryworld-topics/SKILL.md:12`:修复被转义吃成制表符的路径与字段名(`\tryworld-paper/...`、`\topic_sources`、`\topic_rules`)。这些引用自 `6f81f86`(2026-09-01)起在任何安装环境中都无法解析,本次在 git blob 层修复 ## [1.0.0] - 2026-09-05 diff --git a/README.md b/README.md index a592dc9..1a4d9e3 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ That's it. The request is recognized and routed through the whole pipeline autom ### Pipeline Runner (Optional) -After installation, use the pipeline runner instead of manually executing steps. The runner runs 6 step scripts in order, checks preconditions at each step, and stops on failure: +The runner is a **gate bookkeeper for steps 2–4**, not a replacement for the agent's work. It runs the preconditions in order and stops on the first failure, but the content itself (optimising and sanitising the script, authoring the HyperFrames composition, covers, titles, publish plan, email) stays in the SKILL.md prose — the runner never writes or renders it. Step 01 copies the input verbatim to `work/draft.md`; the agent then optimises it, re-runs step 02, and step 03 blocks until the user confirms. Steps 05/06 require a composition that no step produces, so run them only once that project exists. ```bash # List all steps @@ -92,7 +92,7 @@ python -m pipeline.runner --project-dir --steps 1,2 python -m pipeline.runner --project-dir --steps 3,4,5,6 ``` -Six steps: `01 optimize` → `02 check_prose` → `03 confirm` (hard gate) → `04 tts` → `05 render` → `06 verify` (hard gate). +Six steps: `01 ingest` → `02 check_prose` → `03 confirm` (hard gate) → `04 tts` → `05 render` → `06 verify` (hard gate). --- ## 📑 Contents @@ -315,16 +315,29 @@ Full edge-tts voice ids are accepted too. By default the pipeline never asks abo ## 🔒 The Aliveness Gate -Before delivery, every polished script and platform title passes a machine gate — **the ban targets rhetorical moves, not literal strings**. Repeating the same move with different words still counts. +Before delivery, every polished script and platform title passes a machine gate. Be clear about what that gate is and what it is not. -The banned moves (flip-flop rhetoric, triple+ parallel structure, lyric metaphor, nominalization, punctuation tiers, hard stop-words, model signposts, jargon tiers) are defined in exactly two places, so the list cannot drift between copies: +**What blocks.** `check_prose.py` runs ~22 signal families, but only five of them can fail the run (exit 1): punctuation (colons / dashes), hard stop-words (3 terms), absolute jargon (27 terms), model insight signposts (6 phrases), and the flip-flop construction (`不是……而是……` and 7 regex variants). Everything else — parallel structure, lyric metaphor, nominalization, conjunction density, 「」-quote density, sentence-length variance, context jargon — is reported as a **warning for a human to judge** and never blocks delivery. + +**What it cannot do.** These are surface-form regex and word-list rules, not semantic analysis. The rules catch a wide set of *literal fingerprints* of model prose, and rewriting the same rhetorical move in different words can slip past them. The gate is a formatting-and-cliché linter, not an aliveness detector. The real anti-AI writing quality lives in the instructions addressed to the agent — the seven-pass rewrite in `references/workflow.md` §1.2.1 and `writing-methodology.md` — which no script enforces. Treat a green gate as "no fingerprints found", not as "this reads like a human". + +**Known trade-off, and how to override.** The five blocking rules are aggressive by design, so some legitimate prose trips them (a plain causal correction like `不是因为A,而是B` is flagged). Technical notation is exempt: `16:9`, `4:3`, `12:30`, `2024–2026` pass as a warning, because that notation is unavoidable in this genre. For anything else you judge to be a false positive, `--whitelist ` (one regex per line) strips matched text before analysis: + +```bash +python -X utf8 skills/tryworld-paper/scripts/check_prose.py draft.md --whitelist exemptions.txt +python -m pipeline.runner --project-dir --steps 1,2 --script draft.md --whitelist exemptions.txt +``` + +The whitelist deletes matched text, so exempted content is *not checked at all* — it is an explicit, visible opt-out (the run prints a `NOTE:` line), not a silent bypass. Keep it to specific terms; a too-broad pattern empties the text and the checker exits 2. + +The banned moves are defined in exactly two places, so the list cannot drift between copies: | Source | What it holds | |---|---| | `skills/tryworld-paper/references/workflow.md` §1.2.1 (item 5) | The prose rules, with action-level definitions | -| `skills/tryworld-paper/scripts/check_prose.py` | The checker that enforces them — absolute bans + context-sensitive words, plus statistical signals (sentence-length variance, conjunction density, model-favorite lyric words, 「」-quote density) | +| `skills/tryworld-paper/scripts/check_prose.py` | The checker: the five blocking rules plus ~17 advisory statistical signals | -`check_prose.py` (from [human-writing](https://github.com/KKKKhazix/human-writing) v1.1.0, MIT) is the authority when the two disagree in wording. **Zero hard violations required before the user-confirmation gate; failing means no delivery.** +`check_prose.py` (from [human-writing](https://github.com/KKKKhazix/human-writing) v1.1.0, MIT) is the authority when the two disagree in wording. **Zero blocking violations required before the user-confirmation gate; failing means no delivery.** --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 3a9f85c..9d2f567 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -76,7 +76,7 @@ Copy-Item -Path .\skills\* -Destination "$env:USERPROFILE\.agents\skills" -Recur ### Pipeline Runner(可选) -安装后可以用 pipeline runner 代替手动执行流程。Runner 按编号顺序运行 6 个步骤脚本,每步检查前置条件,失败即停: +Runner 是**步骤 2–4 的门禁记账器**,不是手动流程的替代品。它按编号顺序检查前置条件、失败即停,但内容本身(优化与净化稿子、编写 HyperFrames 构图、封面、标题、发布计划、邮件通知)仍由 SKILL.md 的散文流程负责——Runner 不写稿也不渲染。步骤 01 只把输入逐字复制成 `work/draft.md`;由 agent 完成优化后重跑步骤 02,步骤 03 在用户确认前一直拦截。步骤 05/06 依赖一个没有任何步骤会生成的构图,只有该项目已存在时才执行: ```bash # 查看所有步骤 @@ -92,7 +92,7 @@ python -m pipeline.runner --project-dir <项目目录> --steps 1,2 python -m pipeline.runner --project-dir <项目目录> --steps 3,4,5,6 ``` -六个步骤:`01 optimize` → `02 check_prose` → `03 confirm`(硬闸门)→ `04 tts` → `05 render` → `06 verify`(硬闸门)。 +六个步骤:`01 ingest` → `02 check_prose` → `03 confirm`(硬闸门)→ `04 tts` → `05 render` → `06 verify`(硬闸门)。 --- ## 📑 目录 @@ -315,16 +315,29 @@ python tryworld-paper/scripts/tts_yunxi.py script.txt --out work/audio --voice x ## 🔒 活人感门禁 -成片之前,每一版口播稿与平台标题都要过一道机器门禁——**禁的是修辞动作,不是字面**。换一套字做同一个动作,仍然算命中。 +成片之前,每一版口播稿与平台标题都要过一道机器门禁。这道门禁是什么、不是什么,先说清楚。 -禁项(翻案腔、同构排比、抒情借喻、动词名词化、标点分级、硬停词、模型路标、黑话两档)只在两处定义,避免多份拷贝互相漂移: +**会拦截的(exit 1)。** `check_prose.py` 共约 22 类信号,但只有 5 类能判失败:标点(冒号/破折号)、硬停词(3 个)、绝对黑话(27 个)、模型洞察路标(6 个短语)、翻案句式(`不是……而是……` 等 7 个正则变体)。其余全部——同构排比、抒情借喻、名词化、连词密度、「」金句密度、句长变异系数、语境判断词——只作为**警告交给人判断**,不拦交付。 + +**拦不住的。** 这些是字面正则和词表规则,不是语义分析。它能抓住大量模型腔的*字面指纹*,但同一个修辞动作换一套字,可能查不出来。这道门禁是格式与套话检查器,不是活人感检测器。真正决定"像不像人写的",是给 agent 的写作指令——`references/workflow.md` §1.2.1 的七遍改稿和 `writing-methodology.md`——那部分没有任何脚本在强制。门禁通过请理解为"未发现字面指纹",不等于"读起来像人写的"。 + +**已知代价与豁免。** 5 条拦截规则刻意从严,正常行文偶尔误伤(平实因果句"不是因为A,而是B"会被判翻案)。技术记法已豁免:`16:9`、`4:3`、`12:30`、`2024–2026` 只提示不拦截,因为这类内容绕不开这些写法。其余误伤可用 `--whitelist <文件>`(每行一个正则)在分析前剔除匹配文本: + +```bash +python -X utf8 skills/tryworld-paper/scripts/check_prose.py draft.md --whitelist exemptions.txt +python -m pipeline.runner --project-dir <项目目录> --steps 1,2 --script draft.md --whitelist exemptions.txt +``` + +白名单是删除文本,被豁免的内容*完全没有被检查*——所以它是显式、可见的退出(运行会打印 `NOTE:` 行),不是静默绕过。只写具体词条;过宽的模式会把全文清空,检查器直接 exit 2。 + +禁项只在两处定义,避免多份拷贝互相漂移: | 来源 | 内容 | |---|---| | `skills/tryworld-paper/references/workflow.md` §1.2.1 第 5 条 | 改稿规则与动作级定义 | -| `skills/tryworld-paper/scripts/check_prose.py` | 执行检测的脚本——绝对禁词 + 语境判断词,并给出统计层提示(句长变异系数、连词密度、模型偏爱抒情词、「」金句密度) | +| `skills/tryworld-paper/scripts/check_prose.py` | 执行检测的脚本——5 类拦截规则 + 约 17 类咨询性统计信号 | -措辞不一致时以 `check_prose.py`(源自 [human-writing](https://github.com/KKKKhazix/human-writing) v1.1.0,MIT)为准。**硬禁项清零才允许进入用户确认闸门,失败不交付。** +措辞不一致时以 `check_prose.py`(源自 [human-writing](https://github.com/KKKKhazix/human-writing) v1.1.0,MIT)为准。**拦截项清零才允许进入用户确认闸门,失败不交付。** --- diff --git a/pipeline/runner.py b/pipeline/runner.py index d2bdeed..5532cc5 100644 --- a/pipeline/runner.py +++ b/pipeline/runner.py @@ -32,6 +32,7 @@ # (or, for --confirm, when the flag is present). STEP_ARGS: dict[int, list[str]] = { 1: ["--script", "--theme-content"], + 2: ["--whitelist"], 3: ["--confirm"], 4: ["--theme-content"], 5: ["--quality"], @@ -111,6 +112,9 @@ def main() -> int: help="Theme JSON forwarded to step 01 (content theme) / step 04 (brand/visual theme)") parser.add_argument("--confirm", action="store_true", help="Forward the confirmation flag to step 03; without it the gate blocks") + parser.add_argument("--whitelist", type=Path, default=None, + help="Regex-per-line file forwarded to step 02, exempting literal terms " + "from the prose gate. Visible opt-out, recorded in the run output.") parser.add_argument("--quality", choices=["draft", "high"], default=None, help="Render quality forwarded to step 05 (default: the step's own default, high)") parser.add_argument("--video", type=Path, default=None, @@ -146,7 +150,7 @@ def main() -> int: # shell must be made absolute here — otherwise a runner started outside the # repo root would hand the step a path that resolves somewhere else entirely. args.project_dir = args.project_dir.expanduser().resolve() - for flag in ("script", "theme_content", "video"): + for flag in ("script", "theme_content", "video", "whitelist"): value = getattr(args, flag) if value is not None: setattr(args, flag, value.expanduser().resolve()) diff --git a/pipeline/steps/step_01_optimize.py b/pipeline/steps/step_01_ingest.py similarity index 65% rename from pipeline/steps/step_01_optimize.py rename to pipeline/steps/step_01_ingest.py index efebc55..ded5671 100644 --- a/pipeline/steps/step_01_optimize.py +++ b/pipeline/steps/step_01_ingest.py @@ -1,5 +1,12 @@ #!/usr/bin/env python3 -"""Step 01: Read script, load content theme, output draft.md for user confirmation.""" +"""Step 01: Ingest the input script into work/draft.md (verbatim copy). + +This step does NOT optimize the script. Optimization (traffic first-principles + +the seven-pass aliveness rewrite + script sanitising) is the agent's job, per +tryworld-koubo / tryworld-paper SKILL.md. The runner is a gate bookkeeper, not a +writer: it copies the input verbatim and binds the approval markers to that +exact content, so a later edit to draft.md invalidates any prior confirmation. +""" import argparse import json import sys @@ -10,7 +17,7 @@ def main() -> int: - parser = argparse.ArgumentParser(description="Step 01: Optimize script") + parser = argparse.ArgumentParser(description="Step 01: Ingest input script to draft.md (no optimization)") parser.add_argument("--project-dir", type=Path, default=Path.cwd()) parser.add_argument("--script", type=Path, required=True, help="Path to input script (.md/.txt)") parser.add_argument("--theme-content", type=Path, default=None, help="Path to content theme JSON (default: themes/content-default.json)") @@ -47,7 +54,9 @@ def main() -> int: han_count = sum(1 for c in text if "\u4e00" <= c <= "\u9fff") print(f"Input script: {args.script.name} ({han_count} hanzi)") - # For now, pass through. In production, Agent would optimize here. + # Verbatim ingest. Optimization belongs to the agent (SKILL.md workflow), not + # to this step — draft.md is the exact text that the prose gate and the user + # confirmation are bound to. work_dir = args.project_dir / "work" work_dir.mkdir(parents=True, exist_ok=True) draft = work_dir / "draft.md" @@ -57,8 +66,11 @@ def main() -> int: (work_dir / marker_name).unlink(missing_ok=True) print(f"Draft written to: {draft}") - print("\nACTION REQUIRED: Review draft.md and confirm before proceeding to step 02.") - print("This step does NOT auto-confirm. User must explicitly approve.") + print("\nACTION REQUIRED: this is a verbatim copy of the input, NOT an optimized script.") + print("Per tryworld-koubo / tryworld-paper SKILL.md, the agent must now optimize +") + print("sanitise the script (traffic first-principles, seven-pass aliveness rewrite),") + print("overwrite work/draft.md with the optimized text, then run step 02 (prose gate).") + print("Step 03 blocks until the user confirms; this step does NOT auto-confirm.") return 0 diff --git a/pipeline/steps/step_02_check_prose.py b/pipeline/steps/step_02_check_prose.py index d95b90b..a73e544 100644 --- a/pipeline/steps/step_02_check_prose.py +++ b/pipeline/steps/step_02_check_prose.py @@ -18,21 +18,40 @@ def main() -> int: parser = argparse.ArgumentParser(description="Step 02: Prose gate") parser.add_argument("--project-dir", type=Path, default=Path.cwd()) + parser.add_argument( + "--whitelist", + type=Path, + default=None, + help="Optional regex-per-line file exempting literal terms from the gate. " + "Visible opt-out: the run records it and prints a notice; it is not a silent bypass.", + ) args = parser.parse_args() draft = args.project_dir / "work" / "draft.md" if not draft.exists(): print(f"FAIL: draft.md not found at {draft}") - print("Run step_01_optimize.py first.") + print("Run step_01 first.") return 1 + cmd = [sys.executable, "-X", "utf8", str(CHECK_PROSE), str(draft)] + if args.whitelist: + if not args.whitelist.is_file(): + print(f"FAIL: whitelist file not found: {args.whitelist}") + return 1 + cmd += ["--whitelist", str(args.whitelist)] + result = subprocess.run( - [sys.executable, "-X", "utf8", str(CHECK_PROSE), str(draft)], + cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", ) print(result.stdout) if result.stderr: print(result.stderr, file=sys.stderr) + if args.whitelist: + # Keep the exemption visible: it deletes matched text before analysis, + # so a run that used it must not look identical to a clean run. + print(f"NOTE: prose gate ran with a whitelist ({args.whitelist}). " + "Exempted text was not checked.") if result.returncode != 0: print("\nPROSE GATE: FAIL. Fix issues in draft.md, then re-run step_02.") @@ -42,6 +61,8 @@ def main() -> int: # Write marker for step_03 marker = args.project_dir / "work" / ".prose_pass" write_marker(marker, draft) + if args.whitelist: + print(f"whitelist_used: {args.whitelist}") return 0 diff --git a/skills/tryworld-koubo/SKILL.md b/skills/tryworld-koubo/SKILL.md index 63e3cdb..f91ed0f 100644 --- a/skills/tryworld-koubo/SKILL.md +++ b/skills/tryworld-koubo/SKILL.md @@ -18,7 +18,7 @@ description: 试界TryWorld AI 口播视频的统一定位入口,覆盖口播 ## 内容主题 -出片前确认内容主题(默认 ryworld-paper/themes/content-default.json)。内容主题定义选题域、受众、写稿规范、平台列表等。换品牌/换领域时复制此 JSON 并修改——流水线逻辑不变。用户说「用 <主题名> 内容主题」时切换。 +出片前确认内容主题(默认 tryworld-paper/themes/content-default.json)。内容主题定义选题域、受众、写稿规范、平台列表等。换品牌/换领域时复制此 JSON 并修改——流水线逻辑不变。用户说「用 <主题名> 内容主题」时切换。 ```json { diff --git a/skills/tryworld-paper/references/titles.md b/skills/tryworld-paper/references/titles.md index 954b4fd..af0c65c 100644 --- a/skills/tryworld-paper/references/titles.md +++ b/skills/tryworld-paper/references/titles.md @@ -45,7 +45,7 @@ - **同构排比**:三项以上整齐排比禁,两项为限。 - **抒情借喻**:不给抽象名词配具体动词("时间保管细节"类)。 - **动词名词化**:禁"实现了……的提升"式,还原成直接动词。 -- **标点**:标题禁中文/英文冒号(`:` `:`)与破折号(`—` `——` `–`)。标题无"引出原话"场景,冒号一律不用(与正文的分级规则不同)。 +- **标点**:标题禁中文/英文冒号(`:` `:`)与破折号(`—` `——` `–`)。标题无"引出原话"场景,冒号一律不用(与正文的分级规则不同;数字间技术记法如 16:9、4:3 豁免,标题一般用不到)。 - **硬停词**:"说白了/说穿了/先说结论"。 - **模型洞察路标**:"值得注意的是""还有一层""从某种意义上说"等。 - **商业/模型黑话**:绝对禁词 + 语境判断词两档(清单见 `scripts/check_prose.py`)。 diff --git a/skills/tryworld-paper/references/workflow.md b/skills/tryworld-paper/references/workflow.md index a995f56..e0eb79f 100644 --- a/skills/tryworld-paper/references/workflow.md +++ b/skills/tryworld-paper/references/workflow.md @@ -46,7 +46,7 @@ 2. **检查有没有往前走**:给每段标一个主要作用(动作/事实/解释/例子/疑问/判断/背景/情绪),连续几段做同一件事就合并;后段只把前段换说法,删;删掉三分之一后事实与判断几乎没变,说明在注水。 3. **拆表演性中文**:删假深刻(单独截图好看、放回正文没用的句子)、假具体(无来源的时间/天气/神态/摆设/对白)、假口语("老铁""兄弟们""泡杯茶慢慢说")、抽象名词堆砌(问谁做了什么、影响了谁、钱和时间落在哪里)、比喻换场(短距离出现多套借喻先还原成本义)。 4. **听中文顺不顺**:主干早出现(先给做事的人与动作,再接时间/原因/条件);代词指向清楚;连续短句像敲桌子就合并,长句一口气没落脚处就拆开;动作/原话已经把情绪写出来,删掉后面的解释。 -5. **清硬禁项(动作级)**:禁的是修辞动作不是字面。翻案腔按动作查(先立读者没有的误解再推翻抬价,外衣包括"不是……而是……""你以为……其实……""回头才发现""说到底"等 9+ 变形);三项以上同构排比;抒情借喻(抽象名词配具体动词);动词名词化("完成了对……的优化");标点:破折号全禁、冒号仅引出直接原话可用;硬停词"说白了/说穿了/先说结论";模型路标("值得注意的是""还有一层"等);黑话(绝对禁词 + 语境判断词,清单见 `scripts/check_prose.py`)。命中就拆回普通句子,不要换另一种漂亮句式。 +5. **清硬禁项(动作级)**:写作目标是拆掉修辞动作本身,不要换另一种漂亮句式;机器检测按字面规则执行,能抓住常见指纹,改写换词可能漏网,所以逐遍人工判断仍要过。翻案腔按动作查(先立读者没有的误解再推翻抬价,外衣包括"不是……而是……""你以为……其实……""回头才发现""说到底"等 9+ 变形);三项以上同构排比;抒情借喻(抽象名词配具体动词);动词名词化("完成了对……的优化");标点:破折号全禁、冒号仅引出直接原话可用(数字间的技术记法豁免,如 16:9、4:3、12:30、2024–2026,检查器只提示不拦截);硬停词"说白了/说穿了/先说结论";模型路标("值得注意的是""还有一层"等);黑话(绝对禁词 + 语境判断词,清单见 `scripts/check_prose.py`)。命中就拆回普通句子,不要换另一种漂亮句式。确认属误伤的个别写法,可用 `check_prose.py --whitelist <每行一个正则的文件>` 豁免(豁免部分不检查,运行会打印 NOTE 行)。 6. **核现实**:时间、数字、身份、引语、因果与第一人称动作逐项核对;数据点无来源标"待核实",禁止编造。 7. **查结尾**:删掉最后两段各读一遍,删后更有力就提前结束;结尾不重新摘要全文;固定签名保留,正文在签名前自然收束。 diff --git a/skills/tryworld-paper/scripts/check_prose.py b/skills/tryworld-paper/scripts/check_prose.py index 14704c9..8ab9a66 100644 --- a/skills/tryworld-paper/scripts/check_prose.py +++ b/skills/tryworld-paper/scripts/check_prose.py @@ -104,6 +104,12 @@ "–": "连接号式破折号", } +# 技术记法豁免:比例、时间、数值区间是这类内容的必备写法,不是修辞动作。 +# 「16:9 横版」「发布时间 12:30」「2024–2026」不应被标点禁令打死。 +# 豁免只认「数字 X 数字」这一种紧贴形态(X 为半角冒号或连接号 –), +# 中文冒号「:」与破折号「——」的所有用法照旧拦截/警告。 +TECH_DIGIT = re.compile(r"\A\d") + PIVOT_PATTERNS = ( re.compile(r"(?:并)?不是[^。!?\n]{0,90}而是"), re.compile(r"并非[^。!?\n]{0,90}而是"), @@ -431,20 +437,44 @@ def main() -> int: warnings = [] quote_colons = [] + tech_punct = [] for symbol, label in FORBIDDEN_PUNCTUATION.items(): matches = list(re.finditer(re.escape(symbol), prose)) if symbol in (":", ":"): hard = [] for match in matches: - tail = prose[match.end() : match.end() + 2].lstrip() - if tail[:1] in ("「", "『", "“", "‘", '"'): + head = prose[match.start() - 1 : match.start()] + tail_raw = prose[match.end() : match.end() + 2] + tail = tail_raw.lstrip() + if TECH_DIGIT.match(head) and TECH_DIGIT.match(tail_raw): + # 数字:数字(16:9、4:3、12:30)属技术记法,不是提示性冒号 + tech_punct.append(("比例/时间", match)) + elif tail[:1] in ("「", "『", "“", "‘", '"'): quote_colons.append(match) else: hard.append(match) matches = hard + elif symbol == "–": + # 数值区间的连接号(2024–2026、第 3–5 章)同属技术记法;破折号「——」照旧全禁 + hard = [] + for match in matches: + head = prose[match.start() - 1 : match.start()] + tail_raw = prose[match.end() : match.end() + 2] + if TECH_DIGIT.match(head) and TECH_DIGIT.match(tail_raw): + tech_punct.append(("数值区间", match)) + else: + hard.append(match) + matches = hard if matches: lines = "、".join(str(line_number(text, match.start())) for match in matches[:8]) failures.append(f"{label}共 {len(matches)} 处,出现在第 {lines} 行。") + if tech_punct: + lines = "、".join( + str(line_number(text, match.start())) for _, match in tech_punct[:8] + ) + warnings.append( + f"技术记法标点 {len(tech_punct)} 处(数字间的比例/时间/区间,如 16:9、12:30、2024–2026),第 {lines} 行。已豁免,不算违规。" + ) if quote_colons: lines = "、".join( str(line_number(text, match.start())) for match in quote_colons[:8] diff --git a/skills/tryworld-topics/SKILL.md b/skills/tryworld-topics/SKILL.md index ec34a1f..238a467 100644 --- a/skills/tryworld-topics/SKILL.md +++ b/skills/tryworld-topics/SKILL.md @@ -9,7 +9,7 @@ description: 基于 aihot.virxact.com 最新 AI 资讯,为 AI 口播频道( ## 内容主题 -选题规则默认来自 ryworld-paper/themes/content-default.json 的 opic_sources、 opic_rules、platforms 字段。换领域/品牌时修改内容主题 JSON,无需改本 SKILL.md。 +选题规则默认来自 tryworld-paper/themes/content-default.json 的 topic_sources、topic_rules、platforms 字段。换领域/品牌时修改内容主题 JSON,无需改本 SKILL.md。 ## 流程 diff --git a/tests/test_prose.py b/tests/test_prose.py index 8c72fe9..f187058 100644 --- a/tests/test_prose.py +++ b/tests/test_prose.py @@ -63,3 +63,67 @@ def test_checker_help(self): ) assert r.returncode == 0 assert r.returncode == 0 + + +class TestTechnicalNotationExemption: + """16:9 / 4:3 / 12:30 / 2024–2026 are unavoidable notation for this genre.""" + + def _run(self, tmp_path, text): + f = tmp_path / "t.md" + f.write_text(text, encoding="utf-8") + return run_checker(f) + + def test_aspect_ratios_and_times_pass(self, tmp_path): + r = self._run(tmp_path, "视频做成 16:9 横版,封面用 4:3 和 3:4。\n发布时间 12:30 与 19:30。\n") + assert r.returncode == 0, f"technical notation must not block:\n{r.stdout}\n{r.stderr}" + assert "技术记法标点" in r.stdout # exempted, but visible as a warning + + def test_numeric_range_en_dash_passes(self, tmp_path): + r = self._run(tmp_path, "数据覆盖 2024–2026 三年,样本共一千二百条,来源为公开年报。\n") + assert r.returncode == 0, f"2024–2026 must not block:\n{r.stdout}" + + def test_prose_colon_still_fails(self, tmp_path): + r = self._run(tmp_path, "请注意以下三点:第一带身份证,第二提前到。\n") + assert r.returncode == 1 + assert "中文冒号" in r.stdout + + def test_em_dash_still_fails(self, tmp_path): + r = self._run(tmp_path, "价格降了一半——这个降幅很少见。\n") + assert r.returncode == 1 + assert "破折号" in r.stdout + + def test_en_dash_between_words_still_fails(self, tmp_path): + # exemption is only digit–digit; a word-adjacent en dash is still a dash + r = self._run(tmp_path, "上游生态–下游应用的联动被反复提及。\n") + assert r.returncode == 1 + + def test_time_with_space_not_exempt(self, tmp_path): + # "12 :30" is not the tight digit:digit form — must stay blocked + r = self._run(tmp_path, "钟点写作 12 :30 这种带空格的格式应被拦。\n") + assert r.returncode == 1 + + def test_fixture_valid_titles_still_pass(self): + r = run_checker(FIXTURES / "valid_titles.txt") + assert r.returncode == 0 + + +class TestWhitelistOverride: + def test_whitelist_exempts_and_is_visible(self, tmp_path): + draft = tmp_path / "d.md" + draft.write_text("这里有一个冒号:请豁免它。\n", encoding="utf-8") + assert run_checker(draft).returncode == 1 + + wl = tmp_path / "wl.txt" + wl.write_text(":\n", encoding="utf-8") + r = subprocess.run( + [sys.executable, "-X", "utf8", str(CHECKER), str(draft), "--whitelist", str(wl)], + capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + assert r.returncode == 0, r.stdout + # invalid regex must not crash the gate + wl.write_text("[unclosed\n", encoding="utf-8") + r2 = subprocess.run( + [sys.executable, "-X", "utf8", str(CHECKER), str(draft), "--whitelist", str(wl)], + capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + assert "WARNING" in r2.stderr + r2.stdout diff --git a/tests/test_runner.py b/tests/test_runner.py index a839411..5b8dacc 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -43,7 +43,7 @@ def test_dry_run_lists_steps_and_forwarded_args(self, tmp_path): r = run_runner(tmp_path, "--dry-run", "--script", "x.md", "--confirm", "--quality", "draft") assert r.returncode == 0 out = r.stdout - for name in ("optimize", "check_prose", "confirm", "tts", "render", "verify"): + for name in ("ingest", "check_prose", "confirm", "tts", "render", "verify"): assert name in out # forwarded args appear on the right steps only assert "--script" in out and "x.md" in out @@ -72,14 +72,14 @@ def test_invalid_step_selection_rejected(self, tmp_path): def test_step_selection_tolerates_spaces_and_trailing_comma(self, tmp_path): r = run_runner(tmp_path, "--dry-run", "--steps", " 1 , 2 ,", "--script", "x.md") assert r.returncode == 0 - assert "optimize" in r.stdout and "check_prose" in r.stdout + assert "ingest" in r.stdout and "check_prose" in r.stdout assert "tts" not in r.stdout.split("Skipped:")[0] def test_out_of_order_selection_runs_in_pipeline_order(self, tmp_path): r = run_runner(tmp_path, "--dry-run", "--steps", "2,1", "--script", "x.md") assert r.returncode == 0 body = r.stdout.split("Skipped:")[0] - assert body.index("optimize") < body.index("check_prose") + assert body.index("ingest") < body.index("check_prose") class TestCwdIndependence: