From a852222d33eb1905bbedf1156cd6729a61a8d24c Mon Sep 17 00:00:00 2001 From: TryWorld Date: Tue, 22 Sep 2026 08:50:23 +0800 Subject: [PATCH 1/5] fix: pipeline runner forwards step args, runs steps as modules, records run state - runner gains --script/--theme-content/--confirm/--quality/--video flags forwarded per-step via STEP_ARGS table; --confirm only forwards when the user passes it explicitly, so the confirmation gate still blocks by default - steps are invoked as python -m pipeline.steps. (cwd=REPO); step files unchanged and still runnable directly - run diagnostics written to /work/pipeline_run.json (diagnostics only, never a gate input) - step 01 without --script now fails with a clear message instead of an argparse crash; --dry-run/--list show forwarded args - adds tests/test_runner.py: dry-run forwarding, steps 1-2 markers, gate block/pass, stale-confirmation blocking, run-state file, invalid selection --- pipeline/runner.py | 144 ++++++++++++++++++++++++++++++++++++++++--- tests/test_runner.py | 140 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+), 9 deletions(-) create mode 100644 tests/test_runner.py diff --git a/pipeline/runner.py b/pipeline/runner.py index dc11ea5..19e0705 100644 --- a/pipeline/runner.py +++ b/pipeline/runner.py @@ -3,18 +3,41 @@ Usage: python -m pipeline.runner --project-dir [--steps 1,2,3] [--dry-run] + [--script ] [--theme-content ] + [--confirm] [--quality draft|high] [--video ] -Each step is a Python script in pipeline/steps/ named step_NN_.py. -Steps check their own preconditions and fail fast if prerequisites are missing. +Each step is a Python module in pipeline/steps/ named step_NN_.py, invoked +as `python -m pipeline.steps.step_NN_` (direct script invocation keeps +working; steps keep their own argparse). Per-step arguments are forwarded from +the runner flags through STEP_ARGS below. + +Run state is recorded in /work/pipeline_run.json for diagnostics +only — it is never an input to any gate. Gate semantics live in the steps +(marker freshness in pipeline/state.py) and are unchanged by this runner. """ import argparse +import json import subprocess import sys +import time +from datetime import datetime, timezone from pathlib import Path REPO = Path(__file__).resolve().parents[1] STEPS_DIR = Path(__file__).resolve().parent / "steps" +# Arguments each step accepts that the runner can forward. Steps keep their own +# argparse definitions; this table only controls what the runner passes through. +# A flag is forwarded only when the user set it explicitly on the runner CLI +# (or, for --confirm, when the flag is present). +STEP_ARGS: dict[int, list[str]] = { + 1: ["--script", "--theme-content"], + 3: ["--confirm"], + 4: ["--theme-content"], + 5: ["--quality"], + 6: ["--video"], +} + def discover_steps() -> list[tuple[int, str, Path]]: """Find all step_NN_*.py scripts sorted by step number.""" @@ -27,18 +50,72 @@ def discover_steps() -> list[tuple[int, str, Path]]: return steps +def forwarded_args(step_num: int, args: argparse.Namespace) -> list[str]: + """Build the argument list the runner passes to one step. + + Only flags the user set explicitly on the runner command line are + forwarded; otherwise the step's own defaults apply. --confirm is forwarded + only when present, so the confirmation gate still blocks by default. + """ + forwarded: list[str] = [] + for flag in STEP_ARGS.get(step_num, []): + dest = flag.lstrip("-").replace("-", "_") + value = getattr(args, dest, None) + if flag == "--confirm": + if value: + forwarded.append(flag) + elif value is not None: + forwarded += [flag, str(value)] + return forwarded + + +def forwarded_summary(step_num: int, args: argparse.Namespace) -> dict[str, str]: + """Human/JSON-friendly view of what will be forwarded to a step.""" + summary: dict[str, str] = {} + for flag in STEP_ARGS.get(step_num, []): + dest = flag.lstrip("-").replace("-", "_") + value = getattr(args, dest, None) + if flag == "--confirm": + if value: + summary[flag] = "true" + elif value is not None: + summary[flag] = str(value) + return summary + + def run_step(script: Path, project_dir: Path, extra_args: list[str]) -> subprocess.CompletedProcess: - """Run a single step script.""" - cmd = [sys.executable, "-X", "utf8", str(script), "--project-dir", str(project_dir)] + """Run a single step as a module (repo root on sys.path via cwd).""" + cmd = [ + sys.executable, "-X", "utf8", "-m", f"pipeline.steps.{script.stem}", + "--project-dir", str(project_dir), + ] cmd += extra_args - return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace") + return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=str(REPO)) + + +def write_run_state(state_path: Path, state: dict) -> None: + """Persist run diagnostics; never blocks the pipeline on failure.""" + try: + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + except OSError as exc: + print(f"warning: cannot write run state {state_path}: {exc}", file=sys.stderr) def main() -> int: parser = argparse.ArgumentParser(description="Paper Algorithm pipeline runner") parser.add_argument("--project-dir", type=Path, default=Path.cwd()) parser.add_argument("--steps", default="", help="Comma-separated step numbers to run (default: all)") - parser.add_argument("--dry-run", action="store_true", help="Show steps without running") + parser.add_argument("--script", type=Path, default=None, help="Input script for step 01 (.md/.txt)") + parser.add_argument("--theme-content", type=Path, default=None, + 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("--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, + help="Main video path forwarded to step 06 when outputs/ holds multiple mp4 files") + parser.add_argument("--dry-run", action="store_true", help="Show steps and forwarded args without running") parser.add_argument("--list", action="store_true", help="List available steps") args = parser.parse_args() @@ -50,7 +127,8 @@ def main() -> int: if args.list: print("Available steps:") for num, name, path in steps: - print(f" {num:02d} {name:30s} {path.name}") + forwardable = ", ".join(STEP_ARGS.get(num, [])) or "-" + print(f" {num:02d} {name:30s} {path.name:24s} forwards: {forwardable}") return 0 selected = steps @@ -63,13 +141,36 @@ def main() -> int: if not selected: parser.error("--steps does not select any available step") + selected_nums = {num for num, _, _ in selected} + + # Step 01 needs an input script; give a clear error instead of an argparse crash. + if 1 in selected_nums and not args.script: + print("FAIL: step 01 requires --script .") + print("Example: python -m pipeline.runner --project-dir --steps 1,2 --script script.md") + return 2 + if args.dry_run: print("Steps to run:") for num, name, path in selected: - print(f" {num:02d} {name}") + forwarded = forwarded_summary(num, args) + suffix = f" [forwards: {forwarded}]" if forwarded else "" + print(f" {num:02d} {name}{suffix}") + skipped = [(n, name) for n, name, _ in steps if n not in selected_nums] + if skipped: + print("\nSkipped:") + for num, name in skipped: + print(f" {num:02d} {name}") print(f"\nProject dir: {args.project_dir}") return 0 + state: dict = { + "startedAt": datetime.now(timezone.utc).isoformat(), + "projectDir": str(args.project_dir), + "steps": [], + } + state_path = args.project_dir / "work" / "pipeline_run.json" + write_run_state(state_path, state) + print(f"Pipeline runner | project: {args.project_dir}") print(f"Steps: {len(selected)}") print("=" * 60) @@ -77,17 +178,42 @@ def main() -> int: for num, name, path in selected: print(f"\n[{num:02d}] {name}") print("-" * 40) - result = run_step(path, args.project_dir, []) + forwarded = forwarded_args(num, args) + started = time.monotonic() + result = run_step(path, args.project_dir, forwarded) + duration_ms = round((time.monotonic() - started) * 1000) if result.stdout: print(result.stdout) if result.stderr: print(result.stderr, file=sys.stderr) + + entry: dict = { + "step": num, + "name": name, + "status": "ok" if result.returncode == 0 else "failed", + "exitCode": result.returncode, + "durationMs": duration_ms, + "args": forwarded_summary(num, args), + } + if result.returncode != 0 and num == 3 and not args.confirm: + entry["status"] = "blocked" + print("\nConfirmation gate blocked: review the draft, then re-run with --confirm.") + state["steps"].append(entry) + write_run_state(state_path, state) + if result.returncode != 0: print(f"\nFAILED at step {num:02d} ({name}), exit code {result.returncode}") print("Stopping pipeline.") + state["result"] = "failed" + state["finishedAt"] = datetime.now(timezone.utc).isoformat() + write_run_state(state_path, state) return result.returncode print(f"[{num:02d}] OK") + state["result"] = "complete" + state["finishedAt"] = datetime.now(timezone.utc).isoformat() + write_run_state(state_path, state) + print("\n" + "=" * 60) print("Pipeline complete.") return 0 diff --git a/tests/test_runner.py b/tests/test_runner.py new file mode 100644 index 0000000..b88455b --- /dev/null +++ b/tests/test_runner.py @@ -0,0 +1,140 @@ +"""End-to-end tests for pipeline/runner.py (offline: steps 01-03 only). + +Steps 04-06 need edge-tts / ffmpeg / hyperframes, so their forwarding is +covered indirectly via --dry-run assertions and marker-freshness failures +(which happen before any external tool is invoked). +""" +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] +CLEAN_FIXTURE = Path(__file__).resolve().parent / "fixtures" / "clean_prose.md" + + +def run_runner(project_dir: Path, *extra: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, "-X", "utf8", "-m", "pipeline.runner", + "--project-dir", str(project_dir), *extra], + cwd=REPO, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + + +def prepare_steps_1_2(project_dir: Path) -> subprocess.CompletedProcess: + return run_runner(project_dir, "--steps", "1,2", "--script", str(CLEAN_FIXTURE)) + + +def prepare_confirmed_project(project_dir: Path) -> None: + r = prepare_steps_1_2(project_dir) + assert r.returncode == 0, f"steps 1,2 failed:\n{r.stdout}\n{r.stderr}" + r = run_runner(project_dir, "--steps", "3", "--confirm") + assert r.returncode == 0, f"step 3 --confirm failed:\n{r.stdout}\n{r.stderr}" + + +class TestDryRun: + 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"): + assert name in out + # forwarded args appear on the right steps only + assert "--script" in out and "x.md" in out + assert "--confirm" in out + assert "--quality" in out and "draft" in out + assert str(tmp_path) in out + + def test_dry_run_does_not_write_state_or_dirs(self, tmp_path): + r = run_runner(tmp_path, "--dry-run", "--steps", "1", "--script", "x.md") + assert r.returncode == 0 + assert not (tmp_path / "work").exists() + + +class TestStepSelection: + def test_step1_requires_script(self, tmp_path): + r = run_runner(tmp_path, "--steps", "1") + assert r.returncode != 0 + assert "--script" in (r.stdout + r.stderr) + + def test_invalid_step_selection_rejected(self, tmp_path): + for selection in ("999", "abc"): + r = run_runner(tmp_path, "--steps", selection) + assert r.returncode != 0 + assert "step" in (r.stdout + r.stderr).lower() + + +class TestSteps12: + def test_steps_1_2_run_and_write_markers(self, tmp_path): + from pipeline.state import marker_matches_draft + + r = prepare_steps_1_2(tmp_path) + assert r.returncode == 0, f"steps 1,2 failed:\n{r.stdout}\n{r.stderr}" + + draft = tmp_path / "work" / "draft.md" + prose_pass = tmp_path / "work" / ".prose_pass" + assert draft.exists() + assert draft.read_text(encoding="utf-8") == CLEAN_FIXTURE.read_text(encoding="utf-8") + assert prose_pass.exists() + assert marker_matches_draft(prose_pass, draft) + + def test_run_state_file_records_steps(self, tmp_path): + r = prepare_steps_1_2(tmp_path) + assert r.returncode == 0 + state_path = tmp_path / "work" / "pipeline_run.json" + assert state_path.exists() + state = json.loads(state_path.read_text(encoding="utf-8")) + assert state["projectDir"] == str(tmp_path) + assert [s["step"] for s in state["steps"]] == [1, 2] + assert all(s["status"] == "ok" for s in state["steps"]) + assert state["steps"][0]["args"].get("--script") + + +class TestConfirmationGate: + def test_step3_blocks_without_confirm_and_passes_with(self, tmp_path): + from pipeline.state import marker_matches_draft + + prepare_steps_1_2(tmp_path) + draft = tmp_path / "work" / "draft.md" + confirmed = tmp_path / "work" / ".confirmed" + + r = run_runner(tmp_path, "--steps", "3") + assert r.returncode != 0 + assert "confirm" in (r.stdout + r.stderr).lower() + assert not confirmed.exists() + + r = run_runner(tmp_path, "--steps", "3", "--confirm") + assert r.returncode == 0, f"step 3 --confirm failed:\n{r.stdout}\n{r.stderr}" + assert confirmed.exists() + assert marker_matches_draft(confirmed, draft) + + def test_stale_confirmation_blocks_downstream(self, tmp_path): + prepare_confirmed_project(tmp_path) + draft = tmp_path / "work" / "draft.md" + draft.write_text(draft.read_text(encoding="utf-8") + "\n新增一句未经确认的修改。\n", encoding="utf-8") + + # Step 04 fails on the stale-confirmation check before touching edge-tts. + r = run_runner(tmp_path, "--steps", "4") + assert r.returncode != 0 + combined = r.stdout + r.stderr + assert "stale" in combined.lower() or "确认" in combined + + # Same via the documented multi-step continuation. + r = run_runner(tmp_path, "--steps", "3,4", "--confirm") + assert r.returncode != 0 + assert "stale" in (r.stdout + r.stderr).lower() or "确认" in (r.stdout + r.stderr) + + +class TestList: + def test_list_shows_forwardable_args(self, tmp_path): + r = run_runner(tmp_path, "--list") + assert r.returncode == 0 + assert "forwards:" in r.stdout + assert "--script" in r.stdout + assert "--confirm" in r.stdout From 6feacfdf70e7e5bb11f5dc82579a56fe418e458c Mon Sep 17 00:00:00 2001 From: TryWorld Date: Tue, 22 Sep 2026 08:51:42 +0800 Subject: [PATCH 2/5] fix: fetch_aihot.py gains --base-url to match the .ps1 fallback - adds --base-url (default https://aihot.virxact.com) used for the daily and items endpoints; behavior otherwise unchanged - adds tests/test_fetch_aihot.py: import, CLI help, and fetch_json retry behavior for 429/503 vs non-retryable HTTP/URL errors (mocked, no network) --- skills/tryworld-topics/scripts/fetch_aihot.py | 7 +- tests/test_fetch_aihot.py | 89 +++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 tests/test_fetch_aihot.py diff --git a/skills/tryworld-topics/scripts/fetch_aihot.py b/skills/tryworld-topics/scripts/fetch_aihot.py index 4b82f5a..85de4f2 100644 --- a/skills/tryworld-topics/scripts/fetch_aihot.py +++ b/skills/tryworld-topics/scripts/fetch_aihot.py @@ -12,6 +12,7 @@ BASE = "https://aihot.virxact.com" UA = "Mozilla/5.0 (compatible; paper-algorithm/1.0)" + def fetch_json(url: str, retries: int = 3) -> dict | list: """Fetch JSON with retry on 429/5xx.""" last_err = None @@ -38,20 +39,22 @@ def main() -> int: parser.add_argument("--days", type=int, default=7) parser.add_argument("--take", type=int, default=100) parser.add_argument("--out", type=Path, default=Path("work/aihot")) + parser.add_argument("--base-url", default=BASE, + help=f"AIHOT-compatible API base URL (default: {BASE})") args = parser.parse_args() args.out.mkdir(parents=True, exist_ok=True) daily = None try: - daily = fetch_json(f"{BASE}/api/public/daily") + daily = fetch_json(f"{args.base_url}/api/public/daily") except Exception as e: print(f"warning: daily fetch failed: {e}", file=sys.stderr) since = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - args.days * 86400)) items = None try: - items = fetch_json(f"{BASE}/api/public/items?mode=selected&since={since}&take={args.take}") + items = fetch_json(f"{args.base_url}/api/public/items?mode=selected&since={since}&take={args.take}") except Exception as e: print(f"warning: items fetch failed: {e}", file=sys.stderr) diff --git a/tests/test_fetch_aihot.py b/tests/test_fetch_aihot.py new file mode 100644 index 0000000..1c5647c --- /dev/null +++ b/tests/test_fetch_aihot.py @@ -0,0 +1,89 @@ +"""Tests for fetch_aihot.py (import, CLI help, retry logic; no network).""" +import importlib.util +import subprocess +import sys +import urllib.error +from pathlib import Path +from unittest.mock import MagicMock, patch + +REPO = Path(__file__).resolve().parents[1] +FETCH = REPO / "skills" / "tryworld-topics" / "scripts" / "fetch_aihot.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location("fetch_aihot", str(FETCH)) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + return m + + +def _ok_response(payload: bytes = b'{"items": []}'): + resp = MagicMock() + resp.read.return_value = payload + resp.__enter__ = lambda self: self + resp.__exit__ = lambda self, *args: None + return resp + + +def _http_error(code: int) -> urllib.error.HTTPError: + return urllib.error.HTTPError("https://example.invalid/x", code, "err", None, None) + + +class TestImport: + def test_imports_without_third_party(self): + m = load_module() + assert m.BASE == "https://aihot.virxact.com" + assert callable(m.fetch_json) + + +class TestCli: + def test_help_exposes_base_url(self): + r = subprocess.run( + [sys.executable, "-X", "utf8", str(FETCH), "--help"], + capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + assert r.returncode == 0 + assert "--base-url" in r.stdout + assert "https://aihot.virxact.com" in r.stdout + + +class TestFetchJsonRetry: + def test_retries_on_429_then_succeeds(self): + m = load_module() + with patch.object(m.time, "sleep") as sleep_mock, \ + patch("urllib.request.urlopen", side_effect=[_http_error(429), _ok_response()]) as urlopen: + result = m.fetch_json("https://example.invalid/x") + assert result == {"items": []} + assert urlopen.call_count == 2 + assert sleep_mock.called + + def test_retries_on_503_then_succeeds(self): + m = load_module() + with patch.object(m.time, "sleep"), \ + patch("urllib.request.urlopen", side_effect=[_http_error(503), _ok_response()]): + assert m.fetch_json("https://example.invalid/x") == {"items": []} + + def test_non_retryable_http_error_raises_immediately(self): + m = load_module() + with patch.object(m.time, "sleep") as sleep_mock, \ + patch("urllib.request.urlopen", side_effect=_http_error(404)) as urlopen: + try: + m.fetch_json("https://example.invalid/x") + raised = False + except urllib.error.HTTPError: + raised = True + assert raised + assert urlopen.call_count == 1 + assert not sleep_mock.called + + def test_url_error_raises_immediately(self): + m = load_module() + with patch.object(m.time, "sleep") as sleep_mock, \ + patch("urllib.request.urlopen", side_effect=urllib.error.URLError("dns down")): + try: + m.fetch_json("https://example.invalid/x") + raised = False + except urllib.error.URLError: + raised = True + assert raised + assert not sleep_mock.called From 33e8035a019546191b18bd3ed363fed1c59227a9 Mon Sep 17 00:00:00 2001 From: TryWorld Date: Tue, 22 Sep 2026 08:55:22 +0800 Subject: [PATCH 3/5] docs: mark .ps1 scripts deprecated, point all docs at the .py canonical path - notify_delivery.ps1 / fetch_aihot.ps1 / doctor.ps1 get a DEPRECATED header (kept as Windows PowerShell fallbacks, no new features synced) - tryworld-topics SKILL.md + api.md, tryworld-koubo SKILL.md + workflow.md now lead with the .py scripts; registry-env credential fallback and -DryRun are documented as .ps1-only - CONTRIBUTING.md structure lists doctor.py; both READMEs note the .ps1 fallbacks are deprecated --- CONTRIBUTING.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- scripts/doctor.ps1 | 4 +++- skills/tryworld-koubo/SKILL.md | 6 +++--- skills/tryworld-koubo/references/workflow.md | 12 +++++++----- skills/tryworld-koubo/scripts/notify_delivery.ps1 | 4 +++- skills/tryworld-topics/SKILL.md | 4 ++-- skills/tryworld-topics/references/api.md | 2 +- skills/tryworld-topics/scripts/fetch_aihot.ps1 | 4 +++- 10 files changed, 25 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b604150..f448aa0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ paper-algorithm/ │ └── tryworld-topics/ # 选题技能(AIHOT 数据 → 选题清单) ├── scripts/ # 仓库级检查脚本 │ ├── check_skills.py # 编译技能脚本 + 跑活人感门禁 -│ └── doctor.ps1 # 环境自检 +│ └── doctor.py # 环境自检(跨平台;doctor.ps1 为 Windows 回退,已弃用) ├── examples/ # 默认主题产出的示例 └── README.md / README.zh-CN.md # 中英索引 ``` diff --git a/README.md b/README.md index c47e2d0..3ddc532 100644 --- a/README.md +++ b/README.md @@ -370,7 +370,7 @@ A skill fires when the request matches its trigger words — no commands to memo Run `npx hyperframes doctor` to check the environment at once. These skills are built for TryWorld's own production workflow, so a few things are assumed — all adjustable in the skill files: -- **Cross-platform** — data-fetching (`tryworld-topics`) and email-notice (`tryworld-koubo`) scripts are Python (`.ps1` fallback versions retained); `tryworld-topics` documents a curl fallback in its `references/`. +- **Cross-platform** — data-fetching (`tryworld-topics`) and email-notice (`tryworld-koubo`) scripts are Python (`.ps1` fallback versions retained but deprecated — the `.py` scripts are the canonical path); `tryworld-topics` documents a curl fallback in its `references/`. - **Python 3.10+** — `pip install edge-tts` for the voiceover pipeline (`tryworld-paper/scripts/tts_yunxi.py`, default Yunxi + voice presets); `tryworld-paper/scripts/check_prose.py` needs no third-party packages. - **Node.js >= 22 + HyperFrames** — rendering (`npx hyperframes render` / `lint` / `validate` / `inspect`). - **FFmpeg** (with ffprobe, on PATH) — audio processing. diff --git a/README.zh-CN.md b/README.zh-CN.md index 1dd7cb4..3249efb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -370,7 +370,7 @@ Copy-Item -Path .\skills\tryworld-paper -Destination "$env:USERPROFILE\.agents\s 运行 `npx hyperframes doctor` 可一键检查环境。这些技能按试界自己的工作流定制,默认假设如下——全部都可以在技能文件里自行调整: -- **跨平台**——拉取数据(tryworld-topics)与发通知邮件(tryworld-koubo)的脚本均为 Python(同时保留 `.ps1` 回退版本);`tryworld-topics` 的 `references/` 里提供了 curl 替代。 +- **跨平台**——拉取数据(tryworld-topics)与发通知邮件(tryworld-koubo)的脚本均为 Python(`.py` 为规范路径;`.ps1` 回退版本保留但已弃用,文件头有 DEPRECATED 声明);`tryworld-topics` 的 `references/` 里提供了 curl 替代。 - **Python 3.10+**——`pip install edge-tts` 用于配音管线(`tryworld-paper/scripts/tts_yunxi.py`,默认云希、内置多音色预设);`tryworld-paper/scripts/check_prose.py` 无需第三方包。 - **Node.js >= 22 + HyperFrames**——渲染(`npx hyperframes render` / `lint` / `validate` / `inspect`)。 - **FFmpeg**(含 ffprobe,加入 PATH)——音频处理。 diff --git a/scripts/doctor.ps1 b/scripts/doctor.ps1 index 2caa1c4..ee9a1f1 100644 --- a/scripts/doctor.ps1 +++ b/scripts/doctor.ps1 @@ -1,4 +1,6 @@ -param( +# DEPRECATED: 保留作 Windows PowerShell 回退。规范路径为同名 .py(跨平台) +# scripts/doctor.py,详见 README「Environment」。新功能不再同步到本脚本。 +param( [switch]$Json ) diff --git a/skills/tryworld-koubo/SKILL.md b/skills/tryworld-koubo/SKILL.md index 883a5b5..63e3cdb 100644 --- a/skills/tryworld-koubo/SKILL.md +++ b/skills/tryworld-koubo/SKILL.md @@ -48,7 +48,7 @@ description: 试界TryWorld AI 口播视频的统一定位入口,覆盖口播 ## 模式 B:选题 → 写稿 → 优化 → 出片 -1. 调 `$tryworld-topics`:运行其 `scripts/fetch_aihot.ps1` 拉数据,读 `references/selection-rules.md`,产出 3-8 个候选选题。 +1. 调 `$tryworld-topics`:运行其 `scripts/fetch_aihot.py` 拉数据,读 `references/selection-rules.md`,产出 3-8 个候选选题。 2. **去重(以工作区实际成片为准)**:扫描 `content.topic_dedup_dir`(内容主题 JSON 定义,默认 `E:\Codex口播视频`)各一级子文件夹,候选选题按关键词与该文件夹名/口播稿标题匹配;若对应文件夹 `outputs/` 同时存在成片视频(*.mp4)与横竖封面(cover_4x3.png / cover_3x4.png 或等价命名)→ 判定已做,从清单排除;反之保留。命令见 `references/workflow.md`。 3. **闸门 1:展示选题清单,停,等用户挑选**(用户可要求换一批或给自定义主题)。 4. 按选中选题 + 素材链接写初稿(规范见"写稿规范")。 @@ -80,7 +80,7 @@ description: 试界TryWorld AI 口播视频的统一定位入口,覆盖口播 - 主视频(烧录字幕)、口播稿/文案、横竖封面、平台标题、字幕时间轴 - **发布计划.txt**(固定内容):小红书 中午 12:30 / 抖音 晚上 19:30 / B站 晚上 20:30 / 微信视频号 晚上 20:30 -- **成片交付后自动发通知邮件**:运行 `scripts/notify_delivery.ps1 -ProjectDir <项目目录>`,邮件含产物路径(视频/横竖封面/平台标题)+ 四平台发布时间提醒;凭证未配置时跳过,不阻塞交付。详见 `references/workflow.md`。 +- **成片交付后自动发通知邮件**:运行 `python -X utf8 scripts/notify_delivery.py --project-dir <项目目录>`,邮件含产物路径(视频/横竖封面/平台标题)+ 四平台发布时间提醒;凭证未配置时跳过,不阻塞交付。详见 `references/workflow.md`。 ## 活人感门禁(不通过不进闸门) @@ -96,7 +96,7 @@ description: 试界TryWorld AI 口播视频的统一定位入口,覆盖口播 ## 资源 - `references/workflow.md`:两种模式详细步骤、去重扫描命令、写稿细节、异常处理 -- `scripts/notify_delivery.ps1`:成片交付邮件通知(调用 `$qq-email` 发信) +- `scripts/notify_delivery.py`:成片交付邮件通知(调用 `$qq-email` 发信;跨平台规范路径,`.ps1` 为 Windows 回退,已弃用) - `../tryworld-paper/scripts/check_prose.py`:活人感硬禁项检查脚本(TryWorld 改造版,源自 KKKKhazix/human-writing v1.1.0,MIT;禁令上移到修辞动作级) ### 品牌适配边界 diff --git a/skills/tryworld-koubo/references/workflow.md b/skills/tryworld-koubo/references/workflow.md index 6fd687c..7a4a757 100644 --- a/skills/tryworld-koubo/references/workflow.md +++ b/skills/tryworld-koubo/references/workflow.md @@ -33,7 +33,7 @@ ### B1 拉数据与产出选题 1. 进入 `$tryworld-topics`: - - 运行 `scripts/fetch_aihot.ps1`(默认 7 天、100 条,落盘 `work/aihot/`); + - 运行 `python -X utf8 scripts/fetch_aihot.py`(跨平台规范路径;默认 7 天、100 条,落盘 `work/aihot/`;`--base-url` 可指定其他 AIHOT 兼容端点); - 读 `references/selection-rules.md`; - 产出 3-8 个候选选题(选题名、为什么选、核心素材+原文链接、切入角度、优先级、命中原理)。 @@ -123,10 +123,12 @@ B站:晚上 20:30 成片交付后自动运行(不阻塞交付): ```powershell -powershell -File "$env:USERPROFILE\.agents\skills\tryworld-koubo\scripts\notify_delivery.ps1" -ProjectDir "E:\Codex口播视频\<项目slug>" +python -X utf8 "$env:USERPROFILE\.agents\skills\tryworld-koubo\scripts\notify_delivery.py" --project-dir "E:\Codex口播视频\<项目slug>" ``` -- 邮件主题与发布计划从主题文件(`paper-algorithm.json` 或 `-ThemeFile` 指定的文件)读取 `brand.platform_name` 与 `publish_plan`;文件缺失或字段不存在时回退到硬编码默认值。 +(`scripts/notify_delivery.ps1` 为 Windows PowerShell 回退,已弃用,文件头有 DEPRECATED 声明;仅在没有 Python 环境时使用,且不支持下文「凭证读取顺序」中的注册表回退。) + +- 邮件主题与发布计划从主题文件(`paper-algorithm.json` 或 `--theme-file` 指定的文件)读取 `brand.platform_name` 与 `publish_plan`;文件缺失或字段不存在时回退到硬编码默认值。 - 邮件内容:主题 `✅ {品牌名} 口播成片已交付 · <项目> · <日期>`(品牌名取自主题文件 `brand.platform_name`);正文 = 完成提示 + 【平台标题】文字(读取 titles.txt 直接写入正文,不作为附件)+ 【发布计划】(内容取自主题文件 `publish_plan`)。 - 附件规则(QQ 邮箱附件上限约 50MB,含 base64 开销,单文件安全阈值 35MB): @@ -137,8 +139,8 @@ powershell -File "$env:USERPROFILE\.agents\skills\tryworld-koubo\scripts\notify_ - 如需把大视频随邮件发送:先将视频压到 35MB 内(如降低码率/分辨率),或改用网盘/仓库链接。 - 前置:调用 `$qq-email` 的 `send.js`,需要环境变量 `QQ_EMAIL_ACCOUNT`(收件人=自己)与 `QQ_EMAIL_AUTH_CODE`(IMAP/SMTP 授权码)。 - 凭证未配置或 send.js 缺失:脚本警告并跳过,不影响交付。 -- 凭证读取顺序:进程环境变量 → 注册表用户环境变量(HKCU:\Environment)→ 缺失则跳过;setx 配置后无需重启即生效。 -- 预览不发送:加 `-DryRun` 参数。 +- 凭证读取顺序:进程环境变量 → 注册表用户环境变量(HKCU:\Environment,仅 .ps1 回退支持)→ 缺失则跳过;.py 规范路径只读进程环境变量,setx 配置后需重开终端(或在本会话先 `$env:` 赋值)再运行。 +- 预览不发送:加 `--dry-run` 参数(.ps1 回退为 `-DryRun`)。 - 配置方式(用户自行执行,勿在对话中粘贴授权码): `setx QQ_EMAIL_ACCOUNT "你的QQ邮箱"` 与 `setx QQ_EMAIL_AUTH_CODE "授权码"`,重开终端生效。 diff --git a/skills/tryworld-koubo/scripts/notify_delivery.ps1 b/skills/tryworld-koubo/scripts/notify_delivery.ps1 index 5f56938..a9b124e 100644 --- a/skills/tryworld-koubo/scripts/notify_delivery.ps1 +++ b/skills/tryworld-koubo/scripts/notify_delivery.ps1 @@ -1,4 +1,6 @@ -# tryworld-koubo · 成片交付邮件通知(Windows PowerShell) +# DEPRECATED: 保留作 Windows PowerShell 回退。规范路径为同名 .py(跨平台) +# scripts/notify_delivery.py,详见 README「Environment」。新功能不再同步到本脚本。 +# tryworld-koubo · 成片交付邮件通知(Windows PowerShell) # 用法: powershell -File scripts/notify_delivery.ps1 -ProjectDir <项目目录> [-ThemeFile <主题JSON路径>] [-DryRun] param( [string]$ProjectDir = (Get-Location).Path, diff --git a/skills/tryworld-topics/SKILL.md b/skills/tryworld-topics/SKILL.md index f74f4c8..ec34a1f 100644 --- a/skills/tryworld-topics/SKILL.md +++ b/skills/tryworld-topics/SKILL.md @@ -13,7 +13,7 @@ description: 基于 aihot.virxact.com 最新 AI 资讯,为 AI 口播频道( ## 流程 -1. **拉数据**:运行 `scripts/fetch_aihot.ps1`(Windows PowerShell;拉最近 7 天精选 + 最新日报,落盘到 `work/aihot/`;`-BaseUrl` 可指定其他 AIHOT 兼容端点,默认官方地址)。非 Windows 环境按 `references/api.md` 的端点用 curl 拉取(**必须带浏览器 UA**,否则 403)。 +1. **拉数据**:运行 `python -X utf8 scripts/fetch_aihot.py`(跨平台;拉最近 7 天精选 + 最新日报,落盘到 `work/aihot/`;`--base-url` 可指定其他 AIHOT 兼容端点,默认官方地址)。`scripts/fetch_aihot.ps1` 为 Windows PowerShell 回退,已弃用(文件头有 DEPRECATED 声明),新功能不再同步。无 Python 环境时按 `references/api.md` 的端点用 curl 拉取(**必须带浏览器 UA**,否则 403)。 2. **读规则**:打开 `references/selection-rules.md`,按频道定位与筛选清单处理数据。 3. **筛选题**:从拉到的条目中选出 3-8 个候选,每条标注:选题名、为什么选(命中哪个增长原理)、核心素材与原文链接、切入角度、优先级。 4. **输出**:用 `references/selection-rules.md` 的模板输出选题清单(markdown,人话,不暴露接口细节)。用户选中某个选题后,可继续给口播稿骨架或直接转入 `$tryworld-paper` 流程。 @@ -29,7 +29,7 @@ description: 基于 aihot.virxact.com 最新 AI 资讯,为 AI 口播频道( ## 资源 -- `scripts/fetch_aihot.ps1`:一键拉取并生成 UTF-8 报告(Windows) +- `scripts/fetch_aihot.py`:一键拉取并生成 UTF-8 报告(跨平台,规范路径;`.ps1` 为 Windows 回退,已弃用) - `references/api.md`:AIHOT API 端点、路由规则、输出规范 - `references/selection-rules.md`:试界TryWorld 选题规则与输出模板(默认;可通过 content.topic_rules 替换) - `references/done-topics.md`:已做选题清单(持续维护) \ No newline at end of file diff --git a/skills/tryworld-topics/references/api.md b/skills/tryworld-topics/references/api.md index 7b4f1aa..dfdeea9 100644 --- a/skills/tryworld-topics/references/api.md +++ b/skills/tryworld-topics/references/api.md @@ -4,7 +4,7 @@ - 调 `/api/public/*` 必须带浏览器 UA,否则 403: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36` -- PowerShell 用 `System.Net.WebClient` + `Encoding=UTF8`(见 `scripts/fetch_aihot.ps1`);非 Windows 用 curl `-H "User-Agent: ..."`。 +- 规范做法是直接运行 `scripts/fetch_aihot.py`(跨平台,已内置 UA 与重试);自行实现时 PowerShell 用 `System.Net.WebClient` + `Encoding=UTF8`(参考 `scripts/fetch_aihot.ps1`,已弃用仅作回退),非 Windows 用 curl `-H "User-Agent: ..."`。 - 匿名访问,无需 Key;限流 600 req/min/IP,串行调用。 ## 端点 diff --git a/skills/tryworld-topics/scripts/fetch_aihot.ps1 b/skills/tryworld-topics/scripts/fetch_aihot.ps1 index 9b48d56..e38f039 100644 --- a/skills/tryworld-topics/scripts/fetch_aihot.ps1 +++ b/skills/tryworld-topics/scripts/fetch_aihot.ps1 @@ -1,4 +1,6 @@ -# AIHOT 口播选题 · 数据拉取脚本(Windows PowerShell) +# DEPRECATED: 保留作 Windows PowerShell 回退。规范路径为同名 .py(跨平台) +# scripts/fetch_aihot.py,详见 README「Environment」。新功能不再同步到本脚本。 +# AIHOT 口播选题 · 数据拉取脚本(Windows PowerShell) # 拉最近 N 天精选 + 最新日报,落盘 UTF-8 报告与原始 JSON。 param( [int]$Days = 7, From 1698455144d05b14ce1703d89428de528a226030 Mon Sep 17 00:00:00 2001 From: TryWorld Date: Tue, 22 Sep 2026 08:59:04 +0800 Subject: [PATCH 4/5] docs: consolidate duplicated rule lists, restructure CHANGELOG, drop hard-coded test count --- CHANGELOG.md | 24 +++++++++++++++++++ README.md | 16 ++++++------- README.zh-CN.md | 16 ++++++------- pipeline/steps/step_04_tts.py | 5 +++- .../references/writing-methodology.md | 11 +-------- 5 files changed, 45 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e24ddc1..d3c2e76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ ## [Unreleased] +_本分支(`refactor/runner-fixes-docs-consolidation`)的改动记录见下文;发布时再并入正式版本号。_ + +### Added + +- `tests/test_runner.py`:覆盖 pipeline runner 的参数转发、门禁拦截、运行状态文件等行为 +- `tests/test_fetch_aihot.py`:覆盖 `fetch_aihot.py` 的 CLI 与 `fetch_json` 重试逻辑 + +### Changed + +- `pipeline/runner.py`:修复参数转发缺失(README 中的命令此前无法直接运行)、改为以模块方式调用各步骤、新增 `--list` 与运行诊断 JSON、明确报错与门禁提示 +- `fetch_aihot.py`:新增 `--base-url`(默认 `https://aihot.virxact.com`),与 `.ps1` 回退脚本的 `-BaseUrl` 对齐 +- `scripts/notify_delivery.ps1`、`skills/tryworld-topics/scripts/fetch_aihot.ps1`、`scripts/doctor.ps1`:标注为已废弃的 Windows 回退脚本,`.py` 为唯一规范路径 +- `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]`,改为真正的“未发布”区 + +### Fixed + +- `README.md` / `README.zh-CN.md`:测试数量不再硬编码,改为引导用 `pytest --collect-only` 读取实际数量 + +## [1.0.0] - 2026-09-05 + +首个正式版本。 + ### Added - `scripts/check_skills.py`:仓库级检查脚本,编译技能 Python 脚本并跑活人感门禁 diff --git a/README.md b/README.md index 3ddc532..a592dc9 100644 --- a/README.md +++ b/README.md @@ -317,14 +317,14 @@ Full edge-tts voice ids are accepted too. By default the pipeline never asks abo 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. -- **Flip-flop rhetoric**: sets up a misunderstanding the reader never had, then overturns it for dramatic lift. Known guises include 不是……而是……, 并非……而是……, 表面……实际……, 看似……实则……, 你以为……其实……, 回头才发现, 说到底, 答案恰恰相反 — state judgments directly, judgment first, evidence after. -- **Triple+ parallel structure**: three or more identical constructions; keep at most two. -- **Lyric metaphor**: no concrete verbs bolted onto abstract nouns ("time keeps the details" type); unaffected when writing about concrete things. -- **Nominalization**: "实现了效率的提升" → say how much faster, how many people saved. -- **Punctuation tiers**: all dashes banned; colons only to introduce direct speech. -- **Jargon tiers**: absolute bans + context-sensitive words, maintained by the checker. +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: -`tryworld-paper/scripts/check_prose.py` (from [human-writing](https://github.com/KKKKhazix/human-writing) v1.1.0, MIT) runs these checks automatically and adds statistical signals — sentence-length variance, conjunction density, model-favorite lyric words, 「」-quote density. **Zero hard violations required before the user-confirmation gate; failing means no delivery.** +| 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) | + +`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.** --- @@ -363,7 +363,7 @@ A skill fires when the request matches its trigger words — no commands to memo ### In-repo check scripts - `python -X utf8 scripts/check_skills.py`: compiles skill Python scripts and runs the aliveness gate -- `python -m pytest tests/ -v`: run 23 unit tests (check_prose, verify_output, tts_yunxi, check_skills) over `examples/` scripts and titles. +- `python -m pytest tests/ -v`: run the unit-test suite (check_prose, verify_output, tts_yunxi, check_skills, fetch_aihot, pipeline runner) over `examples/` scripts and titles. The exact count moves as tests are added — read it from `python -m pytest tests/ --collect-only -q` instead of hard-coding it in this file. - `python scripts/doctor.py`: checks Python, Node, FFmpeg/ffprobe, edge-tts, HyperFrames, and optional email credentials. Exits 1 when a required item is missing. ### Environment diff --git a/README.zh-CN.md b/README.zh-CN.md index 3249efb..3a9f85c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -317,14 +317,14 @@ python tryworld-paper/scripts/tts_yunxi.py script.txt --out work/audio --voice x 成片之前,每一版口播稿与平台标题都要过一道机器门禁——**禁的是修辞动作,不是字面**。换一套字做同一个动作,仍然算命中。 -- **翻案腔**:先立读者没有的误解再推翻抬价。已知外衣不限于「不是……而是……」「并非……而是……」「不在于……而在于……」「表面……实际……」「看似……实则……」「你以为……其实……」「回头才发现」「说到底」「答案恰恰相反」,判断从正面下,先给判断再给依据。 -- **同构排比**:三项以上整齐排比,两项为限。 -- **抒情借喻**:不给抽象名词配具体动词(「时间保管细节」类),写具体事物不受影响。 -- **动词名词化**:「实现了效率的提升」还原成「快了多少、省了几个人」。 -- **标点分级**:破折号全禁;冒号只允许引出人物直接原话。 -- **黑话两档**:绝对禁词 + 语境判断词,清单由检测器维护。 +禁项(翻案腔、同构排比、抒情借喻、动词名词化、标点分级、硬停词、模型路标、黑话两档)只在两处定义,避免多份拷贝互相漂移: -检测器 `tryworld-paper/scripts/check_prose.py`(源自 [human-writing](https://github.com/KKKKhazix/human-writing) v1.1.0,MIT)自动执行以上检查,并额外给出统计层提示——句长变异系数、连词密度、模型偏爱抒情词、「」金句密度。**硬禁项清零才允许进入用户确认闸门,失败不交付。** +| 来源 | 内容 | +|---|---| +| `skills/tryworld-paper/references/workflow.md` §1.2.1 第 5 条 | 改稿规则与动作级定义 | +| `skills/tryworld-paper/scripts/check_prose.py` | 执行检测的脚本——绝对禁词 + 语境判断词,并给出统计层提示(句长变异系数、连词密度、模型偏爱抒情词、「」金句密度) | + +措辞不一致时以 `check_prose.py`(源自 [human-writing](https://github.com/KKKKhazix/human-writing) v1.1.0,MIT)为准。**硬禁项清零才允许进入用户确认闸门,失败不交付。** --- @@ -363,7 +363,7 @@ Copy-Item -Path .\skills\tryworld-paper -Destination "$env:USERPROFILE\.agents\s ### 仓库内检查脚本 - `python -X utf8 scripts/check_skills.py`:编译技能 Python 脚本,并对 `examples/` 的口播稿与标题跑活人感门禁 -- `python -m pytest tests/ -v`:运行 23 个单元测试(check_prose、verify_output、tts_yunxi、check_skills)。 +- `python -m pytest tests/ -v`:运行单元测试(check_prose、verify_output、tts_yunxi、check_skills、fetch_aihot、pipeline runner),覆盖 `examples/` 下的脚本与标题。测试数量会随新增用例变化,请用 `python -m pytest tests/ --collect-only -q` 读取实际数量,本文档不再硬编码数字。 - `python scripts/doctor.py`:检查 Python、Node、FFmpeg/ffprobe、edge-tts、HyperFrames 与可选邮件凭证。缺失必需项时退出码为 1。 ### 环境要求 diff --git a/pipeline/steps/step_04_tts.py b/pipeline/steps/step_04_tts.py index 446f0a3..c22c920 100644 --- a/pipeline/steps/step_04_tts.py +++ b/pipeline/steps/step_04_tts.py @@ -19,7 +19,10 @@ def main() -> int: parser = argparse.ArgumentParser(description="Step 04: TTS") parser.add_argument("--project-dir", type=Path, default=Path.cwd()) - parser.add_argument("--theme-content", type=Path, default=None, help="Path to visual theme JSON (default: paper-algorithm.json)") + parser.add_argument("--theme-content", type=Path, default=None, + help="Brand/visual theme JSON (default: themes/paper-algorithm.json). " + "Note: step 01 uses the same flag name for the *content* theme " + "(themes/content-default.json) — the two are different files.") args = parser.parse_args() confirmed = args.project_dir / "work" / ".confirmed" diff --git a/skills/tryworld-koubo/references/writing-methodology.md b/skills/tryworld-koubo/references/writing-methodology.md index 74cefab..a7edb24 100644 --- a/skills/tryworld-koubo/references/writing-methodology.md +++ b/skills/tryworld-koubo/references/writing-methodology.md @@ -5,13 +5,4 @@ - **写给耳朵**:新名词第一次出现说全称,代词能找到对象,数字只留听众必须记住的;一句话一口气说不完就拆成两句;完整读出声并计时,嘴里拗口必改。 - **段落推进**:每段新增一件东西(新事实/动作/例子/区别/后果),后一段接住前一段留下的问题;同一观点换说法不算推进,删。 - **禁假细节**:没有来源的精确时间、神态、天气、房间摆设、对白属于假细节,现实稿一律不用;不靠"老铁""兄弟们""泡杯茶慢慢说"穿论坛服装。 -- **硬禁项(净化后正文与平台标题同守,动作级定义)**:禁的是修辞动作不是字面,换一套字做同一动作仍算命中。 - - **翻案腔**:先立一个读者没有的误解再推翻抬价。已知外衣不限于"不是……而是……""并非……而是……""不在于……而在于……""与其说……不如说……""表面……实际……""看似……实则……"及变形"不是A,是B""不是A。而是B""你以为……其实……""回头才发现""说到底""答案恰恰相反""A不重要,重要的是B"。判断从正面下,先给判断再给依据;只有真的用材料走过误解到修正的过程,自我修正才可保留,且不能套固定句式。 - - **同构排比**:三项以上整齐排比,两项为限,第三项换说法或删掉。 - - **抒情借喻**:不给抽象名词配具体动词(时间保管细节、焦虑显出形状)。真写具体事物不受影响。 - - **动词名词化**:"完成了对流程的优化"→"把流程改顺了","实现了效率的提升"→"快了多少、省了几个人"。 - - **标点**:破折号(`—`/`——`/`–`)全禁;冒号只允许引出人物直接原话一种用法,"一句话总结:""核心是:"等提示性冒号禁(网址/代码/机器字段除外)。 - - **硬停词**:"说白了""说穿了""先说结论"。 - - **模型洞察路标**:"更微妙的是""还有一层""只说对了一半""值得注意的是""需要指出的是""从某种意义上说"(说楼层、数量等本义时不受影响)。 - - **商业与模型黑话**:完整清单见 `../tryworld-paper/scripts/check_prose.py`(绝对禁词 + 语境判断词两档)。 - - 原话命中禁令时改成转述或省略,不能靠引号保留。 +- **硬禁项(净化后正文与平台标题同守,动作级定义)**:定义与清单唯一来源是 `../tryworld-paper/references/workflow.md` §1.2.1 第 5 条(翻案腔、三项以上同构排比、抒情借喻、动词名词化、标点、硬停词、模型洞察路标、黑话),并由 `../tryworld-paper/scripts/check_prose.py` 机器判定(绝对禁词 + 语境判断词两档)。本文件不再抄录词表,避免两处定义漂移;判罚时以 check_prose.py 输出为准。原话命中禁令时改成转述或省略,不能靠引号保留。 From ad97696221506745f7007d98149f1ce98a401459 Mon Sep 17 00:00:00 2001 From: TryWorld Date: Tue, 22 Sep 2026 09:01:20 +0800 Subject: [PATCH 5/5] fix: verify_output.py resolves ffprobe/ffmpeg like tts_yunxi.py (PATH, env, WinGet) --- CHANGELOG.md | 1 + .../tryworld-paper/scripts/verify_output.py | 37 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3c2e76..68e6134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ _本分支(`refactor/runner-fixes-docs-consolidation`)的改动记录见下 ### Fixed - `README.md` / `README.zh-CN.md`:测试数量不再硬编码,改为引导用 `pytest --collect-only` 读取实际数量 +- `verify_output.py`:ffprobe/ffmpeg 发现逻辑与 `tts_yunxi.py` 的 `find_bin` 对齐(PATH 优先 → `HYPERFRAMES_FFMPEG_DIR` / `FFMPEG_BIN` → WinGet 目录),PATH 未配置时不再直接判定“工具不可用”而退出 ## [1.0.0] - 2026-09-05 diff --git a/skills/tryworld-paper/scripts/verify_output.py b/skills/tryworld-paper/scripts/verify_output.py index a0105a2..42477a1 100644 --- a/skills/tryworld-paper/scripts/verify_output.py +++ b/skills/tryworld-paper/scripts/verify_output.py @@ -7,6 +7,8 @@ """ import argparse import json +import os +import shutil import subprocess import sys from pathlib import Path @@ -21,14 +23,41 @@ def is_within_directory(path: Path, directory: Path) -> bool: return False +def find_bin(name: str) -> str | None: + """Locate ffmpeg/ffprobe: PATH first, then env override, then WinGet locations. + + Kept in sync with tts_yunxi.find_bin so both scripts resolve the same binaries. + """ + which = shutil.which(name) + if which: + return which + env_dir = os.environ.get("HYPERFRAMES_FFMPEG_DIR") or os.environ.get("FFMPEG_BIN") + if env_dir: + candidate = Path(env_dir) / (name + ".exe") + if candidate.exists(): + return str(candidate) + localappdata = os.environ.get("LOCALAPPDATA", "") + winget = Path(localappdata) / "Microsoft" / "WinGet" / "Packages" + if winget.exists(): + for sub in sorted(winget.glob("Gyan.FFmpeg*/ffmpeg-*/bin")): + candidate = sub / (name + ".exe") + if candidate.exists(): + return str(candidate) + return None + + def ffprobe(path: Path, args: list[str]) -> str: + binary = find_bin("ffprobe") + if not binary: + print("error: ffprobe 不可用(请安装 FFmpeg 并加入 PATH,或设置 HYPERFRAMES_FFMPEG_DIR / FFMPEG_BIN),无法执行核验。") + sys.exit(2) try: r = subprocess.run( - ["ffprobe", "-v", "error", *args, "-of", "default=noprint_wrappers=1:nokey=1", str(path)], + [binary, "-v", "error", *args, "-of", "default=noprint_wrappers=1:nokey=1", str(path)], capture_output=True, text=True, ) except FileNotFoundError: - print("error: ffprobe 不可用(请安装 FFmpeg 并加入 PATH),无法执行核验。") + print("error: ffprobe 不可用(请安装 FFmpeg 并加入 PATH,或设置 HYPERFRAMES_FFMPEG_DIR / FFMPEG_BIN),无法执行核验。") sys.exit(2) return r.stdout.strip() if r.returncode == 0 else "" @@ -45,8 +74,8 @@ def _cover_brightness(path: Path) -> float | None: pass except Exception: pass - import shutil, tempfile - ffmpeg = shutil.which("ffmpeg") + import tempfile + ffmpeg = find_bin("ffmpeg") if not ffmpeg: return None try: