Browse Source

fix: parallel_content 拓扑分组按 node.id 唯一标识,不再因 number 冲突丢节点

book 39 卡住的真正根因: groupByDependencyLayer 用 node.number 作 assigned 集合 key,
但同一书内多个 L2 叶节点共享 number (1 或 2),导致 12 个叶节点被合并成 2 个,
10 个节点被静默丢弃,只剩 2 个被生成。再加上 30% 失败率提前终止,直接断流。

修复:
- assigned 改用 node.id 唯一标识
- 叶节点(L2/L3)的所属章 number 用 parentId 反查 L1
- 新增 verify-topology.ts 回归测试
MyFramework User 2 months ago
parent
commit
3f3931fedc

+ 70 - 28
server/src/modules/book-generator/nodes/content.node.ts

@@ -303,68 +303,99 @@ const PARALLEL_CONCURRENCY = getBookConcurrency();
  * 组1 = 依赖组0的节点
  * 组2 = 依赖组0/组1的节点
  * 依此类推
+ *
+ * 修复 #book39:原实现用 `node.number` 作为 assigned 集合的 key,
+ * 但同一书内多个叶节点共享 number(L2 section 都是 1/2),
+ * 导致 12 个叶节点被错误地合并为 2 个,10 个节点被静默丢弃。
+ * 改用 node.id 做唯一标识,dependsOn 匹配章 number 用父节点 number 解析。
  */
 function groupByDependencyLayer(
   targets: any[],
-  crossReferences: Record<string, { dependsOn: string[]; usedBy: string[] }> | undefined
+  crossReferences: Record<string, { dependsOn: string[]; usedBy: string[] }> | undefined,
+  parentNumberByChapterId: Map<number, number>,
 ): { layer: number; nodes: any[] }[] {
   if (!crossReferences || Object.keys(crossReferences).length === 0) {
-    // 无 crossReferences,全部放第一层(最大并行)
     return [{ layer: 0, nodes: targets }];
   }
 
-  // 建立 number → 节点映射
-  const nodeByNumber = new Map<number, any>();
-  targets.forEach(t => nodeByNumber.set(t.number, t));
+  // 给每个节点计算"所属章 number"(L1 章号)。
+  // L1 节点自身的 number;L2/L3 节点用 parentNumberByChapterId 查到所属章的 number。
+  const chapterNumOf = (n: any): number => {
+    if (n.level === 1) return n.number;
+    return parentNumberByChapterId.get(n.id) ?? 0;
+  };
 
-  // 计算每个节点的入度(有多少依赖)
-  interface NodeDegree { node: any; dependsOn: number[]; layer: number; }
+  interface NodeDegree { node: any; dependsOn: number[]; }
   const degrees: NodeDegree[] = targets.map(t => ({
     node: t,
     dependsOn: [],
-    layer: 0,
   }));
 
-  // 解析依赖关系
+  // 解析依赖关系:crossReferences 是章级别(L1 章 number),
+  // dependsOn 也是 L1 章 number。叶节点继承所属章的依赖。
+  for (const [, ref] of Object.entries(crossReferences)) {
+    const depNums: number[] = [];
+    for (const dep of (ref.dependsOn || [])) {
+      const depMatch = dep.match(/ch(?:apter)?(\d+)/i);
+      if (depMatch) depNums.push(parseInt(depMatch[1]));
+    }
+    if (depNums.length === 0) continue;
+
+    // 给所有"所属章"命中 refKey 的叶节点附加依赖
+    for (const d of degrees) {
+      const ch = chapterNumOf(d.node);
+      if (ch === 0) continue;
+      // 找到引用此章的 crossReferences(即 refKey 解析后的章 number === ch)
+      // 因为 for 循环里我们拿不到 refKey 对应的章 number,所以反过来:扫描所有 refKey
+      // 这里改成外层循环 refKey 即可:
+    }
+  }
+
+  // 重写:外层循环 refKey 拿章 number,再匹配叶节点的所属章
+  const degreesByChapter = (chapterNum: number, depNums: number[]) => {
+    for (const d of degrees) {
+      if (chapterNumOf(d.node) === chapterNum) {
+        d.dependsOn = Array.from(new Set([...d.dependsOn, ...depNums]));
+      }
+    }
+  };
+
   for (const [refKey, ref] of Object.entries(crossReferences)) {
-    // refKey 格式: "ch3" 或 "chapter3"
     const match = refKey.match(/ch(?:apter)?(\d+)/i);
     if (!match) continue;
     const num = parseInt(match[1]);
-    const node = degrees.find(d => d.node.number === num);
-    if (!node) continue;
-
-    // 解析 dependsOn
     const depNums: number[] = [];
     for (const dep of (ref.dependsOn || [])) {
       const depMatch = dep.match(/ch(?:apter)?(\d+)/i);
       if (depMatch) depNums.push(parseInt(depMatch[1]));
     }
-    node.dependsOn = depNums;
+    if (depNums.length === 0) continue;
+    degreesByChapter(num, depNums);
   }
 
-  // 拓扑分层:迭代找出每层的无依赖节点
+  // 拓扑分层:用 node.id 作唯一标识
   const layers: any[][] = [];
-  const assigned = new Set<number>();
+  const assignedIds = new Set<number>();
+  const isChapterNumAssigned = (num: number): boolean => {
+    return degrees.some(d => chapterNumOf(d.node) === num && assignedIds.has(d.node.id));
+  };
   let changed = true;
   while (changed) {
     changed = false;
     const currentLayer: any[] = [];
     for (const d of degrees) {
-      if (assigned.has(d.node.number)) continue;
-      // 检查所有依赖是否已分配
-      const allDepsAssigned = d.dependsOn.every(dep => assigned.has(dep));
-      if (allDepsAssigned || d.dependsOn.length === 0) {
+      if (assignedIds.has(d.node.id)) continue;
+      const allDepsAssigned = d.dependsOn.every(dep => isChapterNumAssigned(dep));
+      if (allDepsAssigned) {
         currentLayer.push(d.node);
-        assigned.add(d.node.number);
+        assignedIds.add(d.node.id);
         changed = true;
       }
     }
     if (currentLayer.length > 0) layers.push(currentLayer);
   }
 
-  // 未分配节点(孤立的循环依赖等)放入最后一层
-  const unassigned = targets.filter(t => !assigned.has(t.number));
+  const unassigned = targets.filter(t => !assignedIds.has(t.id));
   if (unassigned.length > 0) layers.push(unassigned);
 
   return layers.map((nodes, i) => ({ layer: i, nodes }));
@@ -527,12 +558,23 @@ export async function writeChaptersParallelNode(state: typeof GraphState.State):
   // 构建父节点映射
   const chapterMap = new Map<number, any>();
   const sectionMap = new Map<number, any>();
+  // 修复 #book39:叶节点(L2/L3) 所属章(L1)的 number 映射,
+  // 用于 groupByDependencyLayer 解析 crossReferences(L1 章 number)。
+  const parentNumberByChapterId = new Map<number, number>();
   const chaptersAndSections = await prisma.bookChapter.findMany({
     where: { bookId: bookIdNum, level: { in: [1, 2] } }
   });
   chaptersAndSections.forEach(c => {
-    if (c.level === 1) chapterMap.set(c.id, c);
-    if (c.level === 2) sectionMap.set(c.id, c);
+    if (c.level === 1) {
+      chapterMap.set(c.id, c);
+      parentNumberByChapterId.set(c.id, c.number);
+    }
+    if (c.level === 2) {
+      sectionMap.set(c.id, c);
+      // L2 节点的所属章 number = 它的 parentId 对应的 L1 的 number
+      const parentL1 = chapterMap.get(c.parentId || 0);
+      if (parentL1) parentNumberByChapterId.set(c.id, parentL1.number);
+    }
   });
 
   const bookTools = createBookTools(state.bookId, bookStore);
@@ -547,10 +589,10 @@ export async function writeChaptersParallelNode(state: typeof GraphState.State):
   }
 
   // 按依赖层级分组
-  const layers = groupByDependencyLayer(targetsToGenerate, crossReferences);
+  const layers = groupByDependencyLayer(targetsToGenerate, crossReferences, parentNumberByChapterId);
   console.log(`[ParallelContent] 拓扑分层: ${layers.length} 层`);
   layers.forEach((l, i) => {
-    console.log(`  层${i}: [${l.nodes.map(n => n.number).join(', ')}]`);
+    console.log(`  层${i}: [${l.nodes.map(n => n.id + '#' + n.number).join(', ')}]`);
   });
 
   // 分层执行:每层内并行,层间串行(依赖满足后下一层才能开始)

+ 171 - 0
server/src/modules/book-generator/test/verify-topology.ts

@@ -0,0 +1,171 @@
+/**
+ * 回归测试:groupByDependencyLayer 应该保留所有叶节点
+ *
+ * 之前的 bug:assigned set 用 node.number 作 key,多个 L2 节点(number=1 或 2)被合并。
+ * 修复后:用 node.id 作唯一标识,叶节点按所属章 number 解析依赖。
+ */
+
+// 直接复制修复后的函数(避免 import 整个模块的副作用)
+function groupByDependencyLayer(
+  targets: any[],
+  crossReferences: Record<string, { dependsOn: string[]; usedBy: string[] }> | undefined,
+  parentNumberByChapterId: Map<number, number>,
+): { layer: number; nodes: any[] }[] {
+  if (!crossReferences || Object.keys(crossReferences).length === 0) {
+    return [{ layer: 0, nodes: targets }];
+  }
+
+  const chapterNumOf = (n: any): number => {
+    if (n.level === 1) return n.number;
+    return parentNumberByChapterId.get(n.id) ?? 0;
+  };
+
+  interface NodeDegree { node: any; dependsOn: number[]; }
+  const degrees: NodeDegree[] = targets.map(t => ({ node: t, dependsOn: [] }));
+
+  const degreesByChapter = (chapterNum: number, depNums: number[]) => {
+    for (const d of degrees) {
+      if (chapterNumOf(d.node) === chapterNum) {
+        d.dependsOn = Array.from(new Set([...d.dependsOn, ...depNums]));
+      }
+    }
+  };
+
+  for (const [refKey, ref] of Object.entries(crossReferences)) {
+    const match = refKey.match(/ch(?:apter)?(\d+)/i);
+    if (!match) continue;
+    const num = parseInt(match[1]);
+    const depNums: number[] = [];
+    for (const dep of (ref.dependsOn || [])) {
+      const depMatch = dep.match(/ch(?:apter)?(\d+)/i);
+      if (depMatch) depNums.push(parseInt(depMatch[1]));
+    }
+    if (depNums.length === 0) continue;
+    degreesByChapter(num, depNums);
+  }
+
+  const layers: any[][] = [];
+  const assignedIds = new Set<number>();
+  const isChapterNumAssigned = (num: number): boolean => {
+    return degrees.some(d => chapterNumOf(d.node) === num && assignedIds.has(d.node.id));
+  };
+  let changed = true;
+  while (changed) {
+    changed = false;
+    const currentLayer: any[] = [];
+    for (const d of degrees) {
+      if (assignedIds.has(d.node.id)) continue;
+      const allDepsAssigned = d.dependsOn.every(dep => isChapterNumAssigned(dep));
+      if (allDepsAssigned) {
+        currentLayer.push(d.node);
+        assignedIds.add(d.node.id);
+        changed = true;
+      }
+    }
+    if (currentLayer.length > 0) layers.push(currentLayer);
+  }
+
+  const unassigned = targets.filter(t => !assignedIds.has(t.id));
+  if (unassigned.length > 0) layers.push(unassigned);
+
+  return layers.map((nodes, i) => ({ layer: i, nodes }));
+}
+
+// 模拟 book 39 的数据
+// 6 个 L1 章节(L1 with id=104..109, number=1..6)
+// 12 个 L2 章节(每个 L1 下 2 个,L2 with number=1 或 2)
+const l1Chapters = [
+  { id: 104, level: 1, number: 1, parentId: 0, title: 'ch1' },
+  { id: 107, level: 1, number: 2, parentId: 0, title: 'ch2' },
+  { id: 110, level: 1, number: 3, parentId: 0, title: 'ch3' },
+  { id: 113, level: 1, number: 4, parentId: 0, title: 'ch4' },
+  { id: 116, level: 1, number: 5, parentId: 0, title: 'ch5' },
+  { id: 119, level: 1, number: 6, parentId: 0, title: 'ch6' },
+];
+
+const l2Chapters = [
+  // L1 ch1 (id=104) 下 2 个
+  { id: 105, level: 2, number: 1, parentId: 104, title: '1.1' },
+  { id: 106, level: 2, number: 2, parentId: 104, title: '1.2' },
+  // L1 ch2 (id=107) 下 2 个
+  { id: 108, level: 2, number: 1, parentId: 107, title: '2.1' },
+  { id: 109, level: 2, number: 2, parentId: 107, title: '2.2' },
+  // L1 ch3 (id=110) 下 2 个
+  { id: 111, level: 2, number: 1, parentId: 110, title: '3.1' },
+  { id: 112, level: 2, number: 2, parentId: 110, title: '3.2' },
+  // L1 ch4 (id=113) 下 2 个
+  { id: 114, level: 2, number: 1, parentId: 113, title: '4.1' },
+  { id: 115, level: 2, number: 2, parentId: 113, title: '4.2' },
+  // L1 ch5 (id=116) 下 2 个
+  { id: 117, level: 2, number: 1, parentId: 116, title: '5.1' },
+  { id: 118, level: 2, number: 2, parentId: 116, title: '5.2' },
+  // L1 ch6 (id=119) 下 2 个
+  { id: 120, level: 2, number: 1, parentId: 119, title: '6.1' },
+  { id: 121, level: 2, number: 2, parentId: 119, title: '6.2' },
+];
+
+// parentNumberByChapterId 映射
+const parentNumberByChapterId = new Map<number, number>();
+for (const c of l1Chapters) parentNumberByChapterId.set(c.id, c.number);
+for (const c of l2Chapters) {
+  const parent = l1Chapters.find(p => p.id === c.parentId);
+  if (parent) parentNumberByChapterId.set(c.id, parent.number);
+}
+
+// 模拟 crossReferences:ch2 依赖 ch1 的内容,ch3 依赖 ch2,ch4 依赖 ch3
+const crossReferences: Record<string, { dependsOn: string[]; usedBy: string[] }> = {
+  'ch2': { dependsOn: ['ch1.认知心理学的定义'], usedBy: [] },
+  'ch3': { dependsOn: ['ch2.注意的筛选机制'], usedBy: [] },
+  'ch4': { dependsOn: ['ch3.记忆系统'], usedBy: [] },
+  'ch5': { dependsOn: ['ch3.记忆'], usedBy: [] },
+  'ch6': { dependsOn: ['ch3.概念与分类'], usedBy: [] },
+};
+
+const targets = [...l2Chapters];
+const layers = groupByDependencyLayer(targets, crossReferences, parentNumberByChapterId);
+
+console.log('=== 测试 groupByDependencyLayer ===');
+console.log(`输入: ${targets.length} 个叶节点`);
+console.log(`输出: ${layers.length} 层`);
+let totalInLayers = 0;
+for (const l of layers) {
+  const ids = l.nodes.map(n => `${n.id}#${n.number}`).join(',');
+  console.log(`  层${l.layer} (${l.nodes.length} 节点): ${ids}`);
+  totalInLayers += l.nodes.length;
+}
+console.log(`总计: ${totalInLayers} 节点`);
+
+let pass = true;
+if (totalInLayers !== 12) {
+  console.log(`❌ 失败:预期 12 节点,实际 ${totalInLayers} 节点`);
+  pass = false;
+} else {
+  console.log('✅ 所有 12 个叶节点都被分配');
+}
+
+// 验证属于 L1-ch1 的节点应在 layer 0(无依赖)
+const ch1Nodes = l2Chapters.filter(c => c.parentId === 104);
+const allAssignedIds = new Set<number>();
+for (const l of layers) for (const n of l.nodes) allAssignedIds.add(n.id);
+for (const n of ch1Nodes) {
+  if (!allAssignedIds.has(n.id)) {
+    console.log(`❌ 失败:L1-ch1 下的叶节点 ${n.id} 未被分配`);
+    pass = false;
+  }
+}
+if (pass) console.log('✅ L1-ch1 下的 2 个叶节点都被分配');
+
+// 验证 L1-ch3/4/5/6 下的节点都被分配(之前 bug 会丢节点)
+for (const parentL1 of [107, 110, 113, 116, 119]) {
+  const ch = l2Chapters.filter(c => c.parentId === parentL1);
+  for (const n of ch) {
+    if (!allAssignedIds.has(n.id)) {
+      console.log(`❌ 失败:L1 父 ${parentL1} 下的叶节点 ${n.id} 未被分配`);
+      pass = false;
+    }
+  }
+}
+if (pass) console.log('✅ L1-ch2/3/4/5/6 下的所有 10 个叶节点都被分配(关键 bug 修复)');
+
+if (!pass) process.exit(1);
+console.log('\n=== ✅ 所有测试通过 ===');