2026-06-01-review-critical-fixes.md 42 KB

Review Critical Fixes Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 修复四份 review 报告交叉验证后的关键问题:CLI JSON 文件输入边界、rejected commit 投影、async 测试配置、KnowledgeQuery schema 漂移、StateManager 状态写入分叉、vector summary 覆盖、review 流程文档、安全边界。

Architecture: 先恢复写作主链和测试套件可信度,再扩展 RAG 投影覆盖,最后收紧本地 dashboard/backup 的安全默认值。每个任务都包含回归测试,避免只改实现不固定行为。

Tech Stack: Python 3.10+, pytest/pytest-asyncio, SQLite, FastAPI, Markdown skills


File Structure

Core Commit/Projection

  • webnovel-writer/scripts/chapter_commit.py: CLI entry point; rejected commit must also run state projection.
  • webnovel-writer/scripts/data_modules/chapter_commit_service.py: projection orchestration; non-accepted commits should still allow the state writer rejected branch.
  • webnovel-writer/scripts/data_modules/event_projection_router.py: writer selection; rejected commits require state.
  • webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py: service-level projection behavior tests.
  • webnovel-writer/scripts/data_modules/tests/test_projection_writers.py: end-to-end projection regression tests.

Test Infrastructure

  • pytest.ini: remove async plugin bans; keep coverage settings.
  • webnovel-writer/scripts/data_modules/tests/test_rag_adapter.py: existing async tests should run normally after config fix.

CLI JSON Input Boundary

  • webnovel-writer/scripts/data_modules/cli_args.py: add optional base-dir containment for @file JSON arguments while keeping stdin/direct JSON behavior unchanged.
  • webnovel-writer/scripts/data_modules/index_manager.py: pass project-root containment to load_json_arg() for write/input JSON commands.
  • webnovel-writer/scripts/data_modules/state_manager.py: pass project-root containment to load_json_arg() for process-chapter.
  • webnovel-writer/scripts/data_modules/sql_state_manager.py: pass project-root containment to load_json_arg() for chapter entity processing.
  • webnovel-writer/scripts/data_modules/memory/store.py: pass project-root containment to load_json_arg() for memory upserts/imports.
  • webnovel-writer/scripts/data_modules/rag_adapter.py: pass project-root containment to load_json_arg() for scene indexing.
  • webnovel-writer/scripts/data_modules/style_sampler.py: pass project-root containment to load_json_arg() for scene extraction.
  • webnovel-writer/scripts/data_modules/tests/test_coverage_boost.py: extend existing cli_args tests.

Knowledge Query

  • webnovel-writer/scripts/data_modules/knowledge_query.py: query production relationship_events.type while returning stable JSON for callers.
  • webnovel-writer/scripts/data_modules/tests/test_knowledge_query.py: use production schema, not mock-only relationship_type.

State Manager

  • webnovel-writer/scripts/data_modules/state_manager.py: route set_chapter_status() through locked merge semantics.
  • webnovel-writer/scripts/data_modules/tests/test_chapter_status.py: preserve status monotonicity.
  • webnovel-writer/scripts/data_modules/tests/test_state_manager_extra.py: add merge regression for existing disk state.

Vector Projection/RAG

  • webnovel-writer/scripts/data_modules/vector_projection_writer.py: add summary and scene chunks to commit projection, and avoid asyncio.run() failure when projection is invoked from an active event loop.
  • webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py: cover summary/scene chunk generation, stable IDs, and active-event-loop storage bridge.

Review Skill Flow

  • webnovel-writer/skills/webnovel-review/SKILL.md: switch to review-pipeline --save-metrics, remove obsolete second save command.
  • webnovel-writer/skills/webnovel-write/SKILL.md: verify already uses --save-metrics; no change unless wording drifts.

Local Security Defaults

  • webnovel-writer/dashboard/app.py: restrict CORS to localhost origins, add file size limit for read API.
  • webnovel-writer/scripts/backup_manager.py: ensure generated .gitignore excludes .env.
  • webnovel-writer/scripts/data_modules/style_sampler.py: tolerate corrupt JSON in tags.
  • New or existing tests under webnovel-writer/scripts/tests/ or webnovel-writer/scripts/data_modules/tests/ for these fixes.

Task 0: Bound @file JSON Arguments to Project Roots

Problem: load_json_arg("@path") currently reads any local path. This is a local CLI feature rather than a remote vulnerability, but in Agent-driven pipelines the project root is the expected trust boundary.

Design: Keep backward compatibility for direct callers by adding an optional base_dir argument. If base_dir is passed, @file must resolve inside it. @- stdin and direct JSON strings are unchanged.

Files:

  • Modify: webnovel-writer/scripts/data_modules/cli_args.py
  • Modify: webnovel-writer/scripts/data_modules/index_manager.py
  • Modify: webnovel-writer/scripts/data_modules/state_manager.py
  • Modify: webnovel-writer/scripts/data_modules/sql_state_manager.py
  • Modify: webnovel-writer/scripts/data_modules/memory/store.py
  • Modify: webnovel-writer/scripts/data_modules/rag_adapter.py
  • Modify: webnovel-writer/scripts/data_modules/style_sampler.py
  • Test: webnovel-writer/scripts/data_modules/tests/test_coverage_boost.py

  • [ ] Step 1: Add failing containment tests

