Преглед изворни кода

feat: add author-friendly reporting foundations

lingfengQAQ пре 2 недеља
родитељ
комит
77cf5c3a63

+ 126 - 0
webnovel-writer/references/author_error_catalog.json

@@ -0,0 +1,126 @@
+{
+  "schema_version": "webnovel-author-error-catalog/v1",
+  "errors": [
+    {
+      "code": "mainline_ready=false",
+      "match": {
+        "codes": ["mainline_ready=false"],
+        "contains": ["mainline_ready=false"]
+      },
+      "severity": "must_handle",
+      "title": "这本书的写作档案还没就绪",
+      "reason": "系统还没有拿到继续写作所需的 Story System 主链资料。",
+      "impact": "继续写章可能会用旧资料或缺少本章写作要求。",
+      "next_action": "先运行 `/webnovel-init` 创建项目档案;如果已经初始化过,再运行 `/webnovel-doctor` 查缺项。",
+      "command": "/webnovel-doctor",
+      "auto_handle": false
+    },
+    {
+      "code": "write-gate failed",
+      "match": {
+        "codes": ["write-gate failed", "prewrite_validator_blocking", "phase_not_ready_for_precommit"],
+        "contains": ["write-gate failed"]
+      },
+      "severity": "must_handle",
+      "title": "写作自检没有通过",
+      "reason": "系统在写前、提交前或提交后检查时发现阻断问题。",
+      "impact": "当前章节不能可靠进入下一步。",
+      "next_action": "按报告里的影响和修复建议处理后,重新运行同一条写作命令。",
+      "command": "/webnovel-doctor",
+      "auto_handle": false
+    },
+    {
+      "code": "chapter-commit rejected",
+      "match": {
+        "codes": ["chapter-commit rejected", "commit.rejected", "rejected"],
+        "contains": ["chapter-commit rejected"]
+      },
+      "severity": "must_handle",
+      "title": "本章事实没有通过提交",
+      "reason": "本章仍有未覆盖节点、待确认歧义或阻断审查问题。",
+      "impact": "本章不会被当作已正式写入故事主链,后续章节不能直接依赖它。",
+      "next_action": "先修复 missed_nodes、pending 或 blocking 问题,再重新提交本章事实。",
+      "command": "/webnovel-doctor",
+      "auto_handle": false
+    },
+    {
+      "code": "artifact.schema_error",
+      "match": {
+        "codes": ["artifact.schema_error", "schema_error"],
+        "contains": ["schema error", "missing field", "字段"]
+      },
+      "severity": "must_handle",
+      "title": "中间结果格式不完整",
+      "reason": "某个检查或资料整理结果缺少必要字段。",
+      "impact": "系统无法确认本章事实是否完整,不能继续提交。",
+      "next_action": "重新运行产生该中间结果的步骤;如果仍失败,运行 `/webnovel-doctor` 并附日志反馈。",
+      "command": "/webnovel-doctor",
+      "auto_handle": false
+    },
+    {
+      "code": "missing_artifact",
+      "match": {
+        "codes": ["artifact.missing_artifact", "missing_artifact"],
+        "contains": ["artifact missing", "missing artifact"]
+      },
+      "severity": "must_handle",
+      "title": "缺少中间结果文件",
+      "reason": "写作检查、目标完成情况或故事事实提取结果没有生成。",
+      "impact": "系统无法安全保存本章事实。",
+      "next_action": "重新运行同一条主命令,让系统从缺失步骤继续。",
+      "command": "/webnovel-doctor",
+      "auto_handle": false
+    },
+    {
+      "code": "projection failed",
+      "match": {
+        "codes": ["projection_failure", "commit.projection_failure", "projection failed"],
+        "contains": ["projection failed"]
+      },
+      "severity": "must_handle",
+      "title": "故事资料更新失败",
+      "reason": "本章事实已生成,但同步到状态、摘要、长期记忆或检索库时失败。",
+      "impact": "后续查询可能读不到本章最新变化。",
+      "next_action": "修复失败原因后补跑资料更新;也可以先运行 `/webnovel-doctor` 查看具体卡点。",
+      "command": "/webnovel-doctor",
+      "auto_handle": true
+    },
+    {
+      "code": "projection pending",
+      "match": {
+        "codes": ["projection_pending", "projection_status_missing", "projection_incomplete"],
+        "contains": ["projection pending"]
+      },
+      "severity": "needs_confirmation",
+      "title": "故事资料还没更新完成",
+      "reason": "本章事实同步到各处的状态仍在等待或缺少记录。",
+      "impact": "后续查询可能暂时不完整。",
+      "next_action": "等待或补跑资料更新;如果反复出现,运行 `/webnovel-doctor`。",
+      "command": "/webnovel-doctor",
+      "auto_handle": true
+    },
+    {
+      "code": "rag degraded",
+      "match": {
+        "codes": ["rag.degraded", "rag_fallback", "fallback"],
+        "contains": ["rag fallback"]
+      },
+      "severity": "auto_handled",
+      "title": "检索临时降级",
+      "reason": "向量检索不可用或响应失败,系统改用较简单的读取方式。",
+      "impact": "当前流程可以继续,但召回资料可能不如平时完整。",
+      "next_action": "本次无需处理;如果经常出现,检查 RAG API key 和网络配置。",
+      "command": "/webnovel-doctor",
+      "auto_handle": true
+    }
+  ],
+  "fallback": {
+    "severity": "must_handle",
+    "title": "遇到未登记的问题",
+    "reason": "这里遇到一个系统还没有登记过的问题。",
+    "impact": "当前不会把它当成已完成。",
+    "next_action": "请先运行 `/webnovel-doctor`,或反馈时附上 `.webnovel/logs/run_last.log`。",
+    "command": "/webnovel-doctor",
+    "auto_handle": false
+  }
+}

