Files
dwarf-fortress-annals/dfannals/gate.py
T
Chen Yi e0073e90bd 第 1 卷(人物主线):第 1–3 集 + 人物表
主角线:Guspu Frillyknots / Ral Fastenhatchets / Alath Blottedmine
由线索挖掘器自动选定,按因果链切集,经 5 维度自评闸门(≥12 分)后入库。
2026-10-05 19:28:49 +08:00

224 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 ValueError(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 not isinstance(value, (int, float)):
raise ValueError(f"自评维度 {key} 缺失或非数字:{data.get(key)!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