Append these tests to the cli_args section of webnovel-writer/scripts/data_modules/tests/test_coverage_boost.py:

def test_load_json_arg_rejects_file_outside_base_dir(tmp_path):
    project = tmp_path / "project"
    project.mkdir()
    outside = tmp_path / "secret.json"
    outside.write_text('{"secret": true}', encoding="utf-8")

    with pytest.raises(ValueError, match="outside allowed directory"):
        load_json_arg(f"@{outside}", base_dir=project)


def test_load_json_arg_allows_file_inside_base_dir(tmp_path):
    project = tmp_path / "project"
    project.mkdir()
    payload = project / "payload.json"
    payload.write_text('{"ok": true}', encoding="utf-8")

    assert load_json_arg(f"@{payload}", base_dir=project) == {"ok": True}


def test_load_json_arg_stdin_ignores_base_dir(monkeypatch, tmp_path):
    monkeypatch.setattr(sys, "stdin", StringIO('{"stdin": true}'))

    assert load_json_arg("@-", base_dir=tmp_path) == {"stdin": True}
  • Step 2: Run failing cli_args tests

Run:

python -m pytest webnovel-writer/scripts/data_modules/tests/test_coverage_boost.py::test_load_json_arg_rejects_file_outside_base_dir webnovel-writer/scripts/data_modules/tests/test_coverage_boost.py::test_load_json_arg_allows_file_inside_base_dir webnovel-writer/scripts/data_modules/tests/test_coverage_boost.py::test_load_json_arg_stdin_ignores_base_dir -q --no-cov

Expected before implementation: fail with TypeError: load_json_arg() got an unexpected keyword argument 'base_dir'.

  • Step 3: Implement optional containment helper

Update webnovel-writer/scripts/data_modules/cli_args.py:

def _resolve_json_arg_file(target: str, *, base_dir: str | Path | None = None) -> Path:
    path = Path(target).expanduser()
    if not path.is_absolute() and base_dir is not None:
        path = Path(base_dir) / path
    resolved = path.resolve()
    if base_dir is not None:
        base = Path(base_dir).expanduser().resolve()
        try:
            resolved.relative_to(base)
        except ValueError as exc:
            raise ValueError(f"json arg file outside allowed directory: {resolved}") from exc
    return resolved

Change the signature and file read branch:

def load_json_arg(raw: str, *, base_dir: str | Path | None = None) -> Any:
    """
    解析 CLI 传入的 JSON 参数,支持两种形式:
    - 直接 JSON 字符串:'{"a":1}'
    - @ 文件路径:'@data.json'(从文件读取 JSON,避免 shell 引号地狱)
      - 特例:'@-' 表示从 stdin 读取
      - 当传入 base_dir 时,@ 文件必须位于 base_dir 内
    """
    if raw is None:
        raise ValueError("missing json arg")
    text = str(raw).strip()
    if text.startswith("@"):
        target = text[1:].strip()
        if not target:
            raise ValueError("invalid json arg: '@' without path")
        if target == "-":
            content = sys.stdin.read()
        else:
            content = _resolve_json_arg_file(target, base_dir=base_dir).read_text(encoding="utf-8")
        return json.loads(content)
    return json.loads(text)
  • Step 4: Pass project root from CLI call sites

For each call site that has args.project_root, pass it as base_dir=args.project_root.

In webnovel-writer/scripts/data_modules/index_manager.py, update all load_json_arg(...) calls in command handlers, for example:

        entities = load_json_arg(args.entities, base_dir=args.project_root)
        scenes = load_json_arg(args.scenes, base_dir=args.project_root)

and:

        data = load_json_arg(args.data, base_dir=args.project_root)

In webnovel-writer/scripts/data_modules/state_manager.py:

        data = load_json_arg(args.data, base_dir=args.project_root)

In webnovel-writer/scripts/data_modules/sql_state_manager.py:

        data = load_json_arg(args.data, base_dir=args.project_root)

In webnovel-writer/scripts/data_modules/memory/store.py:

        payload = load_json_arg(args.data, base_dir=args.project_root)

In webnovel-writer/scripts/data_modules/rag_adapter.py:

        scenes = load_json_arg(args.scenes, base_dir=args.project_root)

In webnovel-writer/scripts/data_modules/style_sampler.py:

        scenes = load_json_arg(args.scenes, base_dir=args.project_root)

If a specific module stores the resolved project root in config.project_root rather than args.project_root, use base_dir=config.project_root.

  • Step 5: Run cli and unified CLI tests

Run:

python -m pytest webnovel-writer/scripts/data_modules/tests/test_coverage_boost.py webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py -q --no-cov

