第 1 卷(人物主线):第 1–3 集 + 人物表

主角线:Guspu Frillyknots / Ral Fastenhatchets / Alath Blottedmine
由线索挖掘器自动选定,按因果链切集,经 5 维度自评闸门(≥12 分)后入库。
This commit is contained in:
Chen Yi
2026-10-05 19:28:49 +08:00
parent 594cf48a1a
commit e0073e90bd
24 changed files with 2070 additions and 79 deletions
+223
View File
@@ -0,0 +1,223 @@
"""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