Przeglądaj źródła

feat: implement 10 quality & automation enhancements

1. Sensitive word detection (dim 27, built-in Chinese word lists)
2. Multi-model routing (modelOverrides per agent in config)
3. Temperature retry fix (scheduler now applies retryTemperatureStep)
4. Post-write constraint validation (regex check for hard prohibitions)
5. Failure clustering + diagnostic alerts (dimension-based tracking)
6. Auto-detection loop in daemon (detect → anti-detect after audit pass)
7. Character dialogue fingerprinting (speaker style in writer context)
8. Lightweight context retrieval (entity-based summary matching)
9. Analytics command (inkos analytics with audit stats)
10. Spot-fix revise mode (targeted minimal revisions)
majx_mac 8 miesięcy temu
rodzic
commit
a2c923bd91

+ 136 - 0
packages/cli/src/commands/analytics.ts

@@ -0,0 +1,136 @@
+import { Command } from "commander";
+import { StateManager } from "@actalk/inkos-core";
+import { loadConfig, findProjectRoot, resolveBookId, log, logError } from "../utils.js";
+
+interface AnalyticsData {
+  readonly bookId: string;
+  readonly totalChapters: number;
+  readonly totalWords: number;
+  readonly avgWordsPerChapter: number;
+  readonly auditPassRate: number;
+  readonly topIssueCategories: ReadonlyArray<{ readonly category: string; readonly count: number }>;
+  readonly chaptersWithMostIssues: ReadonlyArray<{ readonly chapter: number; readonly issueCount: number }>;
+  readonly statusDistribution: Record<string, number>;
+}
+
+function computeAnalytics(
+  bookId: string,
+  chapters: ReadonlyArray<{
+    readonly number: number;
+    readonly status: string;
+    readonly wordCount: number;
+    readonly auditIssues: ReadonlyArray<string>;
+  }>,
+): AnalyticsData {
+  const totalChapters = chapters.length;
+  const totalWords = chapters.reduce((sum, ch) => sum + ch.wordCount, 0);
+  const avgWordsPerChapter = totalChapters > 0 ? Math.round(totalWords / totalChapters) : 0;
+
+  // Audit pass rate: chapters that went to ready-for-review or approved on first try
+  const passedStatuses = new Set(["ready-for-review", "approved", "published"]);
+  const auditedChapters = chapters.filter(
+    (ch) => ch.status !== "drafted" && ch.status !== "drafting" && ch.status !== "card-generated",
+  );
+  const passedChapters = auditedChapters.filter((ch) => passedStatuses.has(ch.status));
+  const auditPassRate = auditedChapters.length > 0
+    ? Math.round((passedChapters.length / auditedChapters.length) * 100)
+    : 100;
+
+  // Issue category extraction
+  const categoryCounts = new Map<string, number>();
+  for (const ch of chapters) {
+    for (const issue of ch.auditIssues) {
+      // Issue format: "[severity] description"
+      // Try to extract category from the description
+      const catMatch = issue.match(/\[(?:critical|warning|info)\]\s*(.+?)[::]/);
+      const category = catMatch?.[1] ?? "未分类";
+      categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
+    }
+  }
+  const topIssueCategories = [...categoryCounts.entries()]
+    .sort((a, b) => b[1] - a[1])
+    .slice(0, 10)
+    .map(([category, count]) => ({ category, count }));
+
+  // Chapters with most issues
+  const chaptersWithMostIssues = [...chapters]
+    .filter((ch) => ch.auditIssues.length > 0)
+    .sort((a, b) => b.auditIssues.length - a.auditIssues.length)
+    .slice(0, 5)
+    .map((ch) => ({ chapter: ch.number, issueCount: ch.auditIssues.length }));
+
+  // Status distribution
+  const statusDistribution: Record<string, number> = {};
+  for (const ch of chapters) {
+    statusDistribution[ch.status] = (statusDistribution[ch.status] ?? 0) + 1;
+  }
+
+  return {
+    bookId,
+    totalChapters,
+    totalWords,
+    avgWordsPerChapter,
+    auditPassRate,
+    topIssueCategories,
+    chaptersWithMostIssues,
+    statusDistribution,
+  };
+}
+
+export const analyticsCommand = new Command("analytics")
+  .description("Show analytics for a book")
+  .argument("[book-id]", "Book ID (auto-detected if only one book)")
+  .option("--json", "Output JSON")
+  .action(async (bookIdArg: string | undefined, opts) => {
+    try {
+      await loadConfig();
+      const root = findProjectRoot();
+      const bookId = await resolveBookId(bookIdArg, root);
+      const state = new StateManager(root);
+      const chapters = await state.loadChapterIndex(bookId);
+
+      const analytics = computeAnalytics(bookId, chapters);
+
+      if (opts.json) {
+        log(JSON.stringify(analytics, null, 2));
+      } else {
+        log(`Analytics for "${bookId}":`);
+        log("");
+        log(`  Total chapters: ${analytics.totalChapters}`);
+        log(`  Total words: ${analytics.totalWords.toLocaleString()}`);
+        log(`  Avg words/chapter: ${analytics.avgWordsPerChapter.toLocaleString()}`);
+        log(`  Audit pass rate: ${analytics.auditPassRate}%`);
+        log("");
+
+        if (Object.keys(analytics.statusDistribution).length > 0) {
+          log("  Status distribution:");
+          for (const [status, count] of Object.entries(analytics.statusDistribution)) {
+            log(`    ${status}: ${count}`);
+          }
+          log("");
+        }
+
+        if (analytics.topIssueCategories.length > 0) {
+          log("  Most common issue categories:");
+          for (const { category, count } of analytics.topIssueCategories) {
+            log(`    ${category}: ${count}`);
+          }
+          log("");
+        }
+
+        if (analytics.chaptersWithMostIssues.length > 0) {
+          log("  Chapters with most issues:");
+          for (const { chapter, issueCount } of analytics.chaptersWithMostIssues) {
+            log(`    Ch.${chapter}: ${issueCount} issues`);
+          }
+        }
+      }
+    } catch (e) {
+      if (opts.json) {
+        log(JSON.stringify({ error: String(e) }));
+      } else {
+        logError(`Analytics failed: ${e}`);
+      }
+      process.exit(1);
+    }
+  });

