소스 검색

feat: add web-search utils and post-write validator

- web-search.ts: DuckDuckGo HTML search + generic URL fetch with timeout
- post-write-validator.ts: 11 deterministic zero-LLM-cost rules
  (禁止句式, 破折号, 转折词密度, 高疲劳词, 元叙事, 报告术语,
   说教词, 集体反应, 连续了字, 段落过长, 本书禁忌)
- 14 tests covering all rules + false-positive validation
- Re-export from core index.ts
majx_mac 8 달 전
부모
커밋
0b49ef09fa

+ 133 - 0
packages/core/src/__tests__/post-write-validator.test.ts

@@ -0,0 +1,133 @@
+import { describe, it, expect } from "vitest";
+import { validatePostWrite, type PostWriteViolation } from "../agents/post-write-validator.js";
+import type { GenreProfile } from "../models/genre-profile.js";
+
+const baseProfile: GenreProfile = {
+  id: "test",
+  name: "测试",
+  chapterTypes: [],
+  fatigueWords: [],
+  pacingRule: "",
+  numericalSystem: false,
+  powerScaling: false,
+  eraResearch: false,
+  auditDimensions: [],
+  satisfactionTypes: [],
+};
+
+function findRule(violations: ReadonlyArray<PostWriteViolation>, rule: string): PostWriteViolation | undefined {
+  return violations.find(v => v.rule === rule);
+}
+
+describe("validatePostWrite", () => {
+  it("returns no violations for clean content", () => {
+    const content = "他走过去,端起杯子,灌了一口。外面的雨越下越大。\n\n她站在窗前,看着街上的行人匆匆走过。";
+    const result = validatePostWrite(content, baseProfile, null);
+    expect(result).toHaveLength(0);
+  });
+
+  it("detects '不是…而是…' pattern", () => {
+    const content = "这不是勇气,而是愚蠢。他知道这一点。";
+    const result = validatePostWrite(content, baseProfile, null);
+    expect(findRule(result, "禁止句式")).toBeDefined();
+    expect(findRule(result, "禁止句式")!.severity).toBe("error");
+  });
+
+  it("detects dash '——'", () => {
+    const content = "他走了过去——然后停下来。";
+    const result = validatePostWrite(content, baseProfile, null);
+    expect(findRule(result, "禁止破折号")).toBeDefined();
+    expect(findRule(result, "禁止破折号")!.severity).toBe("error");
+  });
+
+  it("detects surprise marker density exceeding threshold", () => {
+    // ~100 chars total, threshold = max(1, floor(100/3000)) = 1, but we put 3 markers
+    const content = "他忽然站起来。仿佛听到了什么声音。竟然是那个人回来了。";
+    const result = validatePostWrite(content, baseProfile, null);
+    expect(findRule(result, "转折词密度")).toBeDefined();
+  });
+
+  it("allows markers within threshold", () => {
+    // 3000+ chars with only 1 marker
+    const filler = "这是一段很长的正文内容,描述了角色的行动和场景的变化。".repeat(60);
+    const content = `${filler}他忽然站起来。${filler}`;
+    const result = validatePostWrite(content, baseProfile, null);
+    expect(findRule(result, "转折词密度")).toBeUndefined();
+  });
+
+  it("detects fatigue words from genre profile", () => {
+    const profile = { ...baseProfile, fatigueWords: ["一道目光"] };
+    const content = "一道目光扫过来,又一道目光从侧面射来,第三道目光也来了。";
+    const result = validatePostWrite(content, profile, null);
+    expect(findRule(result, "高疲劳词")).toBeDefined();
+  });
+
+  it("detects meta-narration patterns", () => {
+    const content = "故事发展到了这里,主角终于做出了选择。他站起来走向门口。";
+    const result = validatePostWrite(content, baseProfile, null);
+    expect(findRule(result, "元叙事")).toBeDefined();
+  });
+
+  it("detects report-style terms in prose", () => {
+    const content = "他的核心动机其实很简单,就是想活下去。信息边界在此刻变得模糊。";
+    const result = validatePostWrite(content, baseProfile, null);
+    const v = findRule(result, "报告术语");
+    expect(v).toBeDefined();
+    expect(v!.severity).toBe("error");
+    expect(v!.description).toContain("核心动机");
+    expect(v!.description).toContain("信息边界");
+  });
+
+  it("detects sermon words", () => {
+    const content = "显然,对方低估了他的实力。毋庸置疑,这将是一场硬仗。";
+    const result = validatePostWrite(content, baseProfile, null);
+    const v = findRule(result, "作者说教");
+    expect(v).toBeDefined();
+    expect(v!.description).toContain("显然");
+    expect(v!.description).toContain("毋庸置疑");
+  });
+
+  it("detects collective shock patterns", () => {
+    const content = "众人齐齐震惊,没有人想到他居然能赢。";
+    const result = validatePostWrite(content, baseProfile, null);
+    expect(findRule(result, "集体反应")).toBeDefined();
+  });
+
+  it("detects consecutive '了' sentences", () => {
+    const content = "他走了过去。他拿了杯子。他喝了一口。他放了下来。他转了身。";
+    const result = validatePostWrite(content, baseProfile, null);
+    const v = findRule(result, "连续了字");
+    expect(v).toBeDefined();
+    expect(v!.severity).toBe("warning");
+  });
+
+  it("detects overly long paragraphs", () => {
+    const longPara = "这是一段非常长的段落。".repeat(30); // ~300+ chars
+    const content = `${longPara}\n\n${longPara}\n\n短段落。`;
+    const result = validatePostWrite(content, baseProfile, null);
+    expect(findRule(result, "段落过长")).toBeDefined();
+  });
+
+  it("detects book-level prohibitions", () => {
+    const bookRules = {
+      version: "1",
+      protagonist: { name: "张三", personalityLock: [], behavioralConstraints: [] },
+      prohibitions: ["跪舔"],
+      genreLock: { primary: "xuanhuan" as const, forbidden: [] },
+      chapterTypesOverride: [],
+      fatigueWordsOverride: [],
+      additionalAuditDimensions: [],
+      enableFullCastTracking: false,
+    };
+    const content = "他一脸跪舔的样子让人恶心。";
+    const result = validatePostWrite(content, baseProfile, bookRules);
+    expect(findRule(result, "本书禁忌")).toBeDefined();
+  });
+
+  it("does not flag allowed content", () => {
+    // Content that is clean across all rules
+    const content = `他站起来,环顾四周。窗外的月光洒在地板上,像一层薄薄的霜。\n\n\u201c走吧。\u201d她转身推开门。冷风从缝隙里钻进来,她裹紧了衣服。`;
+    const result = validatePostWrite(content, baseProfile, null);
+    expect(result).toHaveLength(0);
+  });
+});

