第 1 卷(人物主线):第 1–3 集 + 人物表
主角线:Guspu Frillyknots / Ral Fastenhatchets / Alath Blottedmine 由线索挖掘器自动选定,按因果链切集,经 5 维度自评闸门(≥12 分)后入库。
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user