+ 1 - 0
packages/cli/src/commands/daemon.ts

@@ -38,6 +38,7 @@ export const upCommand = new Command("up")
         model: config.llm.model,
         projectRoot: root,
         notifyChannels: config.notify,
+        modelOverrides: config.modelOverrides,
         radarCron: config.daemon.schedule.radarCron,
         writeCron: config.daemon.schedule.writeCron,
         auditCron: config.daemon.schedule.auditCron,

+ 1 - 1
packages/cli/src/commands/revise.ts

@@ -6,7 +6,7 @@ export const reviseCommand = new Command("revise")
   .description("Revise a chapter based on audit issues")
   .argument("[book-id]", "Book ID (auto-detected if only one book)")
   .argument("[chapter]", "Chapter number (defaults to latest)")
-  .option("--mode <mode>", "Revise mode: polish, rewrite, rework", "rewrite")
+  .option("--mode <mode>", "Revise mode: polish, rewrite, rework, spot-fix", "rewrite")
   .option("--json", "Output JSON")
   .action(async (bookIdArg: string | undefined, chapterStr: string | undefined, opts) => {
     try {

+ 2 - 0
packages/cli/src/index.ts

@@ -19,6 +19,7 @@ import { genreCommand } from "./commands/genre.js";
 import { updateCommand } from "./commands/update.js";
 import { detectCommand } from "./commands/detect.js";
 import { styleCommand } from "./commands/style.js";
+import { analyticsCommand } from "./commands/analytics.js";
 
 const program = new Command();
 
@@ -46,5 +47,6 @@ program.addCommand(genreCommand);
 program.addCommand(updateCommand);
 program.addCommand(detectCommand);
 program.addCommand(styleCommand);
+program.addCommand(analyticsCommand);
 
 program.parse();

+ 88 - 0
packages/core/src/__tests__/sensitive-words.test.ts

@@ -0,0 +1,88 @@
+import { describe, it, expect } from "vitest";
+import { analyzeSensitiveWords } from "../agents/sensitive-words.js";
+
+describe("analyzeSensitiveWords", () => {
+  it("returns no issues for clean text", () => {
+    const content = "陈风一脚踩碎了脚下的石板。碎石飞溅,他握紧了手中的长剑,准备迎战。";
+    const result = analyzeSensitiveWords(content);
+    expect(result.issues).toHaveLength(0);
+    expect(result.found).toHaveLength(0);
+  });
+
+  it("detects political terms as block severity", () => {
+    const content = "他在广场上看到了法轮功的标语,不禁皱了皱眉。";
+    const result = analyzeSensitiveWords(content);
+    expect(result.found.length).toBeGreaterThan(0);
+    const politicalMatches = result.found.filter((f) => f.severity === "block");
+    expect(politicalMatches.length).toBeGreaterThan(0);
+    expect(politicalMatches[0]!.word).toBe("法轮功");
+    // Issues should have critical severity for block words
+    const criticalIssues = result.issues.filter((i) => i.severity === "critical");
+    expect(criticalIssues.length).toBeGreaterThan(0);
+    expect(criticalIssues[0]!.category).toBe("敏感词");
+  });
+
+  it("detects sexual terms as warn severity", () => {
+    const content = "他看到了一些淫荡的画面。";
+    const result = analyzeSensitiveWords(content);
+    expect(result.found.length).toBeGreaterThan(0);
+    const warnMatches = result.found.filter((f) => f.severity === "warn");
+    expect(warnMatches.length).toBeGreaterThan(0);
+    // Issues should have warning severity for warn words
+    const warningIssues = result.issues.filter((i) => i.severity === "warning");
+    expect(warningIssues.length).toBeGreaterThan(0);
+  });
+
+  it("detects extreme violence terms as warn severity", () => {
+    const content = "敌人对俘虏进行了残忍的肢解。";
+    const result = analyzeSensitiveWords(content);
+    const violenceMatches = result.found.filter((f) => f.word === "肢解");
+    expect(violenceMatches.length).toBe(1);
+    expect(violenceMatches[0]!.severity).toBe("warn");
+  });
+
+  it("detects custom words", () => {
+    const content = "他使用了禁术「灭世天火」来对付敌人。";
+    const result = analyzeSensitiveWords(content, ["灭世天火", "灭世之力"]);
+    expect(result.found.length).toBe(1);
+    expect(result.found[0]!.word).toBe("灭世天火");
+    expect(result.found[0]!.severity).toBe("warn");
+  });
+
+  it("counts multiple occurrences of the same word", () => {
+    const content = "共产党的历史很长,共产党的影响很大,共产党的组织遍布各地。";
+    const result = analyzeSensitiveWords(content);
+    const match = result.found.find((f) => f.word === "共产党");
+    expect(match).toBeDefined();
+    expect(match!.count).toBe(3);
+  });
+
+  it("matches substring words (新疆 in context)", () => {
+    // "新疆" is not in the default list, but "新疆集中营" is.
+    // This test verifies that exact matching works.
+    const content = "他来自新疆,是一名普通的牧民。";
+    const result = analyzeSensitiveWords(content);
+    // "新疆" alone is not in the list, only "新疆集中营" and "维吾尔"
+    // So this should not match
+    const xinjiangMatch = result.found.find((f) => f.word === "新疆集中营");
+    expect(xinjiangMatch).toBeUndefined();
+  });
+
+  it("does not false-positive on partial matches for multi-char words", () => {
+    const content = "这是一个全新的疆域,充满了未知。";
+    const result = analyzeSensitiveWords(content);
+    // "新疆集中营" should not match in "新的疆域"
+    expect(result.found).toHaveLength(0);
+  });
+
+  it("detects multiple categories simultaneously", () => {
+    const content = "法轮功的信徒在广场上进行了淫荡的仪式,场面极其血腥,有人被肢解。";
+    const result = analyzeSensitiveWords(content);
+    const blockCount = result.found.filter((f) => f.severity === "block").length;
+    const warnCount = result.found.filter((f) => f.severity === "warn").length;
+    expect(blockCount).toBeGreaterThan(0);
+    expect(warnCount).toBeGreaterThan(0);
+    // Should have issues for both political and sexual/violence
+    expect(result.issues.length).toBeGreaterThanOrEqual(2);
+  });
+});

+ 1 - 0
packages/core/src/agents/continuity.ts

@@ -46,6 +46,7 @@ const DIMENSION_MAP: Record<number, string> = {
   24: "支线停滞",
   25: "弧线平坦",
   26: "节奏单调",
+  27: "敏感词检查",
 };
 
 function buildDimensionList(

+ 5 - 2
packages/core/src/agents/reviser.ts

@@ -6,7 +6,7 @@ import { readGenreProfile, readBookRules } from "./rules-reader.js";
 import { readFile } from "node:fs/promises";
 import { join } from "node:path";
 
-export type ReviseMode = "polish" | "rewrite" | "rework" | "anti-detect";
+export type ReviseMode = "polish" | "rewrite" | "rework" | "anti-detect" | "spot-fix";
 
 export interface ReviseOutput {
   readonly revisedContent: string;
@@ -22,6 +22,7 @@ const MODE_DESCRIPTIONS: Record<ReviseMode, string> = {
   rewrite: "改写:可改叙述顺序、画面、力度,但保留核心事实与人物动机",
   rework: "重写:可重构场景推进和冲突组织,但不改主设定和大事件结果",
   "anti-detect": "反检测改写:在保持剧情不变的前提下,降低AI生成可检测性。手法包括:增加段落长度差异、打破句式规律、用口语化/个性化表达替代书面套话、加入非对称修辞、随机化过渡方式",
+  "spot-fix": "定点修复:只修改审稿意见指出的具体句子或段落,其余所有内容必须原封不动保留。修改范围限定在问题句子及其前后各一句。禁止改动无关段落",
 };
 
 export class ReviserAgent extends BaseAgent {
@@ -114,12 +115,14 @@ ${styleGuide}
 ## 待修正章节
 ${chapterContent}`;
 
+    const maxTokens = mode === "spot-fix" ? 8192 : 16384;
+
     const response = await this.chat(
       [
         { role: "system", content: systemPrompt },
         { role: "user", content: userPrompt },
       ],
-      { temperature: 0.3, maxTokens: 16384 },
+      { temperature: 0.3, maxTokens },
     );
 
     return this.parseOutput(response.content, gp);

+ 126 - 0
packages/core/src/agents/sensitive-words.ts

@@ -0,0 +1,126 @@
+/**
+ * Sensitive word detection — rule-based analysis (no LLM).
+ *
+ * Detects politically sensitive, sexually explicit, and extremely violent terms
+ * in Chinese web novel content. Used in audit pipeline to flag or block content.
+ */
+
+import type { AuditIssue } from "./continuity.js";
+
+export interface SensitiveWordMatch {
+  readonly word: string;
+  readonly count: number;
+  readonly severity: "block" | "warn";
+}
+
+export interface SensitiveWordResult {
+  readonly issues: ReadonlyArray<AuditIssue>;
+  readonly found: ReadonlyArray<SensitiveWordMatch>;
+}
+
+// Political terms — severity "block"
+const POLITICAL_WORDS: ReadonlyArray<string> = [
+  "习近平", "习主席", "习总书记", "共产党", "中国共产党", "共青团",
+  "六四", "天安门事件", "天安门广场事件", "法轮功", "法轮大法",
+  "台独", "藏独", "疆独", "港独",
+  "新疆集中营", "再教育营",
+  "维吾尔", "达赖喇嘛", "达赖",
+  "刘晓波", "艾未未", "赵紫阳",
+  "文化大革命", "文革", "大跃进",
+  "反右运动", "镇压", "六四屠杀",
+  "中南海", "政治局常委",
+  "翻墙", "防火长城",
+];
+
+// Sexual terms — severity "warn"
+const SEXUAL_WORDS: ReadonlyArray<string> = [
+  "性交", "做爱", "口交", "肛交", "自慰", "手淫",
+  "阴茎", "阴道", "阴蒂", "乳房", "乳头",
+  "射精", "高潮", "潮吹",
+  "淫荡", "淫乱", "荡妇", "婊子",
+  "强奸", "轮奸",
+];
+
+// Extreme violence — severity "warn"
+const VIOLENCE_EXTREME: ReadonlyArray<string> = [
+  "肢解", "碎尸", "挖眼", "剥皮", "开膛破肚",
+  "虐杀", "凌迟", "活剥", "活埋", "烹煮活人",
+];
+
+interface WordListEntry {
+  readonly words: ReadonlyArray<string>;
+  readonly severity: "block" | "warn";
+  readonly label: string;
+}
+
+const WORD_LISTS: ReadonlyArray<WordListEntry> = [
+  { words: POLITICAL_WORDS, severity: "block", label: "政治敏感词" },
+  { words: SEXUAL_WORDS, severity: "warn", label: "色情敏感词" },
+  { words: VIOLENCE_EXTREME, severity: "warn", label: "极端暴力词" },
+];
+
+/**
+ * Analyze text content for sensitive words.
+ * Returns issues that can be merged into audit results.
+ */
+export function analyzeSensitiveWords(
+  content: string,
+  customWords?: ReadonlyArray<string>,
+): SensitiveWordResult {
+  const found: SensitiveWordMatch[] = [];
+  const issues: AuditIssue[] = [];
+
+  // Check built-in word lists
+  for (const list of WORD_LISTS) {
+    const matches = scanWords(content, list.words, list.severity);
+    if (matches.length > 0) {
+      found.push(...matches);
+      const wordSummary = matches.map((m) => `"${m.word}"×${m.count}`).join("、");
+      issues.push({
+        severity: list.severity === "block" ? "critical" : "warning",
+        category: "敏感词",
+        description: `检测到${list.label}:${wordSummary}`,
+        suggestion: list.severity === "block"
+          ? "必须删除或替换政治敏感词,否则无法发布"
+          : `建议替换或弱化${list.label},避免平台审核问题`,
+      });
+    }
+  }
+
+  // Check custom words
+  if (customWords && customWords.length > 0) {
+    const customMatches = scanWords(content, customWords, "warn");
+    if (customMatches.length > 0) {
+      found.push(...customMatches);
+      const wordSummary = customMatches.map((m) => `"${m.word}"×${m.count}`).join("、");
+      issues.push({
+        severity: "warning",
+        category: "敏感词",
+        description: `检测到自定义敏感词:${wordSummary}`,
+        suggestion: "根据项目规则替换或删除这些词语",
+      });
+    }
+  }
+
+  return { issues, found };
+}
+
+function scanWords(
+  content: string,
+  words: ReadonlyArray<string>,
+  severity: "block" | "warn",
+): ReadonlyArray<SensitiveWordMatch> {
+  const matches: SensitiveWordMatch[] = [];
+  for (const word of words) {
+    const regex = new RegExp(escapeRegExp(word), "g");
+    const hits = content.match(regex);
+    if (hits && hits.length > 0) {
+      matches.push({ word, count: hits.length, severity });
+    }
+  }
+  return matches;
+}
+
+function escapeRegExp(str: string): string {
+  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}

+ 176 - 3
packages/core/src/agents/writer.ts

@@ -13,6 +13,7 @@ export interface WriteChapterInput {
   readonly chapterNumber: number;
   readonly externalContext?: string;
   readonly wordCountOverride?: number;
+  readonly temperatureOverride?: number;
 }
 
 export interface WriteChapterOutput {
@@ -71,6 +72,9 @@ export class WriterAgent extends BaseAgent {
       book, genreProfile, bookRules, bookRulesBody, genreBody, styleGuide, styleFingerprint,
     );
 
+    const dialogueFingerprints = this.extractDialogueFingerprints(recentChapters, storyBible);
+    const relevantSummaries = this.findRelevantSummaries(chapterSummaries, volumeOutline, chapterNumber);
+
     const userPrompt = this.buildUserPrompt({
       chapterNumber,
       storyBible,
@@ -85,17 +89,31 @@ export class WriterAgent extends BaseAgent {
       subplotBoard,
       emotionalArcs,
       characterMatrix,
+      dialogueFingerprints,
+      relevantSummaries,
     });
 
+    const temperature = input.temperatureOverride ?? 0.7;
+
     const response = await this.chat(
       [
         { role: "system", content: systemPrompt },
         { role: "user", content: userPrompt },
       ],
-      { maxTokens: 16384, temperature: 0.7 },
+      { maxTokens: 16384, temperature },
     );
 
-    return this.parseOutput(chapterNumber, response.content, genreProfile);
+    const output = this.parseOutput(chapterNumber, response.content, genreProfile);
+
+    // #4: Post-write constraint validation (regex-based, zero cost)
+    const violations = this.validateConstraints(output.content, bookRules);
+    if (violations.length > 0) {
+      process.stderr.write(
+        `[writer] Constraint violations in chapter ${chapterNumber}: ${violations.join("; ")}\n`,
+      );
+    }
+
+    return output;
   }
 
   async saveChapter(
@@ -145,6 +163,8 @@ export class WriterAgent extends BaseAgent {
     readonly subplotBoard: string;
     readonly emotionalArcs: string;
     readonly characterMatrix: string;
+    readonly dialogueFingerprints?: string;
+    readonly relevantSummaries?: string;
   }): string {
     const contextBlock = params.externalContext
       ? `\n## 外部指令\n以下是来自外部系统的创作指令,请在本章中融入:\n\n${params.externalContext}\n`
@@ -170,6 +190,14 @@ export class WriterAgent extends BaseAgent {
       ? `\n## 角色交互矩阵\n${params.characterMatrix}\n`
       : "";
 
+    const fingerprintBlock = params.dialogueFingerprints
+      ? `\n## 角色对话指纹\n${params.dialogueFingerprints}\n`
+      : "";
+
+    const relevantBlock = params.relevantSummaries
+      ? `\n## 相关历史章节摘要\n${params.relevantSummaries}\n`
+      : "";
+
     return `请续写第${params.chapterNumber}章。
 ${contextBlock}
 ## 当前状态卡
@@ -177,7 +205,7 @@ ${params.currentState}
 ${ledgerBlock}
 ## 伏笔池
 ${params.hooks}
-${summariesBlock}${subplotBlock}${emotionalBlock}${matrixBlock}
+${summariesBlock}${subplotBlock}${emotionalBlock}${matrixBlock}${fingerprintBlock}${relevantBlock}
 ## 最近章节
 ${params.recentChapters || "(这是第一章,无前文)"}
 
@@ -329,6 +357,151 @@ ${params.volumeOutline}
     }
   }
 
+  /** Validate hard constraints against content. Returns list of violation descriptions. */
+  private validateConstraints(content: string, bookRules: BookRules | null): ReadonlyArray<string> {
+    const violations: string[] = [];
+
+    // Built-in hard constraints from writer-prompts
+    if (/不是[^,。!?\n]{0,30}[,,]?\s*而是/.test(content)) {
+      violations.push("硬性禁令:出现了「不是……而是……」句式");
+    }
+    if (content.includes("——")) {
+      violations.push("硬性禁令:出现了破折号「——」");
+    }
+
+    // Book-level prohibitions (check as substring)
+    if (bookRules?.prohibitions) {
+      for (const prohibition of bookRules.prohibitions) {
+        // Only check short prohibitions as literal patterns (long ones are descriptive)
+        if (prohibition.length <= 15 && content.includes(prohibition)) {
+          violations.push(`本书禁忌:出现了"${prohibition}"`);
+        }
+      }
+    }
+
+    return violations;
+  }
+
+  /**
+   * Extract dialogue fingerprints from recent chapters.
+   * For each character with multiple dialogue lines, compute speaking style markers.
+   */
+  private extractDialogueFingerprints(recentChapters: string, _storyBible: string): string {
+    if (!recentChapters) return "";
+
+    // Match dialogue patterns: "speaker said" or dialogue in quotes
+    // Chinese dialogue typically uses "" or 「」
+    const dialogueRegex = /(?:(.{1,6})(?:说道|道|喝道|冷声道|笑道|怒道|低声道|大声道|喝骂道|冷笑道|沉声道|喊道|叫道|问道|答道)\s*[::]\s*["""「]([^"""」]+)["""」])|["""「]([^"""」]{2,})["""」]/g;
+
+    const characterDialogues = new Map<string, string[]>();
+    let match: RegExpExecArray | null;
+
+    while ((match = dialogueRegex.exec(recentChapters)) !== null) {
+      const speaker = match[1]?.trim();
+      const line = match[2] ?? match[3] ?? "";
+      if (speaker && line.length > 1) {
+        const existing = characterDialogues.get(speaker) ?? [];
+        characterDialogues.set(speaker, [...existing, line]);
+      }
+    }
+
+    // Only include characters with >=2 dialogue lines
+    const fingerprints: string[] = [];
+    for (const [character, lines] of characterDialogues) {
+      if (lines.length < 2) continue;
+
+      const avgLen = Math.round(lines.reduce((sum, l) => sum + l.length, 0) / lines.length);
+      const isShort = avgLen < 15;
+
+      // Find frequent words/phrases (2+ occurrences)
+      const wordCounts = new Map<string, number>();
+      for (const line of lines) {
+        // Extract 2-3 char segments as "words"
+        for (let i = 0; i < line.length - 1; i++) {
+          const bigram = line.slice(i, i + 2);
+          wordCounts.set(bigram, (wordCounts.get(bigram) ?? 0) + 1);
+        }
+      }
+      const frequentWords = [...wordCounts.entries()]
+        .filter(([, count]) => count >= 2)
+        .sort((a, b) => b[1] - a[1])
+        .slice(0, 3)
+        .map(([w]) => `「${w}」`);
+
+      // Detect style markers
+      const markers: string[] = [];
+      if (isShort) markers.push("短句为主");
+      else markers.push("长句为主");
+
+      const questionCount = lines.filter((l) => l.includes("?") || l.includes("?")).length;
+      if (questionCount > lines.length * 0.3) markers.push("反问多");
+
+      if (frequentWords.length > 0) markers.push(`常用${frequentWords.join("")}`);
+
+      fingerprints.push(`${character}:${markers.join(",")}`);
+    }
+
+    return fingerprints.length > 0 ? fingerprints.join(";") : "";
+  }
+
+  /**
+   * Find relevant chapter summaries based on volume outline context.
+   * Extracts character names and hook IDs from the current volume's outline,
+   * then searches chapter summaries for matching entries.
+   */
+  private findRelevantSummaries(
+    chapterSummaries: string,
+    volumeOutline: string,
+    chapterNumber: number,
+  ): string {
+    if (!chapterSummaries || chapterSummaries === "(文件尚未创建)") return "";
+    if (!volumeOutline || volumeOutline === "(文件尚未创建)") return "";
+
+    // Extract character names from volume outline (Chinese name patterns)
+    const nameRegex = /[\u4e00-\u9fff]{2,4}(?=[,、。:]|$)/g;
+    const outlineNames = new Set<string>();
+    let nameMatch: RegExpExecArray | null;
+    while ((nameMatch = nameRegex.exec(volumeOutline)) !== null) {
+      outlineNames.add(nameMatch[0]);
+    }
+
+    // Extract hook IDs from volume outline
+    const hookRegex = /H\d{2,}/g;
+    const hookIds = new Set<string>();
+    let hookMatch: RegExpExecArray | null;
+    while ((hookMatch = hookRegex.exec(volumeOutline)) !== null) {
+      hookIds.add(hookMatch[0]);
+    }
+
+    if (outlineNames.size === 0 && hookIds.size === 0) return "";
+
+    // Search chapter summaries for matching rows
+    const rows = chapterSummaries.split("\n").filter((line) =>
+      line.startsWith("|") && !line.startsWith("| 章节") && !line.startsWith("|--") && !line.startsWith("| -"),
+    );
+
+    const matchedRows = rows.filter((row) => {
+      for (const name of outlineNames) {
+        if (row.includes(name)) return true;
+      }
+      for (const hookId of hookIds) {
+        if (row.includes(hookId)) return true;
+      }
+      return false;
+    });
+
+    // Skip rows for the current chapter and recent chapters (they're already in context)
+    const recentCutoff = Math.max(1, chapterNumber - 3);
+    const filteredRows = matchedRows.filter((row) => {
+      const chNumMatch = row.match(/\|\s*(\d+)\s*\|/);
+      if (!chNumMatch) return true;
+      const num = parseInt(chNumMatch[1]!, 10);
+      return num < recentCutoff;
+    });
+
+    return filteredRows.length > 0 ? filteredRows.join("\n") : "";
+  }
+
   private sanitizeFilename(title: string): string {
     return title
       .replace(/[/\\?%*:|"<>]/g, "")

+ 1 - 0
packages/core/src/index.ts

@@ -22,6 +22,7 @@ export { FanqieRadarSource, QidianRadarSource, TextRadarSource, type RadarSource
 export { readGenreProfile, readBookRules, listAvailableGenres, getBuiltinGenresDir } from "./agents/rules-reader.js";
 export { buildWriterSystemPrompt } from "./agents/writer-prompts.js";
 export { analyzeAITells, type AITellResult, type AITellIssue } from "./agents/ai-tells.js";
+export { analyzeSensitiveWords, type SensitiveWordResult, type SensitiveWordMatch } from "./agents/sensitive-words.js";
 export { detectAIContent, type DetectionResult } from "./agents/detector.js";
 export { analyzeStyle } from "./agents/style-analyzer.js";
 export { analyzeDetectionInsights } from "./agents/detection-insights.js";

+ 1 - 0
packages/core/src/models/project.ts

@@ -63,6 +63,7 @@ export const ProjectConfigSchema = z.object({
   llm: LLMConfigSchema,
   notify: z.array(NotifyChannelSchema).default([]),
   detection: DetectionConfigSchema.optional(),
+  modelOverrides: z.record(z.string(), z.string()).optional(),
   daemon: z.object({
     schedule: z.object({
       radarCron: z.string().default("0 9 * * *"),

+ 2 - 1
packages/core/src/notify/webhook.ts

@@ -12,7 +12,8 @@ export type WebhookEvent =
   | "audit-failed"
   | "revision-complete"
   | "pipeline-complete"
-  | "pipeline-error";
+  | "pipeline-error"
+  | "diagnostic-alert";
 
 export interface WebhookPayload {
   readonly event: WebhookEvent;

+ 41 - 17
packages/core/src/pipeline/runner.ts

@@ -11,6 +11,7 @@ import { RadarAgent } from "../agents/radar.js";
 import type { RadarSource } from "../agents/radar-source.js";
 import { readGenreProfile } from "../agents/rules-reader.js";
 import { analyzeAITells } from "../agents/ai-tells.js";
+import { analyzeSensitiveWords } from "../agents/sensitive-words.js";
 import { StateManager } from "../state/manager.js";
 import { dispatchNotification, dispatchWebhookEvent } from "../notify/dispatcher.js";
 import type { WebhookEvent } from "../notify/webhook.js";
@@ -27,6 +28,7 @@ export interface PipelineConfig {
   readonly notifyChannels?: ReadonlyArray<NotifyChannel>;
   readonly radarSources?: ReadonlyArray<RadarSource>;
   readonly externalContext?: string;
+  readonly modelOverrides?: Record<string, string>;
 }
 
 export interface ChapterPipelineResult {
@@ -91,6 +93,19 @@ export class PipelineRunner {
     };
   }
 
+  private modelFor(agentName: string): string {
+    return this.config.modelOverrides?.[agentName] ?? this.config.model;
+  }
+
+  private agentCtxFor(agent: string, bookId?: string): AgentContext {
+    return {
+      client: this.config.client,
+      model: this.modelFor(agent),
+      projectRoot: this.config.projectRoot,
+      bookId,
+    };
+  }
+
   private async loadGenreProfile(genre: string): Promise<{ profile: GenreProfile }> {
     const parsed = await readGenreProfile(this.config.projectRoot, genre);
     return { profile: parsed.profile };
@@ -101,12 +116,12 @@ export class PipelineRunner {
   // ---------------------------------------------------------------------------
 
   async runRadar(): Promise<RadarResult> {
-    const radar = new RadarAgent(this.agentCtx(), this.config.radarSources);
+    const radar = new RadarAgent(this.agentCtxFor("radar"), this.config.radarSources);
     return radar.scan();
   }
 
   async initBook(book: BookConfig): Promise<void> {
-    const architect = new ArchitectAgent(this.agentCtx(book.id));
+    const architect = new ArchitectAgent(this.agentCtxFor("architect", book.id));
     const bookDir = this.state.bookDir(book.id);
 
     await this.state.saveBookConfig(book.id, book);
@@ -127,7 +142,7 @@ export class PipelineRunner {
 
       const { profile: gp } = await this.loadGenreProfile(book.genre);
 
-      const writer = new WriterAgent(this.agentCtx(bookId));
+      const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
       const output = await writer.writeChapter({
         book,
         bookDir,
@@ -187,17 +202,21 @@ export class PipelineRunner {
     }
 
     const content = await this.readChapterContent(bookDir, targetChapter);
-    const auditor = new ContinuityAuditor(this.agentCtx(bookId));
+    const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
     const llmResult = await auditor.auditChapter(bookDir, content, targetChapter, book.genre);
 
     // Merge rule-based AI-tell detection
     const aiTells = analyzeAITells(content);
+    // Merge sensitive word detection
+    const sensitiveResult = analyzeSensitiveWords(content);
+    const hasBlockedWords = sensitiveResult.found.some((f) => f.severity === "block");
     const mergedIssues: ReadonlyArray<AuditIssue> = [
       ...llmResult.issues,
       ...aiTells.issues,
+      ...sensitiveResult.issues,
     ];
     const result: AuditResult = {
-      passed: llmResult.passed,
+      passed: hasBlockedWords ? false : llmResult.passed,
       issues: mergedIssues,
       summary: llmResult.summary,
     };
@@ -246,7 +265,7 @@ export class PipelineRunner {
 
       // Re-audit to get structured issues (index only stores strings)
       const content = await this.readChapterContent(bookDir, targetChapter);
-      const auditor = new ContinuityAuditor(this.agentCtx(bookId));
+      const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
       const auditResult = await auditor.auditChapter(bookDir, content, targetChapter, book.genre);
 
       if (auditResult.passed) {
@@ -255,7 +274,7 @@ export class PipelineRunner {
 
       const { profile: gp } = await this.loadGenreProfile(book.genre);
 
-      const reviser = new ReviserAgent(this.agentCtx(bookId));
+      const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
       const reviseOutput = await reviser.reviseChapter(
         bookDir, content, targetChapter, auditResult.issues, mode, book.genre,
       );
@@ -369,33 +388,34 @@ export class PipelineRunner {
   // Full pipeline (convenience — runs draft + audit + revise in one shot)
   // ---------------------------------------------------------------------------
 
-  async writeNextChapter(bookId: string, wordCount?: number): Promise<ChapterPipelineResult> {
+  async writeNextChapter(bookId: string, wordCount?: number, temperatureOverride?: number): Promise<ChapterPipelineResult> {
     const releaseLock = await this.state.acquireBookLock(bookId);
     try {
-      return await this._writeNextChapterLocked(bookId, wordCount);
+      return await this._writeNextChapterLocked(bookId, wordCount, temperatureOverride);
     } finally {
       await releaseLock();
     }
   }
 
-  private async _writeNextChapterLocked(bookId: string, wordCount?: number): Promise<ChapterPipelineResult> {
+  private async _writeNextChapterLocked(bookId: string, wordCount?: number, temperatureOverride?: number): Promise<ChapterPipelineResult> {
     const book = await this.state.loadBookConfig(bookId);
     const bookDir = this.state.bookDir(bookId);
     const chapterNumber = await this.state.getNextChapterNumber(bookId);
     const { profile: gp } = await this.loadGenreProfile(book.genre);
 
     // 1. Write chapter
-    const writer = new WriterAgent(this.agentCtx(bookId));
+    const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
     const output = await writer.writeChapter({
       book,
       bookDir,
       chapterNumber,
       externalContext: this.config.externalContext,
       ...(wordCount ? { wordCountOverride: wordCount } : {}),
+      ...(temperatureOverride ? { temperatureOverride } : {}),
     });
 
     // 2. Audit chapter
-    const auditor = new ContinuityAuditor(this.agentCtx(bookId));
+    const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
     const llmAudit = await auditor.auditChapter(
       bookDir,
       output.content,
@@ -403,9 +423,11 @@ export class PipelineRunner {
       book.genre,
     );
     const aiTellsResult = analyzeAITells(output.content);
+    const sensitiveWriteResult = analyzeSensitiveWords(output.content);
+    const hasBlockedWriteWords = sensitiveWriteResult.found.some((f) => f.severity === "block");
     let auditResult: AuditResult = {
-      passed: llmAudit.passed,
-      issues: [...llmAudit.issues, ...aiTellsResult.issues],
+      passed: hasBlockedWriteWords ? false : llmAudit.passed,
+      issues: [...llmAudit.issues, ...aiTellsResult.issues, ...sensitiveWriteResult.issues],
       summary: llmAudit.summary,
     };
 
@@ -419,7 +441,7 @@ export class PipelineRunner {
         (i) => i.severity === "critical",
       );
       if (criticalIssues.length > 0) {
-        const reviser = new ReviserAgent(this.agentCtx(bookId));
+        const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
         const reviseOutput = await reviser.reviseChapter(
           bookDir,
           output.content,
@@ -442,9 +464,11 @@ export class PipelineRunner {
             book.genre,
           );
           const reAITells = analyzeAITells(finalContent);
+          const reSensitive = analyzeSensitiveWords(finalContent);
+          const reHasBlocked = reSensitive.found.some((f) => f.severity === "block");
           auditResult = {
-            passed: reAudit.passed,
-            issues: [...reAudit.issues, ...reAITells.issues],
+            passed: reHasBlocked ? false : reAudit.passed,
+            issues: [...reAudit.issues, ...reAITells.issues, ...reSensitive.issues],
             summary: reAudit.summary,
           };
 

+ 98 - 4
packages/core/src/pipeline/scheduler.ts

@@ -2,8 +2,9 @@ import { PipelineRunner } from "./runner.js";
 import type { PipelineConfig } from "./runner.js";
 import { StateManager } from "../state/manager.js";
 import type { BookConfig } from "../models/book.js";
-import type { QualityGates } from "../models/project.js";
+import type { QualityGates, DetectionConfig } from "../models/project.js";
 import { dispatchWebhookEvent } from "../notify/dispatcher.js";
+import { detectChapter, detectAndRewrite } from "./detection-runner.js";
 
 export interface SchedulerConfig extends PipelineConfig {
   readonly radarCron: string;
@@ -11,6 +12,7 @@ export interface SchedulerConfig extends PipelineConfig {
   readonly auditCron: string;
   readonly maxConcurrentBooks: number;
   readonly qualityGates?: QualityGates;
+  readonly detection?: DetectionConfig;
   readonly onChapterComplete?: (bookId: string, chapter: number, status: string) => void;
   readonly onError?: (bookId: string, error: Error) => void;
   readonly onPause?: (bookId: string, reason: string) => void;
@@ -32,6 +34,8 @@ export class Scheduler {
   // Quality gate tracking (per book)
   private consecutiveFailures = new Map<string, number>();
   private pausedBooks = new Set<string>();
+  // Failure clustering: bookId → (dimension → count)
+  private failureDimensions = new Map<string, Map<string, number>>();
 
   constructor(config: SchedulerConfig) {
     this.config = config;
@@ -89,6 +93,7 @@ export class Scheduler {
   resumeBook(bookId: string): void {
     this.pausedBooks.delete(bookId);
     this.consecutiveFailures.delete(bookId);
+    this.failureDimensions.delete(bookId);
   }
 
   /** Check if a book is paused. */
@@ -120,14 +125,46 @@ export class Scheduler {
 
     for (const book of booksToWrite) {
       try {
-        const result = await this.pipeline.writeNextChapter(book.id);
+        // Compute temperature override: base 0.7 + failures * step
+        const failures = this.consecutiveFailures.get(book.id) ?? 0;
+        const tempOverride = failures > 0
+          ? Math.min(1.2, 0.7 + failures * this.gates.retryTemperatureStep)
+          : undefined;
+
+        const result = await this.pipeline.writeNextChapter(book.id, undefined, tempOverride);
 
         if (result.status === "approved") {
           // Reset failure counter on success
           this.consecutiveFailures.delete(book.id);
+
+          // Feature #6: Auto-detection loop after successful audit
+          if (this.config.detection?.enabled) {
+            try {
+              const bookDir = this.state.bookDir(book.id);
+              const chapterContent = await this.readChapterContent(bookDir, result.chapterNumber);
+              const detResult = await detectChapter(
+                this.config.detection,
+                chapterContent,
+                result.chapterNumber,
+              );
+              if (!detResult.passed && this.config.detection.autoRewrite) {
+                await detectAndRewrite(
+                  this.config.detection,
+                  { client: this.config.client, model: this.config.model, projectRoot: this.config.projectRoot },
+                  bookDir,
+                  chapterContent,
+                  result.chapterNumber,
+                  book.config.genre,
+                );
+              }
+            } catch (e) {
+              this.config.onError?.(book.id, e as Error);
+            }
+          }
         } else {
           // Audit failed — apply quality gates
-          await this.handleAuditFailure(book.id, result.chapterNumber);
+          const issueCategories = result.auditResult.issues.map((i) => i.category);
+          await this.handleAuditFailure(book.id, result.chapterNumber, issueCategories);
         }
 
         this.config.onChapterComplete?.(
@@ -142,10 +179,30 @@ export class Scheduler {
     }
   }
 
-  private async handleAuditFailure(bookId: string, chapterNumber: number): Promise<void> {
+  private async handleAuditFailure(
+    bookId: string,
+    chapterNumber: number,
+    issueCategories: ReadonlyArray<string> = [],
+  ): Promise<void> {
     const failures = (this.consecutiveFailures.get(bookId) ?? 0) + 1;
     this.consecutiveFailures.set(bookId, failures);
 
+    // Track failure dimensions for clustering
+    if (issueCategories.length > 0) {
+      const dimMap = this.failureDimensions.get(bookId) ?? new Map<string, number>();
+      for (const cat of issueCategories) {
+        dimMap.set(cat, (dimMap.get(cat) ?? 0) + 1);
+      }
+      this.failureDimensions.set(bookId, dimMap);
+
+      // Check for dimension clustering (any dimension with >=3 failures)
+      for (const [dimension, count] of dimMap) {
+        if (count >= 3) {
+          await this.emitDiagnosticAlert(bookId, chapterNumber, dimension, count);
+        }
+      }
+    }
+
     const gates = this.gates;
 
     // Check if we should retry with higher temperature
@@ -186,6 +243,43 @@ export class Scheduler {
     }
   }
 
+  private async emitDiagnosticAlert(
+    bookId: string,
+    chapterNumber: number,
+    dimension: string,
+    count: number,
+  ): Promise<void> {
+    process.stderr.write(
+      `[scheduler] DIAGNOSTIC: ${bookId} has ${count} failures in dimension "${dimension}"\n`,
+    );
+
+    if (this.config.notifyChannels && this.config.notifyChannels.length > 0) {
+      await dispatchWebhookEvent(this.config.notifyChannels, {
+        event: "diagnostic-alert",
+        bookId,
+        chapterNumber: chapterNumber > 0 ? chapterNumber : undefined,
+        timestamp: new Date().toISOString(),
+        data: { dimension, failureCount: count },
+      });
+    }
+  }
+
+  private async readChapterContent(bookDir: string, chapterNumber: number): Promise<string> {
+    const { readFile, readdir } = await import("node:fs/promises");
+    const { join } = await import("node:path");
+    const chaptersDir = join(bookDir, "chapters");
+    const files = await readdir(chaptersDir);
+    const paddedNum = String(chapterNumber).padStart(4, "0");
+    const chapterFile = files.find((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
+    if (!chapterFile) {
+      throw new Error(`Chapter ${chapterNumber} file not found in ${chaptersDir}`);
+    }
+    const raw = await readFile(join(chaptersDir, chapterFile), "utf-8");
+    const lines = raw.split("\n");
+    const contentStart = lines.findIndex((l, i) => i > 0 && l.trim().length > 0);
+    return contentStart >= 0 ? lines.slice(contentStart).join("\n") : raw;
+  }
+
   private cronToMs(cron: string): number {
     // Simple cron-to-interval mapping for common patterns
     // "0 9 * * *" = daily = 24h