"""5 维度自评闸门:判断一稿是不是"流水账",并决定重写还是换线索。 访谈决策: - 5 个维度各 0–5 分,总分 <12 判平淡 - 不合格先重写本集一次,仍不合格才换线索 - 放弃原因写进仓库 notes/ 附录 - 设失败上限,防止「重写→换线索」空转 为什么要这道闸门:上一版章节读起来像流水账,而"平淡"是模型最容易自我感觉良好的东西。 因此评分必须结构化、维度必须问得具体,且要有一次反例自检证明它真的能判不合格。 """ from __future__ import annotations import json import re from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path from dfannals.config import MAX_TOKENS_STRUCTURED, MODEL, Gateway from dfannals.llm import chat # 维度:键、标签、问什么 DIMENSIONS: tuple[tuple[str, str, str], ...] = ( ("goal", "主角目标", "主角在本集里有没有明确的目标,并为此做出主动选择?全程被动得分低"), ("escalation", "冲突升级", "冲突是否逐级升级,而不是同一件事反复发生?"), ("turning", "真正转折", "本集有没有真正的转折(立场改变、关系破裂、意外后果)?"), ("rival", "对手具体", "对手是不是一个具体的人,有自己的动机?只是数字或人群得分低"), ("beyond_bodycount", "超越战斗计数", "抛开对战次数,本集还有没有可讲的内容(算计、情感、抉择)?"), ) THRESHOLD = 12 # 总分低于它判平淡 MAX_SCORE = 5 # 每个维度满分 EDGE_RE = re.compile(r"^```(?:json)?|```$", re.MULTILINE) # 失败上限:防止反复重写/换线空转 MAX_REWRITES = 1 # 每集最多重写次数(访谈决定) MAX_CANDIDATES = 3 # 最多尝试几条线索 @dataclass class Verdict: total: int scores: dict[str, int] reason: str @property def passed(self) -> bool: return self.total >= THRESHOLD def weak(self, limit: int = 2) -> list[str]: labels = {k: label for k, label, _ in DIMENSIONS} ranked = sorted(self.scores.items(), key=lambda kv: kv[1]) return [f"{labels.get(k, k)}({v})" for k, v in ranked[:limit]] def render(self) -> str: parts = "、".join(f"{label}={self.scores.get(key, '?')}" for key, label, _ in DIMENSIONS) verdict = "通过" if self.passed else "判平淡" return f"{verdict} 总分 {self.total}/{len(DIMENSIONS) * MAX_SCORE}({parts})|{self.reason}" DIMENSION_BLOCK = "\n".join( f'- "{key}": {hint}(0–5 分)' for key, _label, hint in DIMENSIONS ) EVAL_INSTRUCTIONS = f"""你是严格的文学编辑,只做一件事:判断下面这集稿子是不是"流水账"。 按 5 个维度各打 0–5 分: {DIMENSION_BLOCK} 打分要求: - 不要客气,不要因为文笔通顺就给分;只看叙事本身有没有戏。 - 主角全程被动、只是"发生了很多事",goal 与 escalation 必须给低分。 - 通篇只是同一类事件重复(例如反复对战、反复被杀),turning 与 beyond_bodycount 必须给低分。 只输出 JSON,不要解释、不要 Markdown 代码块: {{"goal": 0-5, "escalation": 0-5, "turning": 0-5, "rival": 0-5, "beyond_bodycount": 0-5, "reason": "一句话说明最弱的地方"}} """ def build_eval_prompt(chapter_text: str) -> str: """拼接自评提示词。 刻意不用 str.format:提示词里的 JSON 例子含大括号,用 format 会被当成占位符 (曾直接报 KeyError: '"goal"')。 """ return EVAL_INSTRUCTIONS + "\n待评稿件:\n---\n" + chapter_text + "\n---\n" def _parse(raw: str) -> dict: text = EDGE_RE.sub("", raw).strip() start, end = text.find("{"), text.rfind("}") if start < 0 or end <= start: raise ValueError(f"自评没有返回 JSON:{raw[:200]}") try: data = json.loads(text[start:end + 1]) except json.JSONDecodeError as exc: raise ValueError(f"自评返回的 JSON 无法解析:{exc}|原文:{raw[:200]}") from None if not isinstance(data, dict): raise TypeError(f"自评返回的不是对象:{type(data).__name__}") return data def evaluate( chapter_text: str, *, gateway: Gateway | None = None, model: str = MODEL, ) -> Verdict: """让模型按 5 个维度打分。返回结构化结果。""" reply = chat( [{"role": "user", "content": build_eval_prompt(chapter_text)}], gateway=gateway, model=model, max_tokens=MAX_TOKENS_STRUCTURED, temperature=0.0, ) data = _parse(reply.content) scores: dict[str, int] = {} for key, _label, _hint in DIMENSIONS: value = data.get(key) if value is None: raise ValueError(f"自评维度 {key} 缺失:{data!r}") if isinstance(value, bool) or not isinstance(value, (int, float)): raise TypeError(f"自评维度 {key} 不是数字:{value!r}") scores[key] = max(0, min(MAX_SCORE, int(value))) reason = str(data.get("reason", "")).strip() or "(未给出理由)" return Verdict(total=sum(scores.values()), scores=scores, reason=reason) @dataclass class GateRun: """一次闸门流程的完整记录。""" episode_key: str verdicts: list[Verdict] = field(default_factory=list) accepted: bool = False rewrites: int = 0 @property def final(self) -> Verdict | None: return self.verdicts[-1] if self.verdicts else None def render(self) -> str: lines = [f"闸门:{self.episode_key},重写 {self.rewrites} 次," + ("通过" if self.accepted else "未通过")] for i, v in enumerate(self.verdicts, 1): lines.append(f" 第 {i} 稿:{v.render()}") return "\n".join(lines) def gate_episode( episode_key: str, write: Callable[[int, Verdict | None], str], *, max_rewrites: int = MAX_REWRITES, evaluate_fn: Callable[[str], Verdict] = evaluate, on_event: Callable[[str], None] | None = None, ) -> tuple[str, GateRun]: """写 → 自评 → 不合格则重写一次 → 仍不合格则交给调用方换线索。 ``write(attempt, previous_verdict)``:attempt 为 0 表示初稿, 大于 0 表示重写,previous_verdict 是上一稿的评分(供重写时指出问题)。 """ run = GateRun(episode_key=episode_key) text = write(0, None) verdict = evaluate_fn(text) run.verdicts.append(verdict) while not verdict.passed and run.rewrites < max_rewrites: run.rewrites += 1 if on_event: on_event(f"{episode_key} 第 {run.rewrites} 次重写:{verdict.weak()}") text = write(run.rewrites, verdict) verdict = evaluate_fn(text) run.verdicts.append(verdict) run.accepted = verdict.passed return text, run NOTES_DIR_NAME = "notes" SWITCH_LOG = "switched-threads.md" def append_switch_note( project_dir: Path, *, world_name: str, members: list[str], span: str, verdict: Verdict | None, reason: str, ) -> Path: """把"为什么放弃这条线索"写进仓库 notes/ 附录(访谈决定:公开可查)。""" notes = project_dir / NOTES_DIR_NAME notes.mkdir(parents=True, exist_ok=True) path = notes / SWITCH_LOG header = "# 被放弃的线索\n\n记录每一组挑出来、但写不成故事的线索,以及放弃原因。\n" if not path.is_file(): path.write_text(header, encoding="utf-8") score = verdict.render() if verdict else "(未评分)" entry = ( f"\n## {world_name}|{'、'.join(members)}\n\n" f"- 线索跨度:{span}\n" f"- 放弃原因:{reason}\n" f"- 自评:{score}\n" ) with path.open("a", encoding="utf-8") as fh: fh.write(entry) return path def append_attempt_note(project_dir: Path, text: str) -> Path: """一般性记录(例如失败上限触发)。""" notes = project_dir / NOTES_DIR_NAME notes.mkdir(parents=True, exist_ok=True) path = notes / SWITCH_LOG if not path.is_file(): path.write_text("# 被放弃的线索\n\n", encoding="utf-8") with path.open("a", encoding="utf-8") as fh: fh.write(text if text.startswith("\n") else "\n" + text) return path