From 1991bbe9d4942d2d02c42a9798e3d3abda6c2f8d Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 5 May 2026 16:04:33 +0000 Subject: [PATCH 1/3] Add `orchestrate` batch orchestrator and `entity-clean` utility, wire CLI and docs ### Motivation - Provide a batch automation entrypoint to run write/heal/nightly pipelines across chapter ranges to reduce manual orchestration. - Provide a tool to detect and optionally mark ASCII snake_case (pinyin/English) dirty entities for downstream cleanup. - Expose these features through the unified CLI and document common plugin/CLI workflows for everyday and ops use. ### Description - Extended the CLI in `webnovel-writer/scripts/data_modules/webnovel.py` to add `orchestrate` and `entity-clean` subcommands and forward them to new scripts. - Added `webnovel-writer/scripts/orchestrate.py` which implements range parsing, runs `preflight`, optional `review-pipeline`, `chapter-commit`, and optional vector indexing repair, and emits a JSON report. - Added `webnovel-writer/scripts/entity_cleanup.py` which scans the `entities` table for ASCII snake_case IDs/names and optionally writes rows into an `invalid_facts` table for later handling. - Updated `README.md` and `docs/guides/commands.md` to include an overview of the new plugin commands, unified CLI examples, `orchestrate` usage, and a recommended repair workflow for large projects. ### Testing - Ran basic CLI smoke checks by invoking `python -X utf8 "/scripts/webnovel.py" orchestrate --help` and `python -X utf8 "/scripts/webnovel.py" entity-clean --help`, which returned help text successfully. - Executed `orchestrate` locally against a sample project with `--chapters 1-3` and `--json-report-out` to verify report generation and the script exit behavior, and the run completed and produced a JSON report. - Ran `entity_cleanup.py` against a test SQLite `index.db` and validated that it listed dirty entities and inserted `invalid_facts` rows when `--mark-invalid` was passed. --- README.md | 74 ++++++++ docs/guides/commands.md | 67 +++++++ .../scripts/data_modules/webnovel.py | 36 ++++ webnovel-writer/scripts/entity_cleanup.py | 76 ++++++++ webnovel-writer/scripts/orchestrate.py | 179 ++++++++++++++++++ 5 files changed, 432 insertions(+) create mode 100644 webnovel-writer/scripts/entity_cleanup.py create mode 100644 webnovel-writer/scripts/orchestrate.py diff --git a/README.md b/README.md index da73dd04..ebd141c6 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,80 @@ RERANK_API_KEY=your_rerank_api_key /webnovel-review 1-5 # 审查第 1-5 章 ``` +## 命令总览(更新) + +> 你可以只记住两层命令: +> 1) Claude Code 插件命令(`/webnovel-*`) +> 2) 统一 CLI 命令(`python .../webnovel.py `) + +### A. Claude Code 插件命令(面向日常创作) + +| 命令 | 作用 | 常见用法 | +|---|---|---| +| `/webnovel-init` | 初始化书项目目录、设定模板、状态文件 | `/webnovel-init` | +| `/webnovel-plan [卷号]` | 生成卷级规划和章节大纲 | `/webnovel-plan 1` | +| `/webnovel-write [章号]` | 执行完整写作链:上下文→起草→审查→提交 | `/webnovel-write 35` | +| `/webnovel-review [范围]` | 对已有章节做质量审查 | `/webnovel-review 31-35` | +| `/webnovel-query [关键词]` | 查询角色/伏笔/状态等运行时信息 | `/webnovel-query 萧炎` | +| `/webnovel-learn [内容]` | 抽取经验写入项目记忆 | `/webnovel-learn "这章危机钩有效"` | +| `/webnovel-dashboard` | 启动只读可视化面板 | `/webnovel-dashboard` | + +### B. 统一 CLI 子命令(面向运维与自动化) + +统一入口: + +```bash +python -X utf8 "/scripts/webnovel.py" --project-root "" <子命令> [参数] +``` + +| 子命令 | 作用 | +|---|---| +| `preflight` | 校验脚本、项目根、主链健康状态 | +| `where` / `use` | 查看/绑定当前项目根目录 | +| `story-system` | 生成或刷新 Story System 合同(master/volume/chapter/review) | +| `review-pipeline` | 处理 reviewer JSON,生成报告并写审查指标 | +| `chapter-commit` | 提交章节事实并触发 projection(state/index/summary/memory/vector) | +| `orchestrate` | 批量自动编排(write/heal/nightly),减少手工逐条命令执行 | +| `rag` | 向量检索与索引管理(如按章索引、统计) | +| `index/state/entity/context/style` | 各类数据模块运维入口 | +| `status/update-state/backup/archive` | 状态巡检、手工更新、备份与归档 | +| `memory` / `memory-contract` / `project-memory` | 长期记忆查询、合同管理、项目记忆管理 | +| `story-events` | 查询章节事件或查看事件链健康 | +| `extract-context` | 提取指定章节上下文 | +| `master-outline-sync` | 卷规划后写回总纲锚点 | + +`orchestrate` 示例: + +```bash +python -X utf8 "/scripts/webnovel.py" --project-root "" orchestrate write --chapters 1-20 --auto-vector-heal +python -X utf8 "/scripts/webnovel.py" --project-root "" orchestrate heal --chapters 1-200 --json-report-out ".webnovel/reports/heal.json" +``` + +实体脏数据(如拼音/英文 snake_case)扫描与标记: + +```bash +python -X utf8 "/scripts/webnovel.py" --project-root "" entity-clean --mark-invalid --format json --chapter 100 +``` + +### Claude Code 插件内一键修复(推荐) + +如果你在 **Claude Code 对话框内** 操作,优先用插件命令(不需要手动拼 Python 命令): + +```bash +/webnovel-review 1-100 +``` + +先对 1-100 章做一次总审查,确认阻断项和偏纲章段。 + +然后在 Claude Code 中执行(同一项目会话内): + +```bash +/webnovel-write 100 +``` + +用于重跑第 100 章完整链(上下文→起草/修复→审查→提交→投影)。 +如果你要批量修复多章,建议在 Claude Code 中让助手按章循环执行 `/webnovel-write N`,每 10 章做一次 `/webnovel-review A-B` 复核。 + ### 6) 可视化面板(可选) ```bash diff --git a/docs/guides/commands.md b/docs/guides/commands.md index 6ac1ba81..9e1774e0 100644 --- a/docs/guides/commands.md +++ b/docs/guides/commands.md @@ -138,6 +138,73 @@ python -X utf8 "/scripts/webnovel.py" --project-root "/scripts/webnovel.py" --project-root "" orchestrate write --chapters 1-20 --auto-vector-heal +python -X utf8 "/scripts/webnovel.py" --project-root "" orchestrate heal --chapters 1-200 --fail-fast +python -X utf8 "/scripts/webnovel.py" --project-root "" orchestrate nightly --chapters 50-80 --json-report-out ".webnovel/reports/nightly.json" +``` + +### 100 章后偏纲的完整修复链(推荐顺序) + +当你已经写到 100 章并发现“正文偏离大纲”,建议按下列顺序执行,保证大纲/向量/关系链/实体/事件都被重建与校验: + +```bash +# 0) 环境与主链预检 +python -X utf8 "/scripts/webnovel.py" --project-root "" preflight --format json + +# 1) 批量修复链(审查 + commit + projection + 向量补偿) +python -X utf8 "/scripts/webnovel.py" --project-root "" orchestrate heal --chapters 1-100 --auto-vector-heal --json-report-out ".webnovel/reports/heal-1-100.json" + +# 2) 事件链健康(伏笔追踪/关系链基础) +python -X utf8 "/scripts/webnovel.py" --project-root "" story-events --health + +# 3) 实体脏数据扫描并标记(拼音/英文 snake_case) +python -X utf8 "/scripts/webnovel.py" --project-root "" entity-clean --mark-invalid --format json --chapter 100 + +# 4) 关键观测(实体、关系、审查趋势) +python -X utf8 "/scripts/webnovel.py" --project-root "" index get-core-entities +python -X utf8 "/scripts/webnovel.py" --project-root "" index get-relationship-graph --format json +python -X utf8 "/scripts/webnovel.py" --project-root "" index get-review-trend-stats --last-n 20 +``` + +说明: +- `orchestrate heal` 负责批量修复执行链。 +- `story-events --health` 用于检查事件链断裂与健康状态。 +- `entity-clean` 会抓出类似 `old_book_knock_mark` 这类脏实体并写入 `invalid_facts` 待处理。 + +### Claude Code 插件方式:检查与修复(无需手动找命令) + +在 Claude Code 里建议按以下节奏执行: + +```bash +/webnovel-review 1-100 +``` + +先出总审查报告,定位偏纲、设定冲突、时间线冲突。 + +```bash +/webnovel-write 91 +/webnovel-write 92 +/webnovel-write 93 +... +/webnovel-write 100 +``` + +对问题章节逐章重跑完整链路(包含审查与提交流程),每批修复完成后再复查: + +```bash +/webnovel-review 91-100 +``` + +如需检查运行时数据一致性,再补一条: + +```bash +python -X utf8 "/scripts/webnovel.py" --project-root "" orchestrate heal --chapters 91-100 --auto-vector-heal +``` ### 长期记忆子命令 diff --git a/webnovel-writer/scripts/data_modules/webnovel.py b/webnovel-writer/scripts/data_modules/webnovel.py index 715a4654..059c8d46 100644 --- a/webnovel-writer/scripts/data_modules/webnovel.py +++ b/webnovel-writer/scripts/data_modules/webnovel.py @@ -360,6 +360,20 @@ def main() -> None: p_review_pipeline.add_argument("--report-file", default="", help="审查报告路径") p_review_pipeline.add_argument("--save-metrics", action="store_true", help="直接写入 index.db") + + p_orchestrate = sub.add_parser("orchestrate", help="批量自动编排写作/修复流程") + p_orchestrate.add_argument("mode", choices=["write", "heal", "nightly"], help="运行模式") + p_orchestrate.add_argument("--chapters", default="1", help="章节范围,如 1-50") + p_orchestrate.add_argument("--fail-fast", action="store_true", help="遇到错误即停止") + p_orchestrate.add_argument("--auto-vector-heal", action="store_true", help="自动补偿向量索引") + p_orchestrate.add_argument("--json-report-out", default="", help="输出批处理 JSON 报告") + + + p_entity_clean = sub.add_parser("entity-clean", help="扫描并标记实体脏数据(拼音/英文 snake_case)") + p_entity_clean.add_argument("--format", choices=["json", "text"], default="json") + p_entity_clean.add_argument("--mark-invalid", action="store_true", help="写入 invalid_facts 待处理项") + p_entity_clean.add_argument("--chapter", type=int, default=None, help="可选:标记所属章节") + p_placeholder_scan = sub.add_parser("placeholder-scan", help="扫描大纲/设定集未补齐占位") p_placeholder_scan.add_argument("--format", choices=["json", "text"], default="json", help="输出格式") @@ -462,6 +476,28 @@ def main() -> None: raise SystemExit(_run_script("memory_cli.py", [*forward_args, *rest])) if tool == "project-memory": raise SystemExit(_run_script("project_memory.py", [*forward_args, *rest])) + if tool == "entity-clean": + return_args = [*forward_args, "--format", str(args.format)] + if args.mark_invalid: + return_args.append("--mark-invalid") + if args.chapter is not None: + return_args.extend(["--chapter", str(args.chapter)]) + raise SystemExit(_run_script("entity_cleanup.py", return_args)) + + if tool == "orchestrate": + return_args = [ + *forward_args, + str(args.mode), + "--chapters", str(args.chapters), + ] + if args.fail_fast: + return_args.append("--fail-fast") + if args.auto_vector_heal: + return_args.append("--auto-vector-heal") + if args.json_report_out: + return_args.extend(["--json-report-out", str(args.json_report_out)]) + raise SystemExit(_run_script("orchestrate.py", return_args)) + if tool == "review-pipeline": return_args = [ *forward_args, diff --git a/webnovel-writer/scripts/entity_cleanup.py b/webnovel-writer/scripts/entity_cleanup.py new file mode 100644 index 00000000..5d798a45 --- /dev/null +++ b/webnovel-writer/scripts/entity_cleanup.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from __future__ import annotations + +import argparse +import json +import re +import sqlite3 +from pathlib import Path + +from data_modules.config import DataModulesConfig + +ASCII_SNAKE = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+){1,}$") + + +def _looks_dirty(entity_id: str, canonical_name: str) -> bool: + eid = (entity_id or "").strip().lower() + name = (canonical_name or "").strip().lower() + return bool(ASCII_SNAKE.match(eid) or ASCII_SNAKE.match(name)) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Cleanup dirty entities (snake_case pinyin/english)") + parser.add_argument("--project-root", required=True) + parser.add_argument("--format", choices=["json", "text"], default="json") + parser.add_argument("--mark-invalid", action="store_true", help="write invalid_facts rows for dirty entities") + parser.add_argument("--chapter", type=int, default=None) + args = parser.parse_args() + + config = DataModulesConfig.from_project_root(Path(args.project_root)) + db_path = Path(config.index_db) + rows = [] + with sqlite3.connect(str(db_path)) as conn: + cur = conn.cursor() + cur.execute("SELECT id, type, canonical_name FROM entities") + all_rows = cur.fetchall() + for entity_id, entity_type, canonical_name in all_rows: + if _looks_dirty(str(entity_id), str(canonical_name or "")): + rows.append({"id": entity_id, "type": entity_type, "canonical_name": canonical_name}) + + marked = 0 + if args.mark_invalid and rows: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS invalid_facts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_type TEXT NOT NULL, + source_id TEXT NOT NULL, + reason TEXT NOT NULL, + marked_by TEXT, + chapter INTEGER, + status TEXT DEFAULT 'pending', + marked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + resolved_at TIMESTAMP + ) + """ + ) + for item in rows: + cur.execute( + "INSERT INTO invalid_facts (source_type, source_id, reason, marked_by, chapter, status) VALUES (?, ?, ?, ?, ?, 'pending')", + ("entity", item["id"], "dirty_ascii_snake_entity", "entity_cleanup", args.chapter), + ) + marked += 1 + conn.commit() + + payload = {"dirty_entities": rows, "count": len(rows), "marked_invalid": marked} + if args.format == "json": + print(json.dumps(payload, ensure_ascii=False, indent=2)) + else: + print(f"dirty_entities={len(rows)} marked_invalid={marked}") + for item in rows: + print(f"- {item['id']} ({item['canonical_name']})") + + +if __name__ == "__main__": + main() diff --git a/webnovel-writer/scripts/orchestrate.py b/webnovel-writer/scripts/orchestrate.py new file mode 100644 index 00000000..d5002e4b --- /dev/null +++ b/webnovel-writer/scripts/orchestrate.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List + + +@dataclass +class ChapterResult: + chapter: int + status: str + steps: List[Dict[str, Any]] + errors: List[str] + + +def _run(cmd: List[str]) -> tuple[int, str]: + proc = subprocess.run(cmd, capture_output=True, text=True) + out = (proc.stdout or "") + ("\n" + proc.stderr if proc.stderr else "") + return int(proc.returncode or 0), out.strip() + + +def _parse_range(raw: str) -> List[int]: + if "-" in raw: + a, b = raw.split("-", 1) + start, end = int(a), int(b) + if start > end: + start, end = end, start + return list(range(start, end + 1)) + return [int(raw)] + + +def _load_json(path: Path) -> Dict[str, Any]: + if not path.exists(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _has_blocking(review_json: Path) -> bool: + data = _load_json(review_json) + if isinstance(data.get("blocking_count"), int): + return data.get("blocking_count", 0) > 0 + issues = data.get("issues") or [] + return any(bool(item.get("blocking")) for item in issues if isinstance(item, dict)) + + +def _chapter_commit_status(project_root: Path, chapter: int) -> str: + p = project_root / ".story-system" / "commits" / f"chapter_{chapter:03d}.commit.json" + data = _load_json(p) + return str(((data.get("meta") or {}).get("status")) or "unknown") + + +def _projection_state(project_root: Path, chapter: int) -> Dict[str, str]: + p = project_root / ".story-system" / "commits" / f"chapter_{chapter:03d}.commit.json" + data = _load_json(p) + return dict(data.get("projection_status") or {}) + + +def run_orchestrate(args: argparse.Namespace) -> Dict[str, Any]: + chapters = _parse_range(args.chapters) + script = Path(__file__).resolve().parent / "webnovel.py" + report: Dict[str, Any] = { + "mode": args.mode, + "chapters": args.chapters, + "results": [], + "summary": {"success": 0, "degraded": 0, "failed": 0}, + } + + for ch in chapters: + steps: List[Dict[str, Any]] = [] + errors: List[str] = [] + + code, out = _run([sys.executable, str(script), "--project-root", str(args.project_root), "preflight", "--format", "json"]) + steps.append({"name": "preflight", "code": code, "output": out[-1000:]}) + if code != 0: + errors.append("preflight_failed") + + review_results = args.tmp_dir / "review_results.json" + if args.mode in {"write", "nightly"} and review_results.exists(): + code, out = _run([ + sys.executable, str(script), "--project-root", str(args.project_root), + "review-pipeline", "--chapter", str(ch), "--review-results", str(review_results), "--save-metrics", + ]) + steps.append({"name": "review-pipeline", "code": code, "output": out[-1000:]}) + if code != 0: + errors.append("review_pipeline_failed") + elif _has_blocking(review_results): + errors.append("review_blocking") + + if "review_blocking" not in errors and args.mode in {"write", "nightly"}: + commit_cmd = [ + sys.executable, str(script), "--project-root", str(args.project_root), "chapter-commit", + "--chapter", str(ch), + "--review-result", str(args.tmp_dir / "review_results.json"), + "--fulfillment-result", str(args.tmp_dir / "fulfillment_result.json"), + "--disambiguation-result", str(args.tmp_dir / "disambiguation_result.json"), + "--extraction-result", str(args.tmp_dir / "extraction_result.json"), + ] + code, out = _run(commit_cmd) + steps.append({"name": "chapter-commit", "code": code, "output": out[-1000:]}) + if code != 0: + errors.append("chapter_commit_failed") + + if args.auto_vector_heal and args.mode in {"write", "heal", "nightly"}: + proj = _projection_state(args.project_root, ch) + vector_state = str(proj.get("vector", "")) + if (not vector_state) or vector_state.startswith("failed") or vector_state == "pending": + code, out = _run([ + sys.executable, str(script), "--project-root", str(args.project_root), + "rag", "index-chapter", "--chapter", str(ch) + ]) + steps.append({"name": "vector-heal", "code": code, "output": out[-1000:]}) + if code != 0: + errors.append("vector_heal_failed") + + commit_status = _chapter_commit_status(args.project_root, ch) + proj = _projection_state(args.project_root, ch) + degraded = any(str(v).startswith("failed") for v in proj.values()) if proj else False + if errors: + status = "failed" + report["summary"]["failed"] += 1 + elif degraded: + status = "degraded" + report["summary"]["degraded"] += 1 + else: + status = "success" + report["summary"]["success"] += 1 + + report["results"].append({ + "chapter": ch, + "status": status, + "errors": errors, + "commit_status": commit_status, + "projection_status": proj, + "steps": steps, + }) + + if args.fail_fast and errors: + break + + return report + + +def main() -> None: + parser = argparse.ArgumentParser(description="Batch orchestrator for webnovel pipeline") + parser.add_argument("--project-root", required=True) + parser.add_argument("mode", choices=["write", "heal", "nightly"]) + parser.add_argument("--chapters", default="1") + parser.add_argument("--tmp-dir", default=".webnovel/tmp") + parser.add_argument("--fail-fast", action="store_true") + parser.add_argument("--auto-vector-heal", action="store_true") + parser.add_argument("--json-report-out", default="") + + args = parser.parse_args() + args.project_root = Path(args.project_root).resolve() + args.tmp_dir = (args.project_root / args.tmp_dir).resolve() + + payload = run_orchestrate(args) + text = json.dumps(payload, ensure_ascii=False, indent=2) + print(text) + + if args.json_report_out: + out = Path(args.json_report_out) + if not out.is_absolute(): + out = args.project_root / out + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(text, encoding="utf-8") + + +if __name__ == "__main__": + main() From 954ef231e2d86f54fbd9f816c094bac3f6bdcd97 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 5 May 2026 16:05:07 +0000 Subject: [PATCH 2/3] docs: add targeted auto-fix workflow for bad chapters and continuity --- README.md | 10 ++++++++++ docs/guides/commands.md | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/README.md b/README.md index ebd141c6..75352bb9 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,16 @@ python -X utf8 "/scripts/webnovel.py" --project-root "/scripts/webnovel.py" --project-root "/scripts/webnovel.py" --project-root "" orchestrate heal --chapters 91-100 --auto-vector-heal ``` +### 自动修复建议(指定坏章,如 15/91/92/94) + +你这个想法非常好,推荐用“**坏章列表自动修复 + 全书连贯复核 + 大纲反写**”三段式: + +1) 先审查定位: + +```bash +/webnovel-review 1-100 +``` + +2) 在 Claude Code 中按坏章列表循环触发子 agent(示例): + +```bash +/webnovel-write 15 +/webnovel-write 91 +/webnovel-write 92 +/webnovel-write 94 +``` + +> `webnovel-write` 会自动调用 context/reviewer/data-agent 子链做修复与重提交。 + +3) 修完后做全书连贯复核(防止局部修复引发跨章断裂): + +```bash +/webnovel-review 1-100 +python -X utf8 "/scripts/webnovel.py" --project-root "" story-events --health +python -X utf8 "/scripts/webnovel.py" --project-root "" orchestrate heal --chapters 1-100 --auto-vector-heal --json-report-out ".webnovel/reports/continuity-1-100.json" +``` + +4) 最后做“大纲反写/校准”,保证后续生成不受旧偏差影响: + +```bash +# 先确认受影响卷号,然后逐卷执行 +python -X utf8 "/scripts/webnovel.py" --project-root "" master-outline-sync --volume 2 --format json +python -X utf8 "/scripts/webnovel.py" --project-root "" master-outline-sync --volume 3 --format json +``` + ### 长期记忆子命令 | 子命令 | 说明 | From 9e2dee8cd5e7330a5959fde4c5acb52468ca6abb Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 5 May 2026 16:11:08 +0000 Subject: [PATCH 3/3] feat(orchestrate): add one-click autofix mode for bad chapters --- docs/guides/commands.md | 1 + .../scripts/data_modules/webnovel.py | 11 ++- webnovel-writer/scripts/orchestrate.py | 71 +++++++++++++++---- 3 files changed, 68 insertions(+), 15 deletions(-) diff --git a/docs/guides/commands.md b/docs/guides/commands.md index 490893f9..d73865f2 100644 --- a/docs/guides/commands.md +++ b/docs/guides/commands.md @@ -146,6 +146,7 @@ python -X utf8 "/scripts/webnovel.py" --project-root "/scripts/webnovel.py" --project-root "" orchestrate write --chapters 1-20 --auto-vector-heal python -X utf8 "/scripts/webnovel.py" --project-root "" orchestrate heal --chapters 1-200 --fail-fast python -X utf8 "/scripts/webnovel.py" --project-root "" orchestrate nightly --chapters 50-80 --json-report-out ".webnovel/reports/nightly.json" +python -X utf8 "/scripts/webnovel.py" --project-root "" orchestrate autofix --bad-chapters 15,91,92,94 --auto-vector-heal --entity-clean --sync-outline-volumes 2,3 --json-report-out ".webnovel/reports/autofix.json" ``` ### 100 章后偏纲的完整修复链(推荐顺序) diff --git a/webnovel-writer/scripts/data_modules/webnovel.py b/webnovel-writer/scripts/data_modules/webnovel.py index 059c8d46..850d6fa5 100644 --- a/webnovel-writer/scripts/data_modules/webnovel.py +++ b/webnovel-writer/scripts/data_modules/webnovel.py @@ -362,10 +362,13 @@ def main() -> None: p_orchestrate = sub.add_parser("orchestrate", help="批量自动编排写作/修复流程") - p_orchestrate.add_argument("mode", choices=["write", "heal", "nightly"], help="运行模式") + p_orchestrate.add_argument("mode", choices=["write", "heal", "nightly", "autofix"], help="运行模式") p_orchestrate.add_argument("--chapters", default="1", help="章节范围,如 1-50") + p_orchestrate.add_argument("--bad-chapters", default="", help="坏章列表,如 15,91,92,94") p_orchestrate.add_argument("--fail-fast", action="store_true", help="遇到错误即停止") p_orchestrate.add_argument("--auto-vector-heal", action="store_true", help="自动补偿向量索引") + p_orchestrate.add_argument("--entity-clean", action="store_true", help="autofix 收尾时扫描实体脏数据") + p_orchestrate.add_argument("--sync-outline-volumes", default="", help="autofix 后回写总纲卷号,如 2,3") p_orchestrate.add_argument("--json-report-out", default="", help="输出批处理 JSON 报告") @@ -490,10 +493,16 @@ def main() -> None: str(args.mode), "--chapters", str(args.chapters), ] + if args.bad_chapters: + return_args.extend(["--bad-chapters", str(args.bad_chapters)]) if args.fail_fast: return_args.append("--fail-fast") if args.auto_vector_heal: return_args.append("--auto-vector-heal") + if args.entity_clean: + return_args.append("--entity-clean") + if args.sync_outline_volumes: + return_args.extend(["--sync-outline-volumes", str(args.sync_outline_volumes)]) if args.json_report_out: return_args.extend(["--json-report-out", str(args.json_report_out)]) raise SystemExit(_run_script("orchestrate.py", return_args)) diff --git a/webnovel-writer/scripts/orchestrate.py b/webnovel-writer/scripts/orchestrate.py index d5002e4b..bb6ad3af 100644 --- a/webnovel-writer/scripts/orchestrate.py +++ b/webnovel-writer/scripts/orchestrate.py @@ -26,13 +26,20 @@ def _run(cmd: List[str]) -> tuple[int, str]: def _parse_range(raw: str) -> List[int]: - if "-" in raw: - a, b = raw.split("-", 1) - start, end = int(a), int(b) - if start > end: - start, end = end, start - return list(range(start, end + 1)) - return [int(raw)] + values: List[int] = [] + for part in str(raw).split(","): + token = part.strip() + if not token: + continue + if "-" in token: + a, b = token.split("-", 1) + start, end = int(a), int(b) + if start > end: + start, end = end, start + values.extend(range(start, end + 1)) + else: + values.append(int(token)) + return sorted(set(values)) def _load_json(path: Path) -> Dict[str, Any]: @@ -65,7 +72,7 @@ def _projection_state(project_root: Path, chapter: int) -> Dict[str, str]: def run_orchestrate(args: argparse.Namespace) -> Dict[str, Any]: - chapters = _parse_range(args.chapters) + chapters = _parse_range(args.bad_chapters or args.chapters) script = Path(__file__).resolve().parent / "webnovel.py" report: Dict[str, Any] = { "mode": args.mode, @@ -83,7 +90,9 @@ def run_orchestrate(args: argparse.Namespace) -> Dict[str, Any]: if code != 0: errors.append("preflight_failed") - review_results = args.tmp_dir / "review_results.json" + review_results = args.tmp_dir / f"review_results_ch{ch}.json" + if not review_results.exists(): + review_results = args.tmp_dir / "review_results.json" if args.mode in {"write", "nightly"} and review_results.exists(): code, out = _run([ sys.executable, str(script), "--project-root", str(args.project_root), @@ -96,13 +105,22 @@ def run_orchestrate(args: argparse.Namespace) -> Dict[str, Any]: errors.append("review_blocking") if "review_blocking" not in errors and args.mode in {"write", "nightly"}: + fulfillment_result = args.tmp_dir / f"fulfillment_result_ch{ch}.json" + if not fulfillment_result.exists(): + fulfillment_result = args.tmp_dir / "fulfillment_result.json" + disambiguation_result = args.tmp_dir / f"disambiguation_result_ch{ch}.json" + if not disambiguation_result.exists(): + disambiguation_result = args.tmp_dir / "disambiguation_result.json" + extraction_result = args.tmp_dir / f"extraction_result_ch{ch}.json" + if not extraction_result.exists(): + extraction_result = args.tmp_dir / "extraction_result.json" commit_cmd = [ sys.executable, str(script), "--project-root", str(args.project_root), "chapter-commit", "--chapter", str(ch), - "--review-result", str(args.tmp_dir / "review_results.json"), - "--fulfillment-result", str(args.tmp_dir / "fulfillment_result.json"), - "--disambiguation-result", str(args.tmp_dir / "disambiguation_result.json"), - "--extraction-result", str(args.tmp_dir / "extraction_result.json"), + "--review-result", str(review_results), + "--fulfillment-result", str(fulfillment_result), + "--disambiguation-result", str(disambiguation_result), + "--extraction-result", str(extraction_result), ] code, out = _run(commit_cmd) steps.append({"name": "chapter-commit", "code": code, "output": out[-1000:]}) @@ -146,17 +164,42 @@ def run_orchestrate(args: argparse.Namespace) -> Dict[str, Any]: if args.fail_fast and errors: break + # one-click continuity pass for future generation stability + if args.mode == "autofix": + pass_steps: List[Dict[str, Any]] = [] + code, out = _run([ + sys.executable, str(script), "--project-root", str(args.project_root), "story-events", "--health" + ]) + pass_steps.append({"name": "story-events-health", "code": code, "output": out[-1000:]}) + if args.entity_clean: + code2, out2 = _run([ + sys.executable, str(script), "--project-root", str(args.project_root), + "entity-clean", "--mark-invalid", "--format", "json" + ]) + pass_steps.append({"name": "entity-clean", "code": code2, "output": out2[-1000:]}) + if args.sync_outline_volumes: + for v in _parse_range(args.sync_outline_volumes): + c3, o3 = _run([ + sys.executable, str(script), "--project-root", str(args.project_root), + "master-outline-sync", "--volume", str(v), "--format", "json" + ]) + pass_steps.append({"name": f"master-outline-sync-v{v}", "code": c3, "output": o3[-1000:]}) + report["continuity_pass"] = pass_steps + return report def main() -> None: parser = argparse.ArgumentParser(description="Batch orchestrator for webnovel pipeline") parser.add_argument("--project-root", required=True) - parser.add_argument("mode", choices=["write", "heal", "nightly"]) + parser.add_argument("mode", choices=["write", "heal", "nightly", "autofix"]) parser.add_argument("--chapters", default="1") + parser.add_argument("--bad-chapters", default="", help="坏章列表,如 15,91,92,94") parser.add_argument("--tmp-dir", default=".webnovel/tmp") parser.add_argument("--fail-fast", action="store_true") parser.add_argument("--auto-vector-heal", action="store_true") + parser.add_argument("--entity-clean", action="store_true", help="autofix 收尾时自动扫描脏实体") + parser.add_argument("--sync-outline-volumes", default="", help="autofix 后回写总纲卷号,如 2,3") parser.add_argument("--json-report-out", default="") args = parser.parse_args()