Procházet zdrojové kódy

fix(v13): planner-context column bugs that crippled Phase 3 inputs

Two off-by-one / exact-match bugs in planner-context.ts silently ate the
two most valuable Phase 3 inputs:

1. extractRecentEmotionalArcLines filtered row[0] as chapter number, but
   emotional_arcs.md column layout is "角色 | 章节 | 情绪状态 | ...", so
   the chapter number is at row[1]. Fix: filter row[1]. Effect: restores
   近期情感线 into current_arc_prose (was always empty in v13).

2. extractProtagonistRow matched cells with /^(主角|protagonist)$/, but
   the production convention in character_matrix.md is "主角本人". Fix:
   accept 主角本人|主角|protagonist, plus a header-aware fallback to the
   first non-header data row. Effect: planner user message finally gets
   an actual protagonist row instead of the "未找到主角行" sentinel.

Added packages/core/src/__tests__/planner-context.test.ts with 10 cases
using the real column layouts from story/ truth files, specifically
guarding against both regressions.

Test Files: 54 passed / 520 tests passed.
Ma před 6 měsíci
rodič
revize
1e1bb275f4

+ 137 - 0
packages/core/src/__tests__/planner-context.test.ts

@@ -0,0 +1,137 @@
+import { describe, expect, it } from "vitest";
+
+import {
+  composeCurrentArcProse,
+  extractCollaboratorRows,
+  extractOpponentRows,
+  extractProtagonistRow,
+  extractRelevantThreads,
+} from "../agents/planner-context.js";
+
+// Real column layouts match the production truth-file schemas under story/.
+// Keeping these literal samples in tests guards against the kind of
+// off-by-one column bug that slipped past Phase 3 review.
+const EMOTIONAL_ARCS_SAMPLE = `
+| 角色 | 章节 | 情绪状态 | 触发事件 | 强度(1-10) | 弧线方向 |
+|------|------|----------|----------|-------------|----------|
+| 周谨川 | 36 | 紧绷 | 门前对峙 | 8 | 升级 |
+| 周谨川 | 37 | 克制发热 | 逼对方亮身份 | 9 | 升级 |
+| 周谨川 | 38 | 骤亮后阴冷 | 看见顾明诚收 | 10 | 顶点 |
+`;
+
+const CHARACTER_MATRIX_SAMPLE = `
+| 角色 | 核心标签 | 反差细节 | 说话风格 | 性格底色 | 与主角关系 | 核心动机 | 当前目标 |
+|------|----------|----------|----------|----------|------------|----------|----------|
+| 周谨川 | 带伤守证者 | 起不来身却硬顶 | 短、硬 | 克制耐痛 | 主角本人 | 不让资料被收走 | 守现场 |
+| 草帽男 | 止损执行节点 | 明对手却急坏 | 装熟翻脸 | 控场欲 | 敌对监视者 | 护住七号门 | 顶住门边 |
+| 修锁老师傅 | 一线手艺证人 | 不抢话却硬护场 | 平、稳 | 老练 | 临时协力 | 看锁说锁 | 守住现场事实 |
+`;
+
+const SUBPLOT_BOARD_SAMPLE = `
+| S001 | 三担保排查 | 主角 | ch1 | ch38 | 0 | 推进 | 核心旧账线 | 1章 |
+| S007 | 货款线 | 主角 | ch3 | ch4 | 34 | 暂挂 | 冻结 | 4-6章 |
+`;
+
+describe("composeCurrentArcProse reads chapter from emotional_arcs column 1", () => {
+  it("filters rows by chapter from column index 1 (章节), not column 0 (角色)", () => {
+    // Previously the function filtered where row[0] matched /^\d+$/.
+    // Since row[0] is "周谨川" here, the old predicate produced zero matches
+    // and "近期情感线" fell out of the composed prose entirely.
+    const prose = composeCurrentArcProse(
+      SUBPLOT_BOARD_SAMPLE,
+      EMOTIONAL_ARCS_SAMPLE,
+      39,
+    );
+    expect(prose).toContain("近期情感线");
+    expect(prose).toContain("紧绷");
+    expect(prose).toContain("克制发热");
+    expect(prose).toContain("骤亮后阴冷");
+  });
+
+  it("excludes rows at or beyond the current chapter", () => {
+    const prose = composeCurrentArcProse(
+      SUBPLOT_BOARD_SAMPLE,
+      EMOTIONAL_ARCS_SAMPLE,
+      37,
+    );
+    expect(prose).toContain("紧绷");
+    expect(prose).not.toContain("克制发热");
+    expect(prose).not.toContain("骤亮后阴冷");
+  });
+
+  it("still composes active subplots when emotional arcs are empty", () => {
+    const prose = composeCurrentArcProse(SUBPLOT_BOARD_SAMPLE, "", 39);
+    expect(prose).toContain("活跃支线");
+    expect(prose).toContain("S001");
+    expect(prose).not.toContain("S007");
+  });
+
+  it("returns the empty-state sentinel only when nothing is extractable", () => {
+    const prose = composeCurrentArcProse("", "", 1);
+    expect(prose).toContain("暂无 arc 数据");
+  });
+});
+
+describe("extractProtagonistRow", () => {
+  it("matches the real convention '主角本人', not only the exact token '主角'", () => {
+    const row = extractProtagonistRow(CHARACTER_MATRIX_SAMPLE);
+    expect(row).toContain("周谨川");
+    expect(row).toContain("主角本人");
+  });
+
+  it("falls back to the first data row when no protagonist marker is found", () => {
+    const noMarker = `
+| 角色 | 核心标签 | 与主角关系 |
+|------|----------|------------|
+| 李沉 | 重生者 | — |
+| 王一 | 对手 | 敌对 |
+`;
+    const row = extractProtagonistRow(noMarker);
+    expect(row).toContain("李沉");
+  });
+
+  it("returns the sentinel only when the matrix has zero data rows", () => {
+    const empty = `
+| 角色 | 核心标签 |
+|------|----------|
+`;
+    const row = extractProtagonistRow(empty);
+    expect(row).toContain("未找到主角行");
+  });
+});
+
+describe("extractOpponentRows / extractCollaboratorRows", () => {
+  it("picks opponents by 与主角关系 semantic keywords", () => {
+    const rows = extractOpponentRows(CHARACTER_MATRIX_SAMPLE, 3);
+    expect(rows).toContain("草帽男");
+    expect(rows).not.toContain("周谨川");
+  });
+
+  it("picks collaborators by 与主角关系 semantic keywords", () => {
+    const rows = extractCollaboratorRows(CHARACTER_MATRIX_SAMPLE, 3);
+    expect(rows).toContain("修锁老师傅");
+    expect(rows).not.toContain("草帽男");
+  });
+});
+
+describe("extractRelevantThreads", () => {
+  it("selects active hooks and subplots, filtering dormant/stale", () => {
+    const hooks = `
+| hook_id | 状态 | 最近推进 |
+|---------|------|----------|
+| H001 | activating | ch38 |
+| H002 | dormant | ch20 |
+| H003 | partial_payoff | ch37 |
+`;
+    const subplots = `
+| S001 | 主线追查 | 推进 |
+| S007 | 旁线 | 暂挂 |
+`;
+    const threads = extractRelevantThreads(hooks, subplots);
+    expect(threads).toContain("H001");
+    expect(threads).toContain("H003");
+    expect(threads).not.toContain("H002");
+    expect(threads).toContain("S001");
+    expect(threads).not.toContain("S007");
+  });
+});