+ 145 - 0
webnovel-writer/references/author_glossary.json

@@ -0,0 +1,145 @@
+{
+  "schema_version": "webnovel-author-glossary/v1",
+  "terms": [
+    {
+      "technical": "subagent",
+      "author": "写作助手",
+      "explanation": "负责某一类写作任务的辅助步骤,例如写前准备、写作检查或资料整理。"
+    },
+    {
+      "technical": "context-agent",
+      "author": "写前准备",
+      "explanation": "整理本章需要参考的前情、章纲、伏笔和设定。"
+    },
+    {
+      "technical": "reviewer",
+      "author": "写作检查",
+      "explanation": "检查本章是否存在影响继续写作的问题。"
+    },
+    {
+      "technical": "data-agent",
+      "author": "保存本章故事事实",
+      "explanation": "提取本章新发生的事件、人物状态和设定变化。"
+    },
+    {
+      "technical": "deconstruction-agent",
+      "author": "参考作品拆解",
+      "explanation": "把参考作品的可借鉴结构整理成创意素材。"
+    },
+    {
+      "technical": "artifact",
+      "author": "中间结果文件",
+      "explanation": "流程中保存的检查、提取或提交前材料。"
+    },
+    {
+      "technical": "review_results",
+      "author": "写作检查结果",
+      "explanation": "本章写作检查的结构化结果。"
+    },
+    {
+      "technical": "fulfillment_result",
+      "author": "本章目标完成情况",
+      "explanation": "本章是否覆盖了章纲要求的关键节点。"
+    },
+    {
+      "technical": "disambiguation_result",
+      "author": "待确认的人名/设定歧义",
+      "explanation": "系统不确定某些称呼或设定指向,需要确认后再入库。"
+    },
+    {
+      "technical": "extraction_result",
+      "author": "本章新发生的故事事实",
+      "explanation": "本章可写入故事资料的事件、状态和关系变化。"
+    },
+    {
+      "technical": "chapter-commit",
+      "author": "提交本章事实",
+      "explanation": "把本章通过检查的故事事实正式保存进主链。"
+    },
+    {
+      "technical": "CHAPTER_COMMIT",
+      "author": "本章事实存档",
+      "explanation": "系统保存本章事实时生成的权威记录。"
+    },
+    {
+      "technical": "commit",
+      "author": "入账存档",
+      "explanation": "把本章已确认的事实正式记入故事资料。"
+    },
+    {
+      "technical": "projection",
+      "author": "更新故事资料",
+      "explanation": "把已保存的本章事实同步到状态、摘要、长期记忆和检索库。"
+    },
+    {
+      "technical": "state",
+      "author": "状态",
+      "explanation": "角色、地点和故事进度的当前状态。"
+    },
+    {
+      "technical": "index",
+      "author": "索引",
+      "explanation": "用于查询章节、角色和事件的资料索引。"
+    },
+    {
+      "technical": "summary",
+      "author": "摘要",
+      "explanation": "章节或剧情的简要记录。"
+    },
+    {
+      "technical": "memory",
+      "author": "长期记忆",
+      "explanation": "跨章节保持一致所需的长期资料。"
+    },
+    {
+      "technical": "vector",
+      "author": "检索库",
+      "explanation": "用于相似内容召回和写前查询的资料库。"
+    },
+    {
+      "technical": "write-gate",
+      "author": "自检关卡",
+      "explanation": "写作流程在关键节点做的安全检查。"
+    },
+    {
+      "technical": "blocking issue",
+      "author": "会影响继续写作的问题",
+      "explanation": "不处理会影响提交、连贯性或后续写作的问题。"
+    },
+    {
+      "technical": "fallback",
+      "author": "临时降级读取",
+      "explanation": "主资料不可用时,临时用较简单方式继续读取必要信息。"
+    },
+    {
+      "technical": "runtime contract",
+      "author": "本章写作要求",
+      "explanation": "本章必须遵守的章纲、设定、禁区和检查规则。"
+    },
+    {
+      "technical": "schema error",
+      "author": "中间结果格式不完整",
+      "explanation": "流程产物缺字段或形状不对,系统无法可靠继续。"
+    },
+    {
+      "technical": "mainline_ready",
+      "author": "这本书的档案是否就绪",
+      "explanation": "Story System 主链是否已经具备继续写作所需的基础资料。"
+    },
+    {
+      "technical": "pending",
+      "author": "等待确认",
+      "explanation": "还有事项没有确认,暂时不能当成完成。"
+    },
+    {
+      "technical": "rejected",
+      "author": "本章事实未通过提交",
+      "explanation": "本章事实没有被正式保存进主链。"
+    },
+    {
+      "technical": "accepted",
+      "author": "本章事实已通过提交",
+      "explanation": "本章事实已经正式保存进主链。"
+    }
+  ]
+}

