فهرست منبع

fix: 重新生成时 bookScale 沿用已有值,不再让 AI 自动检测覆盖

用户原书 bookScale=12000 (认知之旅),点击'重新生成'时因描述无字数,
AI 自动检测返回 1000,导致 bookScale 被覆盖,只生成 1 章(实际应有 6 章)。

修复:优先级 body.bookScale > book.bookScale > AI 检测 > 1000,
重新生成场景直接沿用 book.bookScale,避免被误判。

新增 verify-bookscale-priority.ts 回归测试覆盖 6 个场景。
MyFramework User 2 ماه پیش
والد
کامیت
dc018e5de1

+ 11 - 9
server/src/modules/book-generator/langgraph-controller.ts

@@ -921,10 +921,11 @@ router.post('/books/:id/generate', optionalAuth, async (ctx: Context) => {
     }
 
     // ========== 确定 bookScale ==========
-    // 优先级:
+    // 优先级(避免覆盖用户已选值)
     //   1) 请求显式指定(body.bookScale)→ 用户选的,直接用
-    //   2) AI 根据描述自动检测 → LLM 判断
-    //   3) 书籍已有规模 → 沿用
+    //   2) 书籍已有规模(book.bookScale)→ 重新生成时沿用,不再让 AI 重新判断
+    //      (避免描述无明确字数时被 AI 误判为 1000,覆盖原值导致章节缩水)
+    //   3) AI 根据描述自动检测 → 仅首次生成、无任何来源时
     //   4) 兜底 → 1000(最小规模)
     let bookScale: string | undefined;
 
@@ -932,8 +933,12 @@ router.post('/books/:id/generate', optionalAuth, async (ctx: Context) => {
       // 用户在当前请求中显式选了规模
       bookScale = body.bookScale;
       console.log(`[LangGraph Generate] 使用请求指定的 bookScale=${bookScale}`);
+    } else if (book.bookScale && BOOK_TYPE_CONFIG[book.bookScale]) {
+      // 重新生成场景:沿用书籍已有规模,避免 AI 把"认知心理学"等无字数描述误判为 1000
+      bookScale = book.bookScale;
+      console.log(`[LangGraph Generate] 沿用书籍已有 bookScale=${bookScale}`);
     } else {
-      // 未显式选择 → 让 AI 根据描述判断
+      // 首次生成 + 无显式选择 → 让 AI 根据描述判断
       const desc = book.description || '';
       try {
         console.log(`[LangGraph Generate] AI 自动检测规模...`);
@@ -943,15 +948,12 @@ router.post('/books/:id/generate', optionalAuth, async (ctx: Context) => {
         console.warn(`[LangGraph Generate] AI 检测失败,回退:`, e);
       }
 
-      // AI 失败或返回无效值 → 用书籍已保存的
+      // AI 失败或返回无效值 → 兜底 1000
       if (!bookScale || !BOOK_TYPE_CONFIG[bookScale]) {
-        bookScale = book.bookScale && BOOK_TYPE_CONFIG[book.bookScale] ? book.bookScale : undefined;
+        bookScale = '1000';
       }
     }
 
-    // 最终兜底:用最小规模(1000字),避免误判生成大量无用内容
-    bookScale = bookScale && BOOK_TYPE_CONFIG[bookScale] ? bookScale : '1000';
-
     // 持久化检测结果
     if (bookScale !== book.bookScale) {
       await bookStore.update(bookId, { bookScale });

+ 120 - 0
server/src/modules/book-generator/test/verify-bookscale-priority.ts

@@ -0,0 +1,120 @@
+/**
+ * bookScale 优先级回归测试
+ *
+ * 验证 generate 端点的 bookScale 选择逻辑符合新优先级:
+ *   1) body.bookScale(用户本次显式指定)
+ *   2) book.bookScale(已有规模,重新生成时沿用,不再让 AI 覆盖)
+ *   3) AI 自动检测
+ *   4) 兜底 '1000'
+ *
+ * 用 mock 替身模拟 autoDetectBookType 与 BOOK_TYPE_CONFIG,跑四类场景。
+ */
+import { BOOK_TYPE_CONFIG } from '../book-type-config';
+
+interface Book {
+  title: string;
+  description: string;
+  bookScale: string | null | undefined;
+}
+
+/** 模拟修复后的 generate 端点 bookScale 选择逻辑(与 langgraph-controller.ts 一致) */
+async function pickBookScale(
+  body: { bookScale?: string },
+  book: Book,
+  autoDetectBookType: (title: string, description: string) => Promise<string>,
+): Promise<string> {
+  let bookScale: string | undefined;
+
+  if (body.bookScale && BOOK_TYPE_CONFIG[body.bookScale]) {
+    bookScale = body.bookScale;
+  } else if (book.bookScale && BOOK_TYPE_CONFIG[book.bookScale]) {
+    bookScale = book.bookScale;
+  } else {
+    try {
+      bookScale = await autoDetectBookType(book.title || '', book.description || '');
+    } catch {
+      /* ignore */
+    }
+    if (!bookScale || !BOOK_TYPE_CONFIG[bookScale]) {
+      bookScale = '1000';
+    }
+  }
+
+  return bookScale;
+}
+
+interface Case {
+  name: string;
+  body: { bookScale?: string };
+  book: Book;
+  mockAiReturn: string;
+  expected: string;
+}
+
+const cases: Case[] = [
+  {
+    name: 'CASE 1: body 显式指定优先于 bookScale',
+    body: { bookScale: '12000' },
+    book: { title: '认知之旅', description: '认知心理学', bookScale: '31000' },
+    mockAiReturn: '50000',
+    expected: '12000',
+  },
+  {
+    name: 'CASE 2: 重新生成(无 body),沿用 book.bookScale,不调用 AI',
+    body: {},
+    book: { title: '认知之旅', description: '认知心理学', bookScale: '12000' },
+    mockAiReturn: '1000', // 即使 AI 想返回 1000,也不该被采用
+    expected: '12000',
+  },
+  {
+    name: 'CASE 3: 首次生成(book 无 bookScale + 无 body),走 AI',
+    body: {},
+    book: { title: '新书', description: '儿童故事', bookScale: null },
+    mockAiReturn: '7000',
+    expected: '7000',
+  },
+  {
+    name: 'CASE 4: 首次生成 + AI 返回 1000(描述无字数),使用 AI 返回值',
+    body: {},
+    book: { title: '新书', description: '短文一篇', bookScale: null },
+    mockAiReturn: '1000',
+    expected: '1000',
+  },
+  {
+    name: 'CASE 5: AI 抛错 + book 无 bookScale,兜底 1000',
+    body: {},
+    book: { title: '新书', description: '描述', bookScale: null },
+    mockAiReturn: '__THROW__',
+    expected: '1000',
+  },
+  {
+    name: 'CASE 6: book.bookScale 非法值(如 99999),回退到 AI',
+    body: {},
+    book: { title: '异常', description: '描述', bookScale: '99999' },
+    mockAiReturn: '7000',
+    expected: '7000',
+  },
+];
+
+async function run() {
+  let pass = 0;
+  let fail = 0;
+  for (const c of cases) {
+    const autoDetect = async (_t: string, _d: string) => {
+      if (c.mockAiReturn === '__THROW__') throw new Error('AI 异常');
+      return c.mockAiReturn;
+    };
+    const got = await pickBookScale(c.body, c.book, autoDetect);
+    const ok = got === c.expected;
+    console.log(`${ok ? '✅' : '❌'} ${c.name}`);
+    console.log(`   期望=${c.expected}, 实际=${got}`);
+    if (ok) pass++; else fail++;
+  }
+  console.log(`\n总计 ${pass}/${pass + fail} 通过`);
+  if (fail > 0) process.exit(1);
+}
+
+run().catch(err => {
+  console.error('测试异常:', err);
+  process.exit(1);
+});