+ 236 - 0
packages/core/src/agents/post-write-validator.ts

@@ -0,0 +1,236 @@
+/**
+ * Post-write rule-based validator.
+ *
+ * Deterministic, zero-LLM-cost checks that run after every chapter generation.
+ * Catches violations that prompt-only rules cannot guarantee.
+ */
+
+import type { BookRules } from "../models/book-rules.js";
+import type { GenreProfile } from "../models/genre-profile.js";
+
+export interface PostWriteViolation {
+  readonly rule: string;
+  readonly severity: "error" | "warning";
+  readonly description: string;
+  readonly suggestion: string;
+}
+
+// --- Marker word lists ---
+
+/** AI转折/惊讶标记词 */
+const SURPRISE_MARKERS = ["仿佛", "忽然", "竟然", "猛地", "猛然", "不禁", "宛如"];
+
+/** 元叙事/编剧旁白模式 */
+const META_NARRATION_PATTERNS = [
+  /到这里[,,]?算是/,
+  /接下来[,,]?(?:就是|将会|即将)/,
+  /(?:后面|之后)[,,]?(?:会|将|还会)/,
+  /(?:故事|剧情)(?:发展)?到了/,
+  /读者[,,]?(?:可能|应该|也许)/,
+  /我们[,,]?(?:可以|不妨|来看)/,
+];
+
+/** 分析报告式术语(禁止出现在正文中) */
+const REPORT_TERMS = [
+  "核心动机", "信息边界", "信息落差", "核心风险", "利益最大化",
+  "当前处境", "行为约束", "性格过滤", "情绪外化", "锚定效应",
+  "沉没成本", "认知共鸣",
+];
+
+/** 作者说教词 */
+const SERMON_WORDS = ["显然", "毋庸置疑", "不言而喻", "众所周知", "不难看出"];
+
+/** 全场震惊类集体反应 */
+const COLLECTIVE_SHOCK_PATTERNS = [
+  /(?:全场|众人|所有人|在场的人)[,,]?(?:都|全|齐齐|纷纷)?(?:震惊|惊呆|倒吸凉气|目瞪口呆|哗然|惊呼)/,
+  /(?:全场|一片)[,,]?(?:寂静|哗然|沸腾|震动)/,
+];
+
+// --- Validator ---
+
+export function validatePostWrite(
+  content: string,
+  genreProfile: GenreProfile,
+  bookRules: BookRules | null,
+): ReadonlyArray<PostWriteViolation> {
+  const violations: PostWriteViolation[] = [];
+
+  // 1. 硬性禁令: "不是…而是…" 句式
+  if (/不是[^,。!?\n]{0,30}[,,]?\s*而是/.test(content)) {
+    violations.push({
+      rule: "禁止句式",
+      severity: "error",
+      description: "出现了「不是……而是……」句式",
+      suggestion: "改用直述句",
+    });
+  }
+
+  // 2. 硬性禁令: 破折号
+  if (content.includes("——")) {
+    violations.push({
+      rule: "禁止破折号",
+      severity: "error",
+      description: "出现了破折号「——」",
+      suggestion: "用逗号或句号断句",
+    });
+  }
+
+  // 3. 转折/惊讶标记词密度 ≤ 1次/3000字
+  const markerCounts: Record<string, number> = {};
+  let totalMarkerCount = 0;
+  for (const word of SURPRISE_MARKERS) {
+    const matches = content.match(new RegExp(word, "g"));
+    const count = matches?.length ?? 0;
+    if (count > 0) {
+      markerCounts[word] = count;
+      totalMarkerCount += count;
+    }
+  }
+  const markerLimit = Math.max(1, Math.floor(content.length / 3000));
+  if (totalMarkerCount > markerLimit) {
+    const detail = Object.entries(markerCounts)
+      .map(([w, c]) => `"${w}"×${c}`)
+      .join("、");
+    violations.push({
+      rule: "转折词密度",
+      severity: "warning",
+      description: `转折/惊讶标记词共${totalMarkerCount}次(上限${markerLimit}次/${content.length}字),明细:${detail}`,
+      suggestion: "改用具体动作或感官描写传递突然性",
+    });
+  }
+
+  // 4. 高疲劳词检查(从 genreProfile 读取,单章每词 ≤ 1次)
+  const fatigueWords = bookRules?.fatigueWordsOverride && bookRules.fatigueWordsOverride.length > 0
+    ? bookRules.fatigueWordsOverride
+    : genreProfile.fatigueWords;
+  for (const word of fatigueWords) {
+    const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+    const matches = content.match(new RegExp(escaped, "g"));
+    const count = matches?.length ?? 0;
+    if (count > 1) {
+      violations.push({
+        rule: "高疲劳词",
+        severity: "warning",
+        description: `高疲劳词"${word}"出现${count}次(上限1次/章)`,
+        suggestion: `替换多余的"${word}"为同义但不同形式的表达`,
+      });
+    }
+  }
+
+  // 5. 元叙事检查(编剧旁白)
+  for (const pattern of META_NARRATION_PATTERNS) {
+    const match = content.match(pattern);
+    if (match) {
+      violations.push({
+        rule: "元叙事",
+        severity: "warning",
+        description: `出现编剧旁白式表述:"${match[0]}"`,
+        suggestion: "删除元叙事,让剧情自然展开",
+      });
+      break; // 报一次即可
+    }
+  }
+
+  // 6. 分析报告式术语
+  const foundTerms: string[] = [];
+  for (const term of REPORT_TERMS) {
+    if (content.includes(term)) {
+      foundTerms.push(term);
+    }
+  }
+  if (foundTerms.length > 0) {
+    violations.push({
+      rule: "报告术语",
+      severity: "error",
+      description: `正文中出现分析报告术语:${foundTerms.map(t => `"${t}"`).join("、")}`,
+      suggestion: "这些术语只能用于 PRE_WRITE_CHECK 内部推理,正文中用口语化表达替代",
+    });
+  }
+
+  // 7. 作者说教词
+  const foundSermons: string[] = [];
+  for (const word of SERMON_WORDS) {
+    if (content.includes(word)) {
+      foundSermons.push(word);
+    }
+  }
+  if (foundSermons.length > 0) {
+    violations.push({
+      rule: "作者说教",
+      severity: "warning",
+      description: `出现说教词:${foundSermons.map(w => `"${w}"`).join("、")}`,
+      suggestion: "删除说教词,让读者自己从情节中判断",
+    });
+  }
+
+  // 8. 全场震惊类集体反应
+  for (const pattern of COLLECTIVE_SHOCK_PATTERNS) {
+    const match = content.match(pattern);
+    if (match) {
+      violations.push({
+        rule: "集体反应",
+        severity: "warning",
+        description: `出现集体反应套话:"${match[0]}"`,
+        suggestion: "改写成1-2个具体角色的身体反应",
+      });
+      break;
+    }
+  }
+
+  // 9. 连续"了"字检查(3句以上连续含"了")
+  const sentences = content
+    .split(/[。!?]/)
+    .map(s => s.trim())
+    .filter(s => s.length > 2);
+
+  let consecutiveLe = 0;
+  let maxConsecutiveLe = 0;
+  for (const sentence of sentences) {
+    if (sentence.includes("了")) {
+      consecutiveLe++;
+      maxConsecutiveLe = Math.max(maxConsecutiveLe, consecutiveLe);
+    } else {
+      consecutiveLe = 0;
+    }
+  }
+  if (maxConsecutiveLe >= 4) {
+    violations.push({
+      rule: "连续了字",
+      severity: "warning",
+      description: `检测到${maxConsecutiveLe}句连续包含"了"字,节奏拖沓`,
+      suggestion: "保留最有力的一个「了」,其余改为无「了」句式",
+    });
+  }
+
+  // 10. 段落长度检查(手机阅读适配:50-250字/段为宜)
+  const paragraphs = content
+    .split(/\n\s*\n/)
+    .map(p => p.trim())
+    .filter(p => p.length > 0);
+
+  const longParagraphs = paragraphs.filter(p => p.length > 300);
+  if (longParagraphs.length >= 2) {
+    violations.push({
+      rule: "段落过长",
+      severity: "warning",
+      description: `${longParagraphs.length}个段落超过300字,不适合手机阅读`,
+      suggestion: "长段落拆分为3-5行的短段落,在动作切换或情绪节点处断开",
+    });
+  }
+
+  // 11. Book-level prohibitions (substring match for short prohibitions, min 2 chars)
+  if (bookRules?.prohibitions) {
+    for (const prohibition of bookRules.prohibitions) {
+      if (prohibition.length >= 2 && prohibition.length <= 15 && content.includes(prohibition)) {
+        violations.push({
+          rule: "本书禁忌",
+          severity: "error",
+          description: `出现了本书禁忌内容:"${prohibition}"`,
+          suggestion: "删除或改写该内容",
+        });
+      }
+    }
+  }
+
+  return violations;
+}

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

