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
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.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.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.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.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.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.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.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.webnovel-writer/scripts/tests/ or webnovel-writer/scripts/data_modules/tests/ for these fixes.@file JSON Arguments to Project RootsProblem: 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:
webnovel-writer/scripts/data_modules/cli_args.pywebnovel-writer/scripts/data_modules/index_manager.pywebnovel-writer/scripts/data_modules/state_manager.pywebnovel-writer/scripts/data_modules/sql_state_manager.pywebnovel-writer/scripts/data_modules/memory/store.pywebnovel-writer/scripts/data_modules/rag_adapter.pywebnovel-writer/scripts/data_modules/style_sampler.pyTest: 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}
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'.
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)
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.
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"
Problem: StateProjectionWriter supports rejected -> chapter_rejected, but both CLI and service skip projections for non-accepted commits.
Files:
webnovel-writer/scripts/chapter_commit.pywebnovel-writer/scripts/data_modules/chapter_commit_service.pywebnovel-writer/scripts/data_modules/event_projection_router.pywebnovel-writer/scripts/data_modules/tests/test_chapter_commit_service.pyTest: 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"
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.
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)
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.
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))
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"
Problem: pytest.ini disables asyncio and anyio, so @pytest.mark.asyncio tests fail or are not exercised correctly.
Files:
pytest.iniVerify: 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.
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.
Run:
python -m pytest webnovel-writer/scripts/data_modules/tests/test_rag_adapter.py -q --no-cov
Expected: async tests execute and pass.
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"
Problem: Production table uses relationship_events.type; KnowledgeQuery and its tests use relationship_type.
Files:
webnovel-writer/scripts/data_modules/knowledge_query.pyModify: 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.
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.
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(),
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"
Problem: set_chapter_status() mutates in-memory state and calls _save_state(), bypassing save_state() locking and disk merge.
Files:
webnovel-writer/scripts/data_modules/state_manager.pywebnovel-writer/scripts/data_modules/tests/test_state_manager_extra.pyVerify: 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": "宗主"}]
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.
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,
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()
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.
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.
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"
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:
webnovel-writer/scripts/data_modules/vector_projection_writer.pyModify: 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")
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.
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}",
})
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}",
})
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.
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
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.
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.
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"
Problem: CLI supports review-pipeline --save-metrics; webnovel-review still documents the old two-step flow.
Files:
webnovel-writer/skills/webnovel-review/SKILL.mdVerify: 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` 未跑)
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` 表写入
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"
Problem: Dashboard allows * CORS while exposing local project text. Backup-created .gitignore does not exclude .env.
Files:
webnovel-writer/dashboard/app.pywebnovel-writer/scripts/backup_manager.pywebnovel-writer/scripts/tests/test_dashboard_security.pyTest: 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
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=["*"],
)
In file_read(), before read_text, add:
max_bytes = 2 * 1024 * 1024
if resolved.stat().st_size > max_bytes:
raise HTTPException(413, "文件过大,无法预览")
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
.gitignore templateIn backup_manager.py, add this block to the generated .gitignore:
# Env (keep .env.example)
.env
.env.*
!.env.example
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"
Problem: A corrupt tags JSON value in style_samples.db crashes listing.
Files:
webnovel-writer/scripts/data_modules/style_sampler.pyModify: 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.
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.
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]),
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"
Files: read-only verification, plus fixes if tests reveal regressions.
Run:
python -m compileall -q webnovel-writer/scripts webnovel-writer/dashboard
Expected: exit code 0.
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.
Run:
python -m pytest -q
Expected: all tests pass and coverage is at least 90%.
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.
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.
/webnovel-resync implementation for manually edited chapters.index_manager.py, status_reporter.py, or DataModulesConfig.These should be planned separately after the correctness and test-trust fixes land.