chapter-memo-parser.ts 2.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  1. import YAML from "js-yaml";
  2. import { ChapterMemoSchema, type ChapterMemo } from "../models/input-governance.js";
  3. export class PlannerParseError extends Error {
  4. constructor(message: string) {
  5. super(message);
  6. this.name = "PlannerParseError";
  7. }
  8. }
  9. const REQUIRED_SECTIONS: ReadonlyArray<string> = [
  10. "## 当前任务",
  11. "## 读者此刻在等什么",
  12. "## 该兑现的 / 暂不掀的",
  13. "## 日常/过渡承担什么任务",
  14. "## 关键抉择过三连问",
  15. "## 章尾必须发生的改变",
  16. "## 不要做",
  17. ];
  18. /**
  19. * Parse a planner memo produced by the LLM.
  20. *
  21. * Format: YAML frontmatter delimited by `---\n...\n---\n` followed by a
  22. * markdown body containing the seven required section headings.
  23. *
  24. * Strict on core fields (chapter integer + matches expected, goal non-empty
  25. * and ≤ 50 chars, required section headings present). Lenient on aux fields
  26. * (threadRefs coerced to string[], defaults to []).
  27. *
  28. * `isGoldenOpening` is authoritative from the caller — any value the LLM
  29. * includes in the frontmatter is ignored.
  30. */
  31. export function parseMemo(
  32. raw: string,
  33. expectedChapter: number,
  34. isGoldenOpening: boolean,
  35. ): ChapterMemo {
  36. const trimmed = raw.trim();
  37. const match = trimmed.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/);
  38. if (!match) {
  39. throw new PlannerParseError("missing YAML frontmatter delimiters");
  40. }
  41. const yamlText = match[1]!;
  42. const body = match[2]!.trim();
  43. let fm: unknown;
  44. try {
  45. fm = YAML.load(yamlText);
  46. } catch (error) {
  47. const message = error instanceof Error ? error.message : String(error);
  48. throw new PlannerParseError(`invalid YAML in frontmatter: ${message}`);
  49. }
  50. if (!fm || typeof fm !== "object" || Array.isArray(fm)) {
  51. throw new PlannerParseError("frontmatter is not an object");
  52. }
  53. const f = fm as Record<string, unknown>;
  54. if (typeof f.chapter !== "number" || !Number.isInteger(f.chapter)) {
  55. throw new PlannerParseError("chapter must be an integer");
  56. }
  57. if (f.chapter !== expectedChapter) {
  58. throw new PlannerParseError(
  59. `chapter mismatch: expected ${expectedChapter}, got ${f.chapter}`,
  60. );
  61. }
  62. if (typeof f.goal !== "string" || f.goal.length === 0) {
  63. throw new PlannerParseError("goal must be a non-empty string");
  64. }
  65. if (f.goal.length > 50) {
  66. throw new PlannerParseError(
  67. `goal too long: ${f.goal.length} chars (max 50)`,
  68. );
  69. }
  70. const missing = REQUIRED_SECTIONS.filter((heading) => !body.includes(heading));
  71. if (missing.length > 0) {
  72. throw new PlannerParseError(`missing sections: ${missing.join(", ")}`);
  73. }
  74. const threadRefs = Array.isArray(f.threadRefs)
  75. ? f.threadRefs.filter((value): value is string => typeof value === "string")
  76. : [];
  77. return ChapterMemoSchema.parse({
  78. chapter: f.chapter,
  79. goal: f.goal,
  80. isGoldenOpening,
  81. body,
  82. threadRefs,
  83. });
  84. }