+ 20 - 5
packages/core/src/agents/planner-context.ts

@@ -114,25 +114,40 @@ function extractRecentEmotionalArcLines(raw: string, chapterNumber: number, limi
       .slice(-limit)
       .map((line) => line.replace(/^-\s*/, ""));
   }
+  // emotional_arcs.md column layout: 角色 | 章节 | 情绪状态 | 触发事件 | 强度 | 弧线方向
+  // Chapter number lives in column index 1 (row[1]), not column 0.
   return rows
-    .filter((row) => /^\d+$/.test(row[0] ?? ""))
-    .filter((row) => parseInt(row[0]!, 10) < chapterNumber)
+    .filter((row) => /^\d+$/.test(row[1] ?? ""))
+    .filter((row) => parseInt(row[1]!, 10) < chapterNumber)
     .slice(-limit)
     .map((row) => row.filter(Boolean).join(" | "));
 }
 
+const CHARACTER_MATRIX_HEADER_CELLS = /^(角色|character|name|核心标签|与主角关系|relation)$/i;
+
+function isLikelyHeaderRow(row: ReadonlyArray<string>): boolean {
+  return row.some((cell) => CHARACTER_MATRIX_HEADER_CELLS.test(cell.trim()));
+}
+
 /**
- * Extract the protagonist row from character_matrix.md. Protagonist is
- * detected by relation column containing "主角" or "protagonist".
+ * Extract the protagonist row from character_matrix.md. Protagonist is detected
+ * by a cell in the 与主角关系 column matching "主角本人" / "主角" / "protagonist"
+ * (case-insensitive). Falls back to the first non-header data row if no
+ * explicit match is found — that row is almost always the protagonist by
+ * convention.
  */
 export function extractProtagonistRow(characterMatrixRaw: string): string {
   const rows = parseMarkdownTableRows(characterMatrixRaw);
   const protagonist = rows.find((row) =>
-    row.some((cell) => /^(主角|protagonist)$/i.test(cell.trim())),
+    row.some((cell) => /^(主角本人|主角|protagonist)$/i.test(cell.trim())),
   );
   if (protagonist) {
     return `| ${protagonist.join(" | ")} |`;
   }
+  const firstDataRow = rows.find((row) => !isLikelyHeaderRow(row));
+  if (firstDataRow) {
+    return `| ${firstDataRow.join(" | ")} |`;
+  }
   return "(未找到主角行——请检查 character_matrix.md)";
 }