+ 84 - 0
webnovel-writer/scripts/data_modules/author_glossary.py

@@ -0,0 +1,84 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from functools import lru_cache
+from pathlib import Path
+from typing import Any
+
+
+SCHEMA_VERSION = "webnovel-author-glossary/v1"
+
+
+@dataclass(frozen=True)
+class AuthorTerm:
+    technical: str
+    author: str
+    explanation: str
+
+    def to_dict(self) -> dict[str, str]:
+        return {
+            "technical": self.technical,
+            "author": self.author,
+            "explanation": self.explanation,
+        }
+
+
+def default_glossary_path() -> Path:
+    return Path(__file__).resolve().parents[2] / "references" / "author_glossary.json"
+
+
+def _load_payload(path: str | Path | None = None) -> dict[str, Any]:
+    glossary_path = Path(path) if path else default_glossary_path()
+    return json.loads(glossary_path.read_text(encoding="utf-8"))
+
+
+def load_terms(path: str | Path | None = None) -> dict[str, AuthorTerm]:
+    payload = _load_payload(path)
+    if payload.get("schema_version") != SCHEMA_VERSION:
+        raise ValueError(f"unknown author glossary schema: {payload.get('schema_version')}")
+    terms: dict[str, AuthorTerm] = {}
+    for raw in payload.get("terms") or []:
+        if not isinstance(raw, dict):
+            continue
+        technical = str(raw.get("technical") or "").strip()
+        author = str(raw.get("author") or "").strip()
+        explanation = str(raw.get("explanation") or "").strip()
+        if not technical or not author or not explanation:
+            continue
+        terms[technical] = AuthorTerm(
+            technical=technical,
+            author=author,
+            explanation=explanation,
+        )
+    return terms
+
+
+@lru_cache(maxsize=1)
+def _default_terms() -> dict[str, AuthorTerm]:
+    return load_terms()
+
+
+def lookup(term: str, *, terms: dict[str, AuthorTerm] | None = None) -> AuthorTerm | None:
+    term = str(term or "").strip()
+    if not term:
+        return None
+    source = terms if terms is not None else _default_terms()
+    if term in source:
+        return source[term]
+    lower_map = {key.lower(): value for key, value in source.items()}
+    return lower_map.get(term.lower())
+
+
+def author_label(term: str, *, terms: dict[str, AuthorTerm] | None = None) -> str:
+    found = lookup(term, terms=terms)
+    return found.author if found else str(term)
+
+
+def explain(term: str, *, terms: dict[str, AuthorTerm] | None = None) -> str:
+    found = lookup(term, terms=terms)
+    if found:
+        return found.explanation
+    return f"{term}:系统暂未登记这个术语,先按原词显示。"

