- 移除 output/volumes/v1/chapters/001-1-9.md 与 002-10-19.md(仅普通提交,git 历史保留) - 移除被取代的写作规范 prompts/chronicler.md 与旧的按年代切章模块 - README 全面改写为人物主线流程、参数位置与踩坑记录 - scripts/dfannals 启动器:自动挑一个装了 defusedxml 的解释器
226 lines
8.2 KiB
Python
226 lines
8.2 KiB
Python
"""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
|