@@ -26,6 +26,10 @@ export { analyzeSensitiveWords, type SensitiveWordResult, type SensitiveWordMatc
 export { detectAIContent, type DetectionResult } from "./agents/detector.js";
 export { detectAIContent, type DetectionResult } from "./agents/detector.js";
 export { analyzeStyle } from "./agents/style-analyzer.js";
 export { analyzeStyle } from "./agents/style-analyzer.js";
 export { analyzeDetectionInsights } from "./agents/detection-insights.js";
 export { analyzeDetectionInsights } from "./agents/detection-insights.js";
+export { validatePostWrite, type PostWriteViolation } from "./agents/post-write-validator.js";
+
+// Utils
+export { searchWeb, fetchUrl, type SearchResult } from "./utils/web-search.js";
 
 
 // Pipeline
 // Pipeline
 export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type ReviseResult, type TruthFiles, type BookStatusInfo } from "./pipeline/runner.js";
 export { PipelineRunner, type PipelineConfig, type ChapterPipelineResult, type DraftResult, type ReviseResult, type TruthFiles, type BookStatusInfo } from "./pipeline/runner.js";

+ 84 - 0
packages/core/src/utils/web-search.ts

@@ -0,0 +1,84 @@
+/**
+ * Web search and URL fetch utilities for agent loop and architect.
+ */
+
+export interface SearchResult {
+  readonly query: string;
+  readonly results: ReadonlyArray<{ readonly title: string; readonly snippet: string; readonly url: string }>;
+}
+
+/**
+ * Search the web using DuckDuckGo HTML endpoint.
+ * Returns up to 8 result snippets.
+ */
+export async function searchWeb(query: string): Promise<SearchResult> {
+  const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
+
+  const res = await fetch(url, {
+    headers: {
+      "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
+      "Accept": "text/html",
+    },
+    signal: AbortSignal.timeout(15000),
+  });
+
+  if (!res.ok) {
+    throw new Error(`Search failed: ${res.status} ${res.statusText}`);
+  }
+
+  const html = await res.text();
+  const results: Array<{ title: string; snippet: string; url: string }> = [];
+
+  // Parse DuckDuckGo HTML results
+  const resultBlocks = html.split(/class="result\s/g).slice(1, 9);
+
+  for (const block of resultBlocks) {
+    const titleMatch = block.match(/class="result__a"[^>]*>([\s\S]*?)<\/a>/);
+    const snippetMatch = block.match(/class="result__snippet"[^>]*>([\s\S]*?)<\/a>/);
+    const urlMatch = block.match(/class="result__url"[^>]*>([\s\S]*?)<\/a>/);
+
+    const title = titleMatch?.[1]?.replace(/<[^>]*>/g, "").trim() ?? "";
+    const snippet = snippetMatch?.[1]?.replace(/<[^>]*>/g, "").trim() ?? "";
+    const resultUrl = urlMatch?.[1]?.replace(/<[^>]*>/g, "").trim() ?? "";
+
+    if (title) {
+      results.push({ title, snippet, url: resultUrl });
+    }
+  }
+
+  return { query, results };
+}
+
+/**
+ * Fetch a URL and return its text content.
+ * HTML is stripped to plain text. Output is truncated to maxChars.
+ */
+export async function fetchUrl(url: string, maxChars = 8000): Promise<string> {
+  const res = await fetch(url, {
+    headers: {
+      "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
+      "Accept": "text/html, application/json, text/plain",
+    },
+    signal: AbortSignal.timeout(15000),
+  });
+
+  if (!res.ok) {
+    throw new Error(`Fetch failed: ${res.status} ${res.statusText}`);
+  }
+
+  const contentType = res.headers.get("content-type") ?? "";
+  const text = await res.text();
+
+  // If HTML, strip tags and collapse whitespace
+  if (contentType.includes("html")) {
+    return text
+      .replace(/<script[\s\S]*?<\/script>/gi, "")
+      .replace(/<style[\s\S]*?<\/style>/gi, "")
+      .replace(/<[^>]*>/g, " ")
+      .replace(/\s+/g, " ")
+      .trim()
+      .slice(0, maxChars);
+  }
+
+  return text.slice(0, maxChars);
+}