+ 151 - 0
webnovel-writer/scripts/data_modules/error_catalog.py

@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from functools import lru_cache
+from pathlib import Path
+from typing import Any
+
+
+SCHEMA_VERSION = "webnovel-author-error-catalog/v1"
+VALID_SEVERITIES = {"auto_handled", "needs_confirmation", "must_handle"}
+
+
+@dataclass(frozen=True)
+class AuthorError:
+    code: str
+    severity: str
+    title: str
+    reason: str
+    impact: str
+    next_action: str
+    command: str = ""
+    auto_handle: bool = False
+    matched: bool = True
+
+    def to_dict(self) -> dict[str, Any]:
+        return {
+            "code": self.code,
+            "severity": self.severity,
+            "title": self.title,
+            "reason": self.reason,
+            "impact": self.impact,
+            "next_action": self.next_action,
+            "command": self.command,
+            "auto_handle": self.auto_handle,
+            "matched": self.matched,
+        }
+
+
+@dataclass(frozen=True)
+class ErrorCatalogEntry:
+    code: str
+    match_codes: tuple[str, ...]
+    match_contains: tuple[str, ...]
+    error: AuthorError
+
+
+def default_catalog_path() -> Path:
+    return Path(__file__).resolve().parents[2] / "references" / "author_error_catalog.json"
+
+
+def _coerce_error(raw: dict[str, Any], *, matched: bool = True) -> AuthorError:
+    severity = str(raw.get("severity") or "must_handle")
+    if severity not in VALID_SEVERITIES:
+        severity = "must_handle"
+    return AuthorError(
+        code=str(raw.get("code") or "unknown"),
+        severity=severity,
+        title=str(raw.get("title") or "遇到问题"),
+        reason=str(raw.get("reason") or "系统没有提供具体原因。"),
+        impact=str(raw.get("impact") or "当前结果需要确认。"),
+        next_action=str(raw.get("next_action") or "运行 `/webnovel-doctor` 查看详情。"),
+        command=str(raw.get("command") or ""),
+        auto_handle=bool(raw.get("auto_handle")),
+        matched=matched,
+    )
+
+
+def _load_payload(path: str | Path | None = None) -> dict[str, Any]:
+    catalog_path = Path(path) if path else default_catalog_path()
+    return json.loads(catalog_path.read_text(encoding="utf-8"))
+
+
+def load_catalog(path: str | Path | None = None) -> tuple[list[ErrorCatalogEntry], AuthorError]:
+    payload = _load_payload(path)
+    if payload.get("schema_version") != SCHEMA_VERSION:
+        raise ValueError(f"unknown author error catalog schema: {payload.get('schema_version')}")
+
+    entries: list[ErrorCatalogEntry] = []
+    for raw in payload.get("errors") or []:
+        if not isinstance(raw, dict):
+            continue
+        match = raw.get("match") if isinstance(raw.get("match"), dict) else {}
+        match_codes = tuple(str(item).strip() for item in match.get("codes") or [] if str(item).strip())
+        match_contains = tuple(str(item).strip() for item in match.get("contains") or [] if str(item).strip())
+        error = _coerce_error(raw, matched=True)
+        entries.append(
+            ErrorCatalogEntry(
+                code=error.code,
+                match_codes=match_codes or (error.code,),
+                match_contains=match_contains,
+                error=error,
+            )
+        )
+
+    fallback_raw = payload.get("fallback") if isinstance(payload.get("fallback"), dict) else {}
+    fallback = _coerce_error({"code": "unknown", **fallback_raw}, matched=False)
+    return entries, fallback
+
+
+@lru_cache(maxsize=1)
+def _default_catalog() -> tuple[list[ErrorCatalogEntry], AuthorError]:
+    return load_catalog()
+
+
+def _haystack_from_issue(issue: Any) -> tuple[str, str]:
+    if isinstance(issue, dict):
+        code = str(issue.get("code") or issue.get("id") or issue.get("type") or "").strip()
+        text_parts = [
+            code,
+            str(issue.get("message") or ""),
+            str(issue.get("reason") or ""),
+            str(issue.get("impact") or ""),
+            str(issue.get("repair") or ""),
+            str(issue.get("actual") or ""),
+        ]
+        return code, "\n".join(text_parts)
+    text = str(issue or "")
+    return text.strip(), text
+
+
+def classify_issue(
+    issue: Any,
+    *,
+    catalog: tuple[list[ErrorCatalogEntry], AuthorError] | None = None,
+) -> AuthorError:
+    entries, fallback = catalog if catalog is not None else _default_catalog()
+    code, text = _haystack_from_issue(issue)
+    lower_text = text.lower()
+    lower_code = code.lower()
+
+    for entry in entries:
+        if any(lower_code == item.lower() for item in entry.match_codes):
+            return entry.error
+        if any(item.lower() in lower_text for item in entry.match_contains):
+            return entry.error
+    return fallback
+
+
+def format_author_error(error: AuthorError) -> str:
+    lines = [
+        f"{error.title}",
+        f"- 原因:{error.reason}",
+        f"- 影响:{error.impact}",
+        f"- 下一步:{error.next_action}",
+    ]
+    if error.command:
+        lines.append(f"- 可用命令:{error.command}")
+    return "\n".join(lines)