Expected: all tests pass.

  • [ ] Step 6: Commit

    git add webnovel-writer/scripts/data_modules/cli_args.py webnovel-writer/scripts/data_modules/index_manager.py webnovel-writer/scripts/data_modules/state_manager.py webnovel-writer/scripts/data_modules/sql_state_manager.py webnovel-writer/scripts/data_modules/memory/store.py webnovel-writer/scripts/data_modules/rag_adapter.py webnovel-writer/scripts/data_modules/style_sampler.py webnovel-writer/scripts/data_modules/tests/test_coverage_boost.py
    git commit -m "fix: bound json file arguments to project root"
    

Task 1: Restore Rejected Commit State Projection

Problem: StateProjectionWriter supports rejected -> chapter_rejected, but both CLI and service skip projections for non-accepted commits.

Files:

  • Modify: webnovel-writer/scripts/chapter_commit.py
  • Modify: webnovel-writer/scripts/data_modules/chapter_commit_service.py
  • Modify: webnovel-writer/scripts/data_modules/event_projection_router.py
  • Test: webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py
  • Test: webnovel-writer/scripts/data_modules/tests/test_projection_writers.py

  • [ ] Step 1: Add failing service test for rejected projection

Append this test to webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py:

import json


def test_apply_projections_updates_state_for_rejected_commit(tmp_path):
    (tmp_path / ".webnovel").mkdir(parents=True, exist_ok=True)
    (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")

    service = ChapterCommitService(tmp_path)
    payload = service.build_commit(
        chapter=7,
        review_result={"blocking_count": 1},
        fulfillment_result={
            "planned_nodes": ["进入坊市"],
            "covered_nodes": ["进入坊市"],
            "missed_nodes": [],
            "extra_nodes": [],
        },
        disambiguation_result={"pending": []},
        extraction_result={"state_deltas": [], "entity_deltas": [], "accepted_events": []},
    )

    projected = service.apply_projections(payload)

    state = json.loads((tmp_path / ".webnovel" / "state.json").read_text(encoding="utf-8"))
    assert projected["projection_status"]["state"] == "done"
    assert state["progress"]["chapter_status"]["7"] == "chapter_rejected"
  • Step 2: Run the failing test

Run:

python -m pytest webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py::test_apply_projections_updates_state_for_rejected_commit -q --no-cov

Expected before implementation: fail because projection_status["state"] remains pending or state.json has no chapter_rejected.

  • Step 3: Route rejected commits to state writer

Update webnovel-writer/scripts/data_modules/event_projection_router.py:

    def required_writers(self, commit_payload: Dict) -> List[str]:
        writers: Set[str] = set()
        status = str((commit_payload.get("meta") or {}).get("status") or "")
        if status == "rejected":
            writers.add("state")
            return sorted(writers)
        if status == "accepted":
            writers.add("state")
            writers.add("index")
        if commit_payload.get("entity_deltas"):
            writers.add("index")
        if str(commit_payload.get("summary_text") or "").strip():
            writers.add("summary")
        for event in commit_payload.get("accepted_events") or []:
            if not isinstance(event, dict):
                continue
            writers.update(self.route(event))
        return sorted(writers)
  • Step 4: Allow service projection for rejected state only

Update the start of ChapterCommitService.apply_projections() in webnovel-writer/scripts/data_modules/chapter_commit_service.py:

    def apply_projections(self, payload: Dict[str, Any]) -> Dict[str, Any]:
        status = str((payload.get("meta") or {}).get("status") or "")
        if status not in {"accepted", "rejected"}:
            return payload

        if status == "accepted":
            chapter = int((payload.get("meta") or {}).get("chapter") or 0)
            event_store = EventLogStore(self.project_root)
            payload["accepted_events"] = event_store.normalize_events(
                chapter, payload.get("accepted_events", [])
            )
            event_store.write_events(chapter, payload["accepted_events"])

            proposals = AmendProposalTrigger().check(chapter, payload.get("accepted_events", []))
            if proposals:
                manager = IndexManager(DataModulesConfig.from_project_root(self.project_root))
                with manager._get_conn() as conn:
                    ensure_override_ledger_columns(conn)
                    persist_amend_proposals(conn, chapter, proposals)
                    conn.commit()

Keep the writer import block and writer loop after this block. This preserves event log writes and override proposals for accepted commits only, while letting rejected commits reach StateProjectionWriter.

  • Step 5: Make CLI always call apply_projections()

Update webnovel-writer/scripts/chapter_commit.py:

    service.persist_commit(payload)
    payload = service.apply_projections(payload)
    print(json.dumps(payload, ensure_ascii=False))
  • Step 6: Run targeted projection tests

Run:

python -m pytest webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py webnovel-writer/scripts/data_modules/tests/test_projection_writers.py -q --no-cov

Expected: all tests pass.

  • [ ] Step 7: Commit

    git add webnovel-writer/scripts/chapter_commit.py webnovel-writer/scripts/data_modules/chapter_commit_service.py webnovel-writer/scripts/data_modules/event_projection_router.py webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py
    git commit -m "fix: project rejected chapter commits to state"
    

Task 2: Restore Async Pytest Execution

Problem: pytest.ini disables asyncio and anyio, so @pytest.mark.asyncio tests fail or are not exercised correctly.

Files:

  • Modify: pytest.ini
  • Verify: existing async tests under webnovel-writer/scripts/data_modules/tests/test_rag_adapter.py

  • [ ] Step 1: Confirm current async failure

Run:

python -m pytest webnovel-writer/scripts/data_modules/tests/test_rag_adapter.py::test_store_and_search -q --no-cov

Expected before implementation: fail or warn due to disabled async plugin.

  • Step 2: Update pytest config

Change pytest.ini to:

[pytest]
testpaths = webnovel-writer/scripts/data_modules/tests webnovel-writer/scripts/tests
pythonpath = webnovel-writer/scripts
asyncio_mode = auto
addopts = -p no:debugging -p pytest_cov -q --cov --cov-report=term-missing --cov-fail-under=90 -p no:cacheprovider

Do not disable pytest_asyncio or anyio.

  • Step 3: Run async-focused tests

Run:

python -m pytest webnovel-writer/scripts/data_modules/tests/test_rag_adapter.py -q --no-cov

Expected: async tests execute and pass.

  • Step 4: Run full suite once

Run:

python -m pytest -q

Expected: tests run with coverage enforcement. If failures remain, record exact failing tests before touching unrelated code.

  • [ ] Step 5: Commit

    git add pytest.ini
    git commit -m "test: enable pytest async plugins"
    

Task 3: Fix KnowledgeQuery Relationship Schema Drift

Problem: Production table uses relationship_events.type; KnowledgeQuery and its tests use relationship_type.

Files:

  • Modify: webnovel-writer/scripts/data_modules/knowledge_query.py
  • Modify: webnovel-writer/scripts/data_modules/tests/test_knowledge_query.py

  • [ ] Step 1: Rewrite test fixture to production schema

In test_knowledge_query.py, replace the relationship_events table definition with:

    conn.execute("""
        CREATE TABLE IF NOT EXISTS relationship_events (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            from_entity TEXT,
            to_entity TEXT,
            type TEXT NOT NULL,
            action TEXT DEFAULT '',
            polarity TEXT DEFAULT '',
            strength REAL DEFAULT 0.0,
            description TEXT,
            chapter INTEGER,
            scene_index INTEGER DEFAULT 0,
            evidence TEXT DEFAULT '',
            confidence REAL DEFAULT 1.0,
            created_at TEXT
        )
    """)

Replace inserts with:

    conn.execute(
        "INSERT INTO relationship_events (from_entity, to_entity, type, chapter) VALUES (?, ?, ?, ?)",
        ("hanli", "陈巧倩", "同门", 20),
    )
    conn.execute(
        "INSERT INTO relationship_events (from_entity, to_entity, type, chapter) VALUES (?, ?, ?, ?)",
        ("hanli", "陈巧倩", "合作", 45),
    )

Keep assertions against output key relationship_type for backward-compatible CLI JSON.

  • Step 2: Run the failing KnowledgeQuery tests

Run:

python -m pytest webnovel-writer/scripts/data_modules/tests/test_knowledge_query.py -q --no-cov

Expected before implementation: fail with no such column: relationship_type.

  • Step 3: Query production column with output compatibility

Update KnowledgeQuery.entity_relationships_at_chapter() in knowledge_query.py:

            rows = conn.execute(
                """
                SELECT from_entity, to_entity, type AS relationship_type, description, chapter
                FROM relationship_events
                WHERE (from_entity = ? OR to_entity = ?) AND chapter <= ?
                ORDER BY chapter ASC, id ASC
                """,
                (entity_id, entity_id, chapter),
            ).fetchall()

Leave returned JSON as:

                    "relationship_type": str(row["relationship_type"] or "").strip(),
  • Step 4: Run KnowledgeQuery and CLI-adjacent tests

Run:

python -m pytest webnovel-writer/scripts/data_modules/tests/test_knowledge_query.py webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py -q --no-cov

Expected: pass.

  • [ ] Step 5: Commit

    git add webnovel-writer/scripts/data_modules/knowledge_query.py webnovel-writer/scripts/data_modules/tests/test_knowledge_query.py
    git commit -m "fix: query relationship events using production schema"
    

Task 4: Route Chapter Status Writes Through Locked Merge Semantics

Problem: set_chapter_status() mutates in-memory state and calls _save_state(), bypassing save_state() locking and disk merge.

Files:

  • Modify: webnovel-writer/scripts/data_modules/state_manager.py
  • Modify: webnovel-writer/scripts/data_modules/tests/test_state_manager_extra.py
  • Verify: webnovel-writer/scripts/data_modules/tests/test_chapter_status.py

  • [ ] Step 1: Add failing locked-save regression tests

Append to test_state_manager_extra.py:

def test_set_chapter_status_uses_locked_save_state(temp_project, monkeypatch):
    manager = StateManager(temp_project, enable_sqlite_sync=False)
    called = {}

    def fake_save_state():
        called["save_state"] = True

    def fail_direct_save():
        raise AssertionError("set_chapter_status must use save_state()")

    monkeypatch.setattr(manager, "save_state", fake_save_state)
    monkeypatch.setattr(manager, "_save_state", fail_direct_save)

    manager.set_chapter_status(5, "chapter_drafted")

    assert called["save_state"] is True
    assert manager._pending_chapter_status == {"5": "chapter_drafted"}


def test_set_chapter_status_preserves_existing_disk_state(temp_project):
    temp_project.state_file.write_text(
        json.dumps(
            {
                "progress": {"current_chapter": 4, "chapter_status": {"4": "chapter_committed"}},
                "disambiguation_warnings": [{"chapter": 4, "mention": "宗主"}],
            },
            ensure_ascii=False,
        ),
        encoding="utf-8",
    )

    manager = StateManager(temp_project, enable_sqlite_sync=False)
    manager.set_chapter_status(5, "chapter_drafted")

    saved = json.loads(temp_project.state_file.read_text(encoding="utf-8"))
    assert saved["progress"]["chapter_status"]["4"] == "chapter_committed"
    assert saved["progress"]["chapter_status"]["5"] == "chapter_drafted"
    assert saved["disambiguation_warnings"] == [{"chapter": 4, "mention": "宗主"}]
  • Step 2: Run status tests before implementation

Run:

python -m pytest webnovel-writer/scripts/data_modules/tests/test_chapter_status.py webnovel-writer/scripts/data_modules/tests/test_state_manager_extra.py::test_set_chapter_status_uses_locked_save_state webnovel-writer/scripts/data_modules/tests/test_state_manager_extra.py::test_set_chapter_status_preserves_existing_disk_state -q --no-cov

Expected before implementation: test_set_chapter_status_uses_locked_save_state fails because current code calls _save_state() directly and has no _pending_chapter_status.

  • Step 3: Add pending chapter status field

In StateManager.__init__, after _pending_progress_chapter, add:

        self._pending_chapter_status: Dict[str, str] = {}

Update has_pending in save_state() to include:

                self._pending_chapter_status,
  • Step 4: Merge pending chapter statuses inside save_state()

Inside the locked with lock: block, immediately after progress is normalized for progress updates or before disambiguation merge, add:

                if self._pending_chapter_status:
                    progress = disk_state.get("progress", {})
                    if not isinstance(progress, dict):
                        progress = {}
                        disk_state["progress"] = progress
                    chapter_status = progress.get("chapter_status")
                    if not isinstance(chapter_status, dict):
                        chapter_status = {}
                        progress["chapter_status"] = chapter_status
                    chapter_status.update(self._pending_chapter_status)
                    progress["last_updated"] = self._now_progress_timestamp()
  • Step 5: Clear pending chapter statuses only after successful write

Where save_state() clears other pending structures after atomic_write_json, add:

                self._pending_chapter_status.clear()

Place it beside the existing pending clears, not before SQLite sync error handling.

  • Step 6: Update set_chapter_status()

Replace the final mutation/write block with:

        progress = self._state.setdefault("progress", {})
        chapter_status = progress.setdefault("chapter_status", {})
        chapter_status[str(chapter)] = status
        self._pending_chapter_status[str(chapter)] = status
        self.save_state()

Keep monotonicity checks unchanged.

  • Step 7: Run StateManager status tests

Run:

python -m pytest webnovel-writer/scripts/data_modules/tests/test_chapter_status.py webnovel-writer/scripts/data_modules/tests/test_state_manager_extra.py -q --no-cov

Expected: pass.

  • [ ] Step 8: Commit

    git add webnovel-writer/scripts/data_modules/state_manager.py webnovel-writer/scripts/data_modules/tests/test_state_manager_extra.py
    git commit -m "fix: merge chapter status updates under state lock"
    

Task 5: Add Summary/Scene Chunks and Safe Async Bridge to Vector Projection

Problem: Commit projection writes event/entity chunks only. RAG already supports summary and scene, but commit projection does not feed them. The writer also calls asyncio.run() directly, which fails if this synchronous projection path is ever invoked inside an active event loop.

Files:

  • Modify: webnovel-writer/scripts/data_modules/vector_projection_writer.py
  • Modify: webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py

  • [ ] Step 1: Add failing test for summary and scene chunks

Append to test_vector_projection_writer.py:

def test_collect_chunks_includes_summary_and_scenes():
    writer = VectorProjectionWriter.__new__(VectorProjectionWriter)
    payload = {
        "meta": {"chapter": 47, "status": "accepted"},
        "summary_text": "韩立在坊市发现丹方线索。",
        "scenes": [
            {"index": 1, "summary": "韩立入坊市观察摊位", "location": "坊市"},
            {"scene_index": 2, "content": "陈巧倩暗中提醒韩立有人跟踪。"},
        ],
        "accepted_events": [],
        "entity_deltas": [],
    }

    chunks = writer._collect_chunks(payload)

    by_type = {chunk["chunk_type"]: chunk for chunk in chunks}
    assert by_type["summary"]["chunk_id"] == "ch0047_summary"
    assert by_type["summary"]["parent_chunk_id"] is None
    assert by_type["scene"]["parent_chunk_id"] == "ch0047_summary"
    assert any(chunk["scene_index"] == 2 for chunk in chunks if chunk["chunk_type"] == "scene")
  • Step 2: Run failing vector test

Run:

python -m pytest webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py::test_collect_chunks_includes_summary_and_scenes -q --no-cov

Expected before implementation: fail because no summary/scene chunks exist.

  • Step 3: Add summary chunk collection

At the start of _collect_chunks() after chunk_counts, add:

        summary_text = str(commit_payload.get("summary_text") or "").strip()
        summary_chunk_id = f"ch{chapter:04d}_summary" if chapter > 0 else ""
        if chapter > 0 and summary_text:
            chunks.append({
                "chunk_id": summary_chunk_id,
                "chapter": chapter,
                "scene_index": 0,
                "content": summary_text,
                "chunk_type": "summary",
                "parent_chunk_id": None,
                "source_file": f"commit:chapter_{chapter:03d}",
            })
  • Step 4: Add scene chunk collection

After the summary block, add:

        for idx, scene in enumerate(commit_payload.get("scenes") or [], start=1):
            if not isinstance(scene, dict):
                continue
            scene_index = int(scene.get("scene_index") or scene.get("index") or idx)
            text = str(scene.get("summary") or scene.get("content") or "").strip()
            location = str(scene.get("location") or "").strip()
            if location and text:
                text = f"{location}:{text}"
            if not text:
                continue
            chunk_id = self._chunk_id("scene", chapter, scene_index)
            chunks.append({
                "chunk_id": chunk_id,
                "chapter": chapter,
                "scene_index": scene_index,
                "content": text,
                "chunk_type": "scene",
                "parent_chunk_id": summary_chunk_id or None,
                "source_file": f"commit:chapter_{chapter:03d}",
            })
  • Step 5: Run vector and RAG tests

Run:

python -m pytest webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py webnovel-writer/scripts/data_modules/tests/test_rag_adapter.py -q --no-cov

Expected: pass before the async bridge change, except any failures introduced by summary/scene implementation should be fixed before continuing.

  • Step 6: Add active event loop regression test

Append to test_vector_projection_writer.py:

import pytest


@pytest.mark.asyncio
async def test_run_store_coro_works_inside_active_event_loop():
    writer = VectorProjectionWriter.__new__(VectorProjectionWriter)

    async def store():
        return 3

    assert writer._run_store_coro(store()) == 3
  • Step 7: Run failing active-loop test

Run:

python -m pytest webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py::test_run_store_coro_works_inside_active_event_loop -q --no-cov

Expected before implementation: fail because _run_store_coro does not exist.

  • Step 8: Implement safe coroutine bridge

In webnovel-writer/scripts/data_modules/vector_projection_writer.py, add imports:

import threading
from collections.abc import Coroutine

Add this helper method to VectorProjectionWriter:

    def _run_store_coro(self, coro: Coroutine[Any, Any, int]) -> int:
        try:
            asyncio.get_running_loop()
        except RuntimeError:
            return int(asyncio.run(coro) or 0)

        result: dict[str, Any] = {}

        def runner() -> None:
            try:
                result["value"] = asyncio.run(coro)
            except Exception as exc:
                result["error"] = exc

        thread = threading.Thread(target=runner, daemon=True)
        thread.start()
        thread.join()
        if "error" in result:
            raise result["error"]
        return int(result.get("value") or 0)

Then change _store_chunks() from:

            stored = asyncio.run(adapter.store_chunks(chunks))
            return stored

to:

            return self._run_store_coro(adapter.store_chunks(chunks))

This keeps the synchronous public API intact while moving the coroutine to a short-lived thread when the caller already owns an event loop.

  • Step 9: Run vector and RAG tests

Run:

python -m pytest webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py webnovel-writer/scripts/data_modules/tests/test_rag_adapter.py -q --no-cov

Expected: pass.

  • [ ] Step 10: Commit

    git add webnovel-writer/scripts/data_modules/vector_projection_writer.py webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py
    git commit -m "feat: project summaries and scenes to vectors safely"
    

Task 6: Unify Review Skill Metrics Flow

Problem: CLI supports review-pipeline --save-metrics; webnovel-review still documents the old two-step flow.

Files:

  • Modify: webnovel-writer/skills/webnovel-review/SKILL.md
  • Verify: webnovel-writer/skills/webnovel-write/SKILL.md

  • [ ] Step 1: Update common mistakes

In webnovel-review/SKILL.md, replace:

- ❌ 把 report 文件生成等同于已落库(`save-review-metrics` 未跑)

with:

- ❌ 把 report 文件生成等同于已落库(`review-pipeline --save-metrics` 未跑)
  • Step 2: Replace Step 5 command block

Replace the two-command standard flow with:

python -X utf8 "${SCRIPTS_DIR}/webnovel.py" --project-root "${PROJECT_ROOT}" review-pipeline \
  --chapter {chapter_num} \
  --review-results "${PROJECT_ROOT}/.webnovel/tmp/review_results.json" \
  --metrics-out "${PROJECT_ROOT}/.webnovel/tmp/review_metrics.json" \
  --report-file "审查报告/第{chapter_num}章审查报告.md" \
  --save-metrics

Replace the requirement:

- `review-pipeline` 生成的 `review_metrics.json` 必须可直接写入 `review_metrics` 表

with:

- `review-pipeline --save-metrics` 必须完成报告生成、metrics 文件输出、`review_metrics` 表写入
  • Step 3: Grep for obsolete instruction

Run:

rg -n "save-review-metrics|--save-metrics" webnovel-writer/skills/webnovel-review/SKILL.md webnovel-writer/skills/webnovel-write/SKILL.md

Expected: webnovel-review no longer instructs a separate index save-review-metrics call; both skills mention --save-metrics.

  • [ ] Step 4: Commit

    git add webnovel-writer/skills/webnovel-review/SKILL.md
    git commit -m "docs: unify review metrics persistence flow"
    

Task 7: Tighten Local Dashboard and Backup Safety Defaults

Problem: Dashboard allows * CORS while exposing local project text. Backup-created .gitignore does not exclude .env.

Files:

  • Modify: webnovel-writer/dashboard/app.py
  • Modify: webnovel-writer/scripts/backup_manager.py
  • Test: add webnovel-writer/scripts/tests/test_dashboard_security.py
  • Test: add webnovel-writer/scripts/tests/test_backup_manager.py

  • [ ] Step 1: Add dashboard security tests

Create webnovel-writer/scripts/tests/test_dashboard_security.py:

from fastapi.testclient import TestClient

from dashboard.app import create_app


def test_dashboard_cors_allows_localhost_origin(tmp_path):
    (tmp_path / ".webnovel").mkdir(parents=True)
    (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
    app = create_app(tmp_path)
    client = TestClient(app)

    response = client.options(
        "/api/project/info",
        headers={
            "Origin": "http://localhost:5173",
            "Access-Control-Request-Method": "GET",
        },
    )

    assert response.headers["access-control-allow-origin"] == "http://localhost:5173"


def test_dashboard_cors_rejects_untrusted_origin(tmp_path):
    (tmp_path / ".webnovel").mkdir(parents=True)
    (tmp_path / ".webnovel" / "state.json").write_text("{}", encoding="utf-8")
    app = create_app(tmp_path)
    client = TestClient(app)

    response = client.options(
        "/api/project/info",
        headers={
            "Origin": "https://example.com",
            "Access-Control-Request-Method": "GET",
        },
    )

    assert "access-control-allow-origin" not in response.headers
  • Step 2: Restrict CORS origins

In dashboard/app.py, add module-level constant:

LOCAL_CORS_ORIGINS = [
    "http://localhost",
    "http://localhost:5173",
    "http://localhost:8000",
    "http://127.0.0.1",
    "http://127.0.0.1:5173",
    "http://127.0.0.1:8000",
]

Change middleware setup to:

    app.add_middleware(
        CORSMiddleware,
        allow_origins=LOCAL_CORS_ORIGINS,
        allow_methods=["GET"],
        allow_headers=["*"],
    )
  • Step 3: Add file read size guard

In file_read(), before read_text, add:

        max_bytes = 2 * 1024 * 1024
        if resolved.stat().st_size > max_bytes:
            raise HTTPException(413, "文件过大,无法预览")
  • Step 4: Add backup gitignore test

Create webnovel-writer/scripts/tests/test_backup_manager.py:

import subprocess

from backup_manager import GitBackupManager


def test_backup_manager_gitignore_excludes_env(tmp_path, monkeypatch):
    calls = []

    def fake_run(args, cwd=None, check=False, capture_output=False, text=False):
        calls.append(args)
        if args == ["git", "init"]:
            (tmp_path / ".git").mkdir()
        return subprocess.CompletedProcess(args=args, returncode=0, stdout="", stderr="")

    monkeypatch.setattr("backup_manager.is_git_available", lambda: True)
    monkeypatch.setattr("backup_manager.subprocess.run", fake_run)

    GitBackupManager(str(tmp_path))

    gitignore = (tmp_path / ".gitignore").read_text(encoding="utf-8")
    assert ".env" in gitignore
    assert ".env.*" in gitignore
    assert "!.env.example" in gitignore
  • Step 5: Update backup .gitignore template

In backup_manager.py, add this block to the generated .gitignore:

# Env (keep .env.example)
.env
.env.*
!.env.example
  • Step 6: Run security tests

Run:

python -m pytest webnovel-writer/scripts/tests/test_dashboard_security.py webnovel-writer/scripts/tests/test_backup_manager.py -q --no-cov

Expected: pass.

  • [ ] Step 7: Commit

    git add webnovel-writer/dashboard/app.py webnovel-writer/scripts/backup_manager.py webnovel-writer/scripts/tests/test_dashboard_security.py webnovel-writer/scripts/tests/test_backup_manager.py
    git commit -m "fix: tighten dashboard cors and backup gitignore defaults"
    

Task 8: Harden StyleSampler Tag JSON Parsing

Problem: A corrupt tags JSON value in style_samples.db crashes listing.

Files:

  • Modify: webnovel-writer/scripts/data_modules/style_sampler.py
  • Modify: webnovel-writer/scripts/data_modules/tests/test_style_sampler_cli.py

  • [ ] Step 1: Add corrupt tag regression test

Append to test_style_sampler_cli.py:

def test_style_sampler_ignores_corrupt_tag_json(temp_project):
    sampler = StyleSampler(temp_project)
    with sampler._get_conn() as conn:
        conn.execute(
            """
            INSERT INTO style_samples
            (id, chapter, scene_type, content, score, tags, created_at)
            VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
            """,
            ("bad-tags", 1, SceneType.BATTLE.value, "战斗描写" * 50, 0.8, "[bad-json"),
        )
        conn.commit()

    samples = sampler.get_best_samples(limit=5)

    assert samples[0].id == "bad-tags"
    assert samples[0].tags == []

If the schema column order differs, inspect _ensure_db() in style_sampler.py and adjust column names exactly.

  • Step 2: Run failing test

Run:

python -m pytest webnovel-writer/scripts/data_modules/tests/test_style_sampler_cli.py::test_style_sampler_ignores_corrupt_tag_json -q --no-cov

Expected before implementation: fail with json.JSONDecodeError.

  • Step 3: Add safe JSON helper

In style_sampler.py, add:

def _safe_json_list(raw) -> list:
    if not raw:
        return []
    try:
        value = json.loads(raw)
    except (TypeError, json.JSONDecodeError):
        return []
    return value if isinstance(value, list) else []

Change row mapping from:

            tags=json.loads(row[5]) if row[5] else [],

to:

            tags=_safe_json_list(row[5]),
  • Step 4: Run style sampler tests

Run:

python -m pytest webnovel-writer/scripts/data_modules/tests/test_style_sampler_cli.py -q --no-cov

Expected: pass.

  • [ ] Step 5: Commit

    git add webnovel-writer/scripts/data_modules/style_sampler.py webnovel-writer/scripts/data_modules/tests/test_style_sampler_cli.py
    git commit -m "fix: tolerate corrupt style sample tags"
    

Task 9: Final Verification

Files: read-only verification, plus fixes if tests reveal regressions.

  • Step 1: Compile Python sources

Run:

python -m compileall -q webnovel-writer/scripts webnovel-writer/dashboard

Expected: exit code 0.

  • Step 2: Run focused regression suite

Run:

python -m pytest \
  webnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.py \
  webnovel-writer/scripts/data_modules/tests/test_coverage_boost.py \
  webnovel-writer/scripts/data_modules/tests/test_projection_writers.py \
  webnovel-writer/scripts/data_modules/tests/test_knowledge_query.py \
  webnovel-writer/scripts/data_modules/tests/test_chapter_status.py \
  webnovel-writer/scripts/data_modules/tests/test_state_manager_extra.py \
  webnovel-writer/scripts/data_modules/tests/test_vector_projection_writer.py \
  webnovel-writer/scripts/data_modules/tests/test_style_sampler_cli.py \
  webnovel-writer/scripts/tests/test_dashboard_security.py \
  webnovel-writer/scripts/tests/test_backup_manager.py \
  -q --no-cov

Expected: all pass.

  • Step 3: Run full suite

Run:

python -m pytest -q

Expected: all tests pass and coverage is at least 90%.

  • Step 4: Inspect review skill command consistency

Run:

rg -n "save-review-metrics|review-pipeline|--save-metrics" webnovel-writer/skills/webnovel-review/SKILL.md webnovel-writer/skills/webnovel-write/SKILL.md

Expected: both review flows use review-pipeline --save-metrics; no active instruction requires separate index save-review-metrics.

  • Step 5: Commit final test adjustments if needed

If verification required small test-only fixes:

git add <changed-files>
git commit -m "test: cover critical review fixes"

If no further changes were needed, do not create an empty commit.


Out of Scope for This Plan

  • Full /webnovel-resync implementation for manually edited chapters.
  • Full automatic review-fix loop.
  • Full SQLite migration framework.
  • Large refactors of index_manager.py, status_reporter.py, or DataModulesConfig.
  • Dashboard frontend performance work beyond security defaults.
  • Anti-AI/style quality product line improvements.

These should be planned separately after the correctness and test-trust fixes land.