hook-ledger-validator.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. /**
  2. * Phase 9-3: hard gate that a chapter draft actually acts on the hook ledger
  3. * the planner declared in the memo's "## 本章 hook 账" / "## Hook ledger for
  4. * this chapter" section.
  5. *
  6. * The planner commits, per chapter, to:
  7. * - advance: <hook_id> "name" → state-change
  8. * - resolve: <hook_id> "name" → action
  9. *
  10. * The validator parses those two lists and checks that every committed hook
  11. * has observable evidence in the draft. "Evidence" means the draft mentions
  12. * at least one keyword from the ledger line's descriptor (hook name, key
  13. * noun, etc.). We deliberately do NOT require the draft to repeat the raw
  14. * hook_id like "H007" — writers don't embed IDs in prose.
  15. */
  16. export interface HookLedgerViolation {
  17. readonly severity: "critical";
  18. readonly category: string;
  19. readonly description: string;
  20. readonly suggestion: string;
  21. }
  22. export interface HookLedgerEntry {
  23. readonly id: string;
  24. /** Raw text of the ledger line after the hook_id. */
  25. readonly descriptor: string;
  26. /** 2+ char CJK sequences and 3+ letter ASCII words extracted from descriptor. */
  27. readonly keywords: ReadonlyArray<string>;
  28. }
  29. export interface HookLedger {
  30. readonly open: ReadonlyArray<HookLedgerEntry>;
  31. readonly advance: ReadonlyArray<HookLedgerEntry>;
  32. readonly resolve: ReadonlyArray<HookLedgerEntry>;
  33. readonly defer: ReadonlyArray<HookLedgerEntry>;
  34. /**
  35. * Count of `[new] ...` placeholder lines in the `open:` subsection. These
  36. * are brand-new hooks declared by the planner that have no pre-existing
  37. * hook_id (extractLedgerEntry rejects them because they carry no id to
  38. * match downstream), but they still count as "a new hook opened" for the
  39. * 揭 1 埋 1 floor check.
  40. */
  41. readonly newOpenCount: number;
  42. }
  43. const LEDGER_HEADING_PATTERNS = [
  44. /^#{2,3}\s*本章\s*hook\s*账\s*$/im,
  45. /^#{2,3}\s*Hook\s+ledger\s+for\s+this\s+chapter\s*$/im,
  46. ];
  47. const SUBSECTION_KEYS: ReadonlyArray<keyof HookLedger> = ["open", "advance", "resolve", "defer"];
  48. /**
  49. * Tokens that look like hook_ids but are placeholders meaning "no hooks in
  50. * this slot". Writers sometimes emit "- 无" or "- none" under an empty slot
  51. * instead of leaving it blank.
  52. */
  53. const PLACEHOLDER_TOKENS = /^(无|空|none|nil|null|暂无|n\/a|na|n-a|tbd|todo|待定)$/i;
  54. /** Subsection heading words that must not be parsed as hook_ids. */
  55. const SUBSECTION_WORDS = /^(open|advance|resolve|defer|new)$/i;
  56. export function parseHookLedger(memoBody: string): HookLedger {
  57. const section = extractLedgerSection(memoBody);
  58. if (!section) {
  59. return { open: [], advance: [], resolve: [], defer: [], newOpenCount: 0 };
  60. }
  61. const result: Record<"open" | "advance" | "resolve" | "defer", HookLedgerEntry[]> = {
  62. open: [],
  63. advance: [],
  64. resolve: [],
  65. defer: [],
  66. };
  67. let newOpenCount = 0;
  68. let current: "open" | "advance" | "resolve" | "defer" | null = null;
  69. for (const rawLine of section.split(/\r?\n/)) {
  70. const line = rawLine.trim();
  71. if (line.length === 0) continue;
  72. const subHeadingMatch = line.match(/^(open|advance|resolve|defer)\s*[::]?\s*$/i);
  73. if (subHeadingMatch) {
  74. current = subHeadingMatch[1]!.toLowerCase() as typeof current;
  75. continue;
  76. }
  77. if (!current) continue;
  78. if (!line.startsWith("-")) continue;
  79. // `[new]` placeholder lines have no hook_id but still count as a new hook
  80. // opened (揭 1 埋 1 floor check). extractLedgerEntry filters them out for
  81. // advance/resolve evidence matching; we tally them separately here.
  82. const cleaned = line.replace(/^-+\s*/, "").trim();
  83. if (current === "open" && /^\[new\]/i.test(cleaned)) {
  84. newOpenCount += 1;
  85. continue;
  86. }
  87. const entry = extractLedgerEntry(line);
  88. if (entry) result[current].push(entry);
  89. }
  90. return { ...result, newOpenCount };
  91. }
  92. /**
  93. * Enforce: every hook declared under advance / resolve must have observable
  94. * evidence in the draft text. We do NOT validate `open` (new hooks don't have
  95. * a pre-existing id/descriptor to echo) or `defer` (deferred = deliberately
  96. * not touched).
  97. *
  98. * Additionally enforces the "揭 1 埋 1" hard floor (Xu Er Jia De Mao, 番茄文章
  99. * 10): whenever a chapter resolves one or more hooks, it must open at least
  100. * as many new hooks in the same memo. "Resolve without opening" leaves the
  101. * reader feeling "解完即索然无味" — the story loses forward pull. The softer
  102. * "揭 1 埋 2" rule is a planner-prompt recommendation, not a hard gate here,
  103. * because enforcing ×2 would conflict with the "≤ 2 new hooks per chapter"
  104. * cap on the planner side when resolve=2.
  105. */
  106. export function validateHookLedger(
  107. memoBody: string,
  108. draftContent: string,
  109. ): ReadonlyArray<HookLedgerViolation> {
  110. const ledger = parseHookLedger(memoBody);
  111. const violations: HookLedgerViolation[] = [];
  112. // Evidence check for everything the memo committed to land in prose.
  113. const committed = dedupeById([...ledger.advance, ...ledger.resolve]);
  114. for (const entry of committed) {
  115. if (!draftEchoesEntry(draftContent, entry)) {
  116. violations.push({
  117. severity: "critical",
  118. category: "hook 账未兑现",
  119. description: `memo 在 advance/resolve 里声明要处理 ${entry.id},但正文没有对应的落地动作`,
  120. suggestion: `在正文中加入对 ${entry.id} 的具体情节推进(动作、对话、环境变化),或把它从 hook 账里移到 defer 并给出理由`,
  121. });
  122. }
  123. }
  124. // "揭 1 埋 1" hard floor: when anything was resolved, at least the same
  125. // number of new hooks must have been opened. We count both `[new]`
  126. // placeholder lines (newOpenCount — the normal way planners declare fresh
  127. // hooks without an id) and any id-bearing lines under `open:` (rare, but
  128. // legal if a planner re-opens a previously paused hook).
  129. const resolvedCount = ledger.resolve.length;
  130. const openedCount = ledger.open.length + ledger.newOpenCount;
  131. if (resolvedCount > 0 && openedCount < resolvedCount) {
  132. violations.push({
  133. severity: "critical",
  134. category: "hook 账揭 1 埋 1 违规",
  135. description: `本章 resolve 了 ${resolvedCount} 个钩子,但 open 只有 ${openedCount} 个新钩子。只揭不埋会让读者豁然开朗后索然无味,本书的前进拉力被削弱。`,
  136. suggestion: `在 memo 的 open 段下至少再埋 ${resolvedCount - openedCount} 个与本章已揭钩子相关的新钩子(番茄老师徐二家的猫:"掀开一个伏笔的同时,再埋两个伏笔")。新钩子最好与已揭钩子彼此关联,不要凭空冒出来。`,
  137. });
  138. }
  139. return violations;
  140. }
  141. function extractLedgerSection(memoBody: string): string | undefined {
  142. for (const pattern of LEDGER_HEADING_PATTERNS) {
  143. const match = memoBody.match(pattern);
  144. if (!match || match.index === undefined) continue;
  145. const start = match.index + match[0].length;
  146. const rest = memoBody.slice(start);
  147. const nextHeading = rest.match(/\n#{2,3}\s/);
  148. const end = nextHeading ? nextHeading.index ?? rest.length : rest.length;
  149. return rest.slice(0, end);
  150. }
  151. return undefined;
  152. }
  153. function extractLedgerEntry(line: string): HookLedgerEntry | undefined {
  154. const cleaned = line.replace(/^-+\s*/, "").trim();
  155. if (cleaned.startsWith("[new]") || cleaned.startsWith("[NEW]")) return undefined;
  156. // Reject whole-line placeholders first — "- 无", "- n/a", "- none" etc.
  157. const firstWord = cleaned.split(/\s+/)[0] ?? "";
  158. if (PLACEHOLDER_TOKENS.test(firstWord)) return undefined;
  159. const idMatch = cleaned.match(/^([A-Za-z\u4e00-\u9fff][A-Za-z0-9_\-\u4e00-\u9fff]{0,19})/);
  160. if (!idMatch) return undefined;
  161. const candidate = idMatch[1]!;
  162. if (SUBSECTION_WORDS.test(candidate)) return undefined;
  163. if (PLACEHOLDER_TOKENS.test(candidate)) return undefined;
  164. const descriptor = cleaned.slice(candidate.length).trim();
  165. return { id: candidate, descriptor, keywords: extractKeywords(descriptor) };
  166. }
  167. /**
  168. * Extract content-matching tokens from a ledger line's descriptor.
  169. *
  170. * Priority 1: quoted hook name — `H007 "胖虎借条" → ...` — this is the most
  171. * informative token the planner attached, and it's what the writer should
  172. * echo. We split compound CJK names into leading/trailing 2-grams so
  173. * partial echoes still count.
  174. *
  175. * Priority 2: if no quoted name, fall back to the descriptor text UP TO the
  176. * first state-transition arrow (→ or ->), same CJK/ASCII splitting. Anything
  177. * AFTER the arrow describes new state, not the hook itself, and risks
  178. * character-name false positives.
  179. */
  180. function extractKeywords(descriptor: string): ReadonlyArray<string> {
  181. if (!descriptor) return [];
  182. // Try the quoted-name anchor first — matches "..." or "..." quotes.
  183. const quotedMatch = descriptor.match(/[""]([^""\n]+)[""]/);
  184. const source = quotedMatch ? quotedMatch[1]! : descriptor.split(/[→]|->/, 1)[0]!;
  185. const cjkRuns = source.match(/[\u4e00-\u9fff]{2,}/g) ?? [];
  186. const cjkTokens: string[] = [];
  187. for (const run of cjkRuns) {
  188. cjkTokens.push(run);
  189. if (run.length >= 3) {
  190. for (let index = 0; index <= run.length - 2; index++) {
  191. cjkTokens.push(run.slice(index, index + 2));
  192. }
  193. }
  194. if (run.length >= 4) {
  195. cjkTokens.push(run.slice(0, 3));
  196. cjkTokens.push(run.slice(-3));
  197. }
  198. }
  199. const ascii = (source.match(/[A-Za-z]{3,}/g) ?? []).map((w) => w.toLowerCase());
  200. return dedupeStrings([...cjkTokens, ...ascii].filter((tok) => !ASCII_STOPWORDS.has(tok)));
  201. }
  202. const ASCII_STOPWORDS = new Set([
  203. "and", "the", "for", "with", "from", "that", "into", "then",
  204. "open", "close", "advance", "resolve", "defer", "new",
  205. "planted", "pressured", "near", "payoff", "ready", "stale",
  206. ]);
  207. function draftEchoesEntry(draft: string, entry: HookLedgerEntry): boolean {
  208. if (entry.keywords.length > 0) {
  209. const draftLower = draft.toLowerCase();
  210. return entry.keywords.some((kw) => {
  211. // ASCII keywords are already lowercased; CJK keywords case doesn't matter.
  212. return /^[a-z]/.test(kw) ? draftLower.includes(kw) : draft.includes(kw);
  213. });
  214. }
  215. // Bare-id ledger line with no descriptor — fall back to ID match.
  216. if (/^[A-Za-z0-9_-]+$/.test(entry.id)) {
  217. return new RegExp(`\\b${escapeRegex(entry.id)}\\b`).test(draft);
  218. }
  219. return draft.includes(entry.id);
  220. }
  221. function dedupeById(entries: ReadonlyArray<HookLedgerEntry>): HookLedgerEntry[] {
  222. const seen = new Set<string>();
  223. const result: HookLedgerEntry[] = [];
  224. for (const entry of entries) {
  225. if (seen.has(entry.id)) continue;
  226. seen.add(entry.id);
  227. result.push(entry);
  228. }
  229. return result;
  230. }
  231. function dedupeStrings(values: ReadonlyArray<string>): string[] {
  232. return [...new Set(values)];
  233. }
  234. function escapeRegex(value: string): string {
  235. return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  236. }
  237. export const INTERNAL = {
  238. SUBSECTION_KEYS,
  239. extractLedgerSection,
  240. extractLedgerEntry,
  241. extractKeywords,
  242. };