+ 92 - 0
webnovel-writer/scripts/data_modules/review_author_view.py

@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+
+MAX_ACTIONS = 3
+
+
+@dataclass(frozen=True)
+class ReviewAuthorView:
+    verdict: str
+    status: str
+    actions: tuple[str, ...]
+
+    def to_dict(self) -> dict[str, Any]:
+        return {
+            "verdict": self.verdict,
+            "status": self.status,
+            "actions": list(self.actions),
+        }
+
+
+def _issue_priority(issue: dict[str, Any]) -> tuple[int, int]:
+    severity_rank = {
+        "critical": 0,
+        "high": 1,
+        "medium": 2,
+        "low": 3,
+    }
+    blocking_rank = 0 if issue.get("blocking") else 1
+    severity = str(issue.get("severity") or "medium")
+    return blocking_rank, severity_rank.get(severity, 2)
+
+
+def _action_from_issue(issue: dict[str, Any]) -> str:
+    description = str(issue.get("description") or "未填写问题描述").strip()
+    fix_hint = str(issue.get("fix_hint") or "").strip()
+    location = str(issue.get("location") or "").strip()
+
+    prefix = f"{location}:" if location else ""
+    if fix_hint:
+        return f"{prefix}{description}。建议:{fix_hint}"
+    return f"{prefix}{description}"
+
+
+def build_review_author_view(payload: dict[str, Any]) -> ReviewAuthorView:
+    result = payload.get("review_result") or {}
+    issues = [item for item in result.get("issues") or [] if isinstance(item, dict)]
+    blocking_issues = [issue for issue in issues if issue.get("blocking")]
+    sorted_issues = sorted(issues, key=_issue_priority)
+
+    blocking_count = int(result.get("blocking_count") or len(blocking_issues))
+    if blocking_count > 0:
+        source = blocking_issues or sorted_issues
+        actions = tuple(_action_from_issue(issue) for issue in source[:MAX_ACTIONS])
+        return ReviewAuthorView(
+            verdict="⛔必须先改",
+            status="must_fix",
+            actions=actions or ("先处理阻断问题,再继续写下一章。",),
+        )
+
+    if sorted_issues:
+        actions = tuple(_action_from_issue(issue) for issue in sorted_issues[:MAX_ACTIONS])
+        return ReviewAuthorView(
+            verdict="⚠️建议改",
+            status="suggest_fix",
+            actions=actions,
+        )
+
+    summary = str(result.get("summary") or "").strip()
+    action = summary if summary else "本章没有发现阻断问题,可以继续下一步。"
+    return ReviewAuthorView(
+        verdict="✅可以继续",
+        status="can_continue",
+        actions=(action,),
+    )
+
+
+def render_review_author_view(payload: dict[str, Any]) -> str:
+    view = build_review_author_view(payload)
+    lines = [
+        "## 作者视图",
+        "",
+        f"本章结论:{view.verdict}",
+        "",
+        "最值得处理的 1-3 件事:",
+    ]
+    lines.extend(f"- {action}" for action in view.actions[:MAX_ACTIONS])
+    return "\n".join(lines).rstrip() + "\n"

+ 36 - 0
webnovel-writer/scripts/data_modules/tests/test_author_glossary.py

@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+from data_modules.author_glossary import (
+    author_label,
+    default_glossary_path,
+    explain,
+    load_terms,
+    lookup,
+)
+
+
+def test_author_glossary_loads_single_source():
+    terms = load_terms()
+
+    assert default_glossary_path().is_file()
+    assert terms["projection"].author == "更新故事资料"
+    assert terms["mainline_ready"].author == "这本书的档案是否就绪"
+    assert terms["write-gate"].explanation
+
+
+def test_author_glossary_lookup_is_case_insensitive():
+    terms = load_terms()
+
+    found = lookup("chapter_commit", terms=terms)
+    assert found is not None
+    assert found.author == "本章事实存档"
+    assert author_label("CHAPTER_COMMIT", terms=terms) == "本章事实存档"
+
+
+def test_author_glossary_unknown_term_falls_back_to_original():
+    terms = load_terms()
+
+    assert author_label("unknown_runtime_word", terms=terms) == "unknown_runtime_word"
+    assert "unknown_runtime_word" in explain("unknown_runtime_word", terms=terms)

+ 65 - 0
webnovel-writer/scripts/data_modules/tests/test_error_catalog.py

@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+from data_modules.error_catalog import classify_issue, format_author_error, load_catalog
+
+
+def test_error_catalog_loads_known_entries_and_fallback():
+    entries, fallback = load_catalog()
+
+    codes = {entry.code for entry in entries}
+    assert "mainline_ready=false" in codes
+    assert "projection pending" in codes
+    assert fallback.matched is False
+    assert fallback.severity == "must_handle"
+
+
+def test_error_catalog_classifies_schema_error_by_code():
+    result = classify_issue(
+        {
+            "code": "artifact.schema_error",
+            "message": "field required: accepted_events",
+        }
+    )
+
+    assert result.code == "artifact.schema_error"
+    assert result.severity == "must_handle"
+    assert result.auto_handle is False
+    assert "中间结果格式不完整" in format_author_error(result)
+
+
+def test_error_catalog_distinguishes_projection_pending_from_failed():
+    pending = classify_issue(
+        {
+            "code": "projection_status_missing",
+            "message": "projection pending: vector is missing",
+        }
+    )
+    failed = classify_issue(
+        {
+            "code": "projection_failure",
+            "message": "projection failed: vector timeout",
+        }
+    )
+
+    assert pending.code == "projection pending"
+    assert pending.severity == "needs_confirmation"
+    assert failed.code == "projection failed"
+    assert failed.severity == "must_handle"
+
+
+def test_error_catalog_classifies_rag_fallback_as_auto_handled():
+    result = classify_issue("RAG fallback used because vector search timed out")
+
+    assert result.code == "rag degraded"
+    assert result.severity == "auto_handled"
+    assert result.auto_handle is True
+
+
+def test_error_catalog_unknown_error_honestly_falls_back():
+    result = classify_issue({"code": "new.runtime.error", "message": "unexpected traceback"})
+
+    assert result.matched is False
+    assert result.code == "unknown"
+    assert "/webnovel-doctor" in result.next_action

+ 92 - 0
webnovel-writer/scripts/data_modules/tests/test_review_author_view.py

@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+from __future__ import annotations
+
+from data_modules.review_author_view import build_review_author_view, render_review_author_view
+
+
+def _payload(issues, *, summary=""):
+    return {
+        "chapter": 1,
+        "review_result": {
+            "issues": issues,
+            "issues_count": len(issues),
+            "blocking_count": sum(1 for issue in issues if issue.get("blocking")),
+            "has_blocking": any(issue.get("blocking") for issue in issues),
+            "summary": summary,
+        },
+        "metrics": {},
+    }
+
+
+def test_review_author_view_marks_blocking_as_must_fix():
+    view = build_review_author_view(
+        _payload(
+            [
+                {
+                    "severity": "critical",
+                    "category": "timeline",
+                    "location": "第2段",
+                    "description": "时间线回跳",
+                    "fix_hint": "补一句从深夜到清晨的过渡",
+                    "blocking": True,
+                },
+                {
+                    "severity": "medium",
+                    "description": "节奏略慢",
+                    "fix_hint": "压缩解释",
+                },
+            ]
+        )
+    )
+
+    assert view.status == "must_fix"
+    assert view.verdict == "⛔必须先改"
+    assert len(view.actions) == 1
+    assert "时间线回跳" in view.actions[0]
+    assert "补一句" in view.actions[0]
+
+
+def test_review_author_view_limits_actions_to_three_and_prioritizes_severity():
+    issues = [
+        {"severity": "low", "description": "低优先级"},
+        {"severity": "medium", "description": "中优先级"},
+        {"severity": "high", "description": "高优先级"},
+        {"severity": "critical", "description": "严重但非阻断", "blocking": False},
+    ]
+
+    view = build_review_author_view(_payload(issues))
+
+    assert view.status == "suggest_fix"
+    assert view.verdict == "⚠️建议改"
+    assert len(view.actions) == 3
+    assert "严重但非阻断" in view.actions[0]
+    assert "高优先级" in view.actions[1]
+    assert "中优先级" in view.actions[2]
+
+
+def test_review_author_view_allows_clean_chapter_to_continue():
+    view = build_review_author_view(_payload([], summary="整体可继续"))
+
+    assert view.status == "can_continue"
+    assert view.verdict == "✅可以继续"
+    assert view.actions == ("整体可继续",)
+
+
+def test_review_author_view_render_has_author_section():
+    rendered = render_review_author_view(
+        _payload(
+            [
+                {
+                    "severity": "high",
+                    "location": "第5段",
+                    "description": "人物动机不清",
+                    "fix_hint": "补一句内心取舍",
+                }
+            ]
+        )
+    )
+
+    assert rendered.startswith("## 作者视图")
+    assert "本章结论:⚠️建议改" in rendered
+    assert "人物动机不清" in rendered

+ 2 - 0
webnovel-writer/scripts/data_modules/tests/test_webnovel_unified_cli.py

@@ -863,6 +863,8 @@ def test_review_pipeline_main_creates_output_directories(tmp_path):
     assert report_file.is_file()
     assert report_file.is_file()
     report_text = report_file.read_text(encoding="utf-8")
     report_text = report_file.read_text(encoding="utf-8")
     assert "# 第9章审查报告" in report_text
     assert "# 第9章审查报告" in report_text
+    assert "## 作者视图" in report_text
+    assert "本章结论:⚠️建议改" in report_text
     assert "小问题" in report_text
     assert "小问题" in report_text
     assert "## 其他问题" in report_text
     assert "## 其他问题" in report_text
 
 

+ 3 - 0
webnovel-writer/scripts/review_pipeline.py

@@ -25,6 +25,7 @@ def _ensure_scripts_path() -> None:
 
 
 _ensure_scripts_path()
 _ensure_scripts_path()
 
 
+from data_modules.review_author_view import render_review_author_view
 from data_modules.review_schema import append_ai_flavor_anti_patterns, parse_review_output
 from data_modules.review_schema import append_ai_flavor_anti_patterns, parse_review_output
 
 
 
 
@@ -72,6 +73,8 @@ def render_review_report(payload: Dict[str, Any]) -> str:
     lines: List[str] = [
     lines: List[str] = [
         f"# 第{payload['chapter']}章审查报告",
         f"# 第{payload['chapter']}章审查报告",
         "",
         "",
+        render_review_author_view(payload).rstrip(),
+        "",
         "## 总览",
         "## 总览",
         "",
         "",
         f"- 问题数:{result.get('issues_count', 0)}",
         f"- 问题数:{result.get('issues_count', 0)}",