Răsfoiți Sursa

feat: add bilingual support to ai-tells and sensitive-words

Both analyzers now accept a language parameter ("zh" | "en"):
- ai-tells: English hedge/transition/sycophancy word lists, localized output
- sensitive-words: English labels, localized descriptions and suggestions

Cherry-picked from 52e00c4.
Ma 7 luni în urmă
părinte
comite
df931004b8

+ 54 - 26
packages/core/src/agents/ai-tells.ts

@@ -19,18 +19,26 @@ export interface AITellResult {
   readonly issues: ReadonlyArray<AITellIssue>;
 }
 
-// Hedge/filler words common in AI Chinese text
-const HEDGE_WORDS = ["似乎", "可能", "或许", "大概", "某种程度上", "一定程度上", "在某种意义上"];
+type AITellLanguage = "zh" | "en";
 
-// Formulaic transition words
-const TRANSITION_WORDS = ["然而", "不过", "与此同时", "另一方面", "尽管如此", "话虽如此", "但值得注意的是"];
+const HEDGE_WORDS: Record<AITellLanguage, ReadonlyArray<string>> = {
+  zh: ["似乎", "可能", "或许", "大概", "某种程度上", "一定程度上", "在某种意义上"],
+  en: ["seems", "seemed", "perhaps", "maybe", "apparently", "in some ways", "to some extent"],
+};
+
+const TRANSITION_WORDS: Record<AITellLanguage, ReadonlyArray<string>> = {
+  zh: ["然而", "不过", "与此同时", "另一方面", "尽管如此", "话虽如此", "但值得注意的是"],
+  en: ["however", "meanwhile", "on the other hand", "nevertheless", "even so", "still"],
+};
 
 /**
  * Analyze text content for structural AI-tell patterns.
  * Returns issues that can be merged into audit results.
  */
-export function analyzeAITells(content: string): AITellResult {
+export function analyzeAITells(content: string, language: AITellLanguage = "zh"): AITellResult {
   const issues: AITellIssue[] = [];
+  const isEnglish = language === "en";
+  const joiner = isEnglish ? ", " : "、";
 
   const paragraphs = content
     .split(/\n\s*\n/)
@@ -48,9 +56,13 @@ export function analyzeAITells(content: string): AITellResult {
       if (cv < 0.15) {
         issues.push({
           severity: "warning",
-          category: "段落等长",
-          description: `段落长度变异系数仅${cv.toFixed(3)}(阈值<0.15),段落长度过于均匀,呈现AI生成特征`,
-          suggestion: "增加段落长度差异:短段落用于节奏加速或冲击,长段落用于沉浸描写",
+          category: isEnglish ? "Paragraph uniformity" : "段落等长",
+          description: isEnglish
+            ? `Paragraph-length coefficient of variation is only ${cv.toFixed(3)} (threshold <0.15), which suggests unnaturally uniform paragraph sizing`
+            : `段落长度变异系数仅${cv.toFixed(3)}(阈值<0.15),段落长度过于均匀,呈现AI生成特征`,
+          suggestion: isEnglish
+            ? "Increase paragraph-length contrast: use shorter beats for impact and longer blocks for immersive detail"
+            : "增加段落长度差异:短段落用于节奏加速或冲击,长段落用于沉浸描写",
         });
       }
     }
@@ -60,8 +72,8 @@ export function analyzeAITells(content: string): AITellResult {
   const totalChars = content.length;
   if (totalChars > 0) {
     let hedgeCount = 0;
-    for (const word of HEDGE_WORDS) {
-      const regex = new RegExp(word, "g");
+    for (const word of HEDGE_WORDS[language]) {
+      const regex = new RegExp(word, isEnglish ? "gi" : "g");
       const matches = content.match(regex);
       hedgeCount += matches?.length ?? 0;
     }
@@ -69,21 +81,25 @@ export function analyzeAITells(content: string): AITellResult {
     if (hedgeDensity > 3) {
       issues.push({
         severity: "warning",
-        category: "套话密度",
-        description: `套话词(似乎/可能/或许等)密度为${hedgeDensity.toFixed(1)}次/千字(阈值>3),语气过于模糊犹豫`,
-        suggestion: "用确定性叙述替代模糊表达:去掉「似乎」直接描述状态,用具体细节替代「可能」",
+        category: isEnglish ? "Hedge density" : "套话密度",
+        description: isEnglish
+          ? `Hedge-word density is ${hedgeDensity.toFixed(1)} per 1k characters (threshold >3), making the prose sound overly tentative`
+          : `套话词(似乎/可能/或许等)密度为${hedgeDensity.toFixed(1)}次/千字(阈值>3),语气过于模糊犹豫`,
+        suggestion: isEnglish
+          ? "Replace hedges with firmer narration: remove vague qualifiers and use concrete detail instead"
+          : "用确定性叙述替代模糊表达:去掉「似乎」直接描述状态,用具体细节替代「可能」",
       });
     }
   }
 
   // dim 22: Formulaic transition repetition
   const transitionCounts: Record<string, number> = {};
-  for (const word of TRANSITION_WORDS) {
-    const regex = new RegExp(word, "g");
+  for (const word of TRANSITION_WORDS[language]) {
+    const regex = new RegExp(word, isEnglish ? "gi" : "g");
     const matches = content.match(regex);
     const count = matches?.length ?? 0;
     if (count > 0) {
-      transitionCounts[word] = count;
+      transitionCounts[isEnglish ? word.toLowerCase() : word] = count;
     }
   }
   const repeatedTransitions = Object.entries(transitionCounts)
@@ -91,18 +107,22 @@ export function analyzeAITells(content: string): AITellResult {
   if (repeatedTransitions.length > 0) {
     const detail = repeatedTransitions
       .map(([word, count]) => `"${word}"×${count}`)
-      .join("、");
+      .join(joiner);
     issues.push({
       severity: "warning",
-      category: "公式化转折",
-      description: `转折词重复使用:${detail}。同一转折模式≥3次暴露AI生成痕迹`,
-      suggestion: "用情节自然转折替代转折词,或换用不同的过渡手法(动作切入、时间跳跃、视角切换)",
+      category: isEnglish ? "Formulaic transitions" : "公式化转折",
+      description: isEnglish
+        ? `Transition words repeat too often: ${detail}. Reusing the same transition pattern 3+ times creates a formulaic AI texture`
+        : `转折词重复使用:${detail}。同一转折模式≥3次暴露AI生成痕迹`,
+      suggestion: isEnglish
+        ? "Let scenes pivot through action, timing, or viewpoint shifts instead of repeating the same transitions"
+        : "用情节自然转折替代转折词,或换用不同的过渡手法(动作切入、时间跳跃、视角切换)",
     });
   }
 
   // dim 23: List-like structure (consecutive sentences with same prefix pattern)
   const sentences = content
-    .split(/[。!?\n]/)
+    .split(isEnglish ? /[.!?\n]/ : /[。!?\n]/)
     .map((s) => s.trim())
     .filter((s) => s.length > 2);
 
@@ -110,8 +130,12 @@ export function analyzeAITells(content: string): AITellResult {
     let consecutiveSamePrefix = 1;
     let maxConsecutive = 1;
     for (let i = 1; i < sentences.length; i++) {
-      const prevPrefix = sentences[i - 1]!.slice(0, 2);
-      const currPrefix = sentences[i]!.slice(0, 2);
+      const prevPrefix = isEnglish
+        ? sentences[i - 1]!.split(/\s+/)[0]?.toLowerCase() ?? ""
+        : sentences[i - 1]!.slice(0, 2);
+      const currPrefix = isEnglish
+        ? sentences[i]!.split(/\s+/)[0]?.toLowerCase() ?? ""
+        : sentences[i]!.slice(0, 2);
       if (prevPrefix === currPrefix) {
         consecutiveSamePrefix++;
         maxConsecutive = Math.max(maxConsecutive, consecutiveSamePrefix);
@@ -122,9 +146,13 @@ export function analyzeAITells(content: string): AITellResult {
     if (maxConsecutive >= 3) {
       issues.push({
         severity: "info",
-        category: "列表式结构",
-        description: `检测到${maxConsecutive}句连续以相同开头的句子,呈现列表式AI生成结构`,
-        suggestion: "变换句式开头:用不同主语、时间词、动作词开头,打破列表感",
+        category: isEnglish ? "List-like structure" : "列表式结构",
+        description: isEnglish
+          ? `Detected ${maxConsecutive} consecutive sentences with the same opening pattern, creating a list-like generated cadence`
+          : `检测到${maxConsecutive}句连续以相同开头的句子,呈现列表式AI生成结构`,
+        suggestion: isEnglish
+          ? "Vary how sentences open: change subject, timing, or action entry to break the list effect"
+          : "变换句式开头:用不同主语、时间词、动作词开头,打破列表感",
       });
     }
   }

+ 29 - 13
packages/core/src/agents/sensitive-words.ts

@@ -18,6 +18,8 @@ export interface SensitiveWordResult {
   readonly found: ReadonlyArray<SensitiveWordMatch>;
 }
 
+type SensitiveWordLanguage = "zh" | "en";
+
 // Political terms — severity "block"
 const POLITICAL_WORDS: ReadonlyArray<string> = [
   "习近平", "习主席", "习总书记", "共产党", "中国共产党", "共青团",
@@ -51,12 +53,13 @@ interface WordListEntry {
   readonly words: ReadonlyArray<string>;
   readonly severity: "block" | "warn";
   readonly label: string;
+  readonly englishLabel: string;
 }
 
 const WORD_LISTS: ReadonlyArray<WordListEntry> = [
-  { words: POLITICAL_WORDS, severity: "block", label: "政治敏感词" },
-  { words: SEXUAL_WORDS, severity: "warn", label: "色情敏感词" },
-  { words: VIOLENCE_EXTREME, severity: "warn", label: "极端暴力词" },
+  { words: POLITICAL_WORDS, severity: "block", label: "政治敏感词", englishLabel: "political sensitive terms" },
+  { words: SEXUAL_WORDS, severity: "warn", label: "色情敏感词", englishLabel: "sexual sensitive terms" },
+  { words: VIOLENCE_EXTREME, severity: "warn", label: "极端暴力词", englishLabel: "extreme violence terms" },
 ];
 
 /**
@@ -66,23 +69,32 @@ const WORD_LISTS: ReadonlyArray<WordListEntry> = [
 export function analyzeSensitiveWords(
   content: string,
   customWords?: ReadonlyArray<string>,
+  language: SensitiveWordLanguage = "zh",
 ): SensitiveWordResult {
   const found: SensitiveWordMatch[] = [];
   const issues: AuditIssue[] = [];
+  const isEnglish = language === "en";
+  const joiner = isEnglish ? ", " : "、";
 
   // 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("、");
+      const wordSummary = matches.map((m) => `"${m.word}"×${m.count}`).join(joiner);
       issues.push({
         severity: list.severity === "block" ? "critical" : "warning",
-        category: "敏感词",
-        description: `检测到${list.label}:${wordSummary}`,
-        suggestion: list.severity === "block"
-          ? "必须删除或替换政治敏感词,否则无法发布"
-          : `建议替换或弱化${list.label},避免平台审核问题`,
+        category: isEnglish ? "Sensitive terms" : "敏感词",
+        description: isEnglish
+          ? `Detected ${list.englishLabel}: ${wordSummary}`
+          : `检测到${list.label}:${wordSummary}`,
+        suggestion: isEnglish
+          ? (list.severity === "block"
+              ? "You must remove or replace these blocked terms before publication"
+              : `Replace or soften these ${list.englishLabel} to reduce moderation risk`)
+          : (list.severity === "block"
+              ? "必须删除或替换政治敏感词,否则无法发布"
+              : `建议替换或弱化${list.label},避免平台审核问题`),
       });
     }
   }
@@ -92,12 +104,16 @@ export function analyzeSensitiveWords(
     const customMatches = scanWords(content, customWords, "warn");
     if (customMatches.length > 0) {
       found.push(...customMatches);
-      const wordSummary = customMatches.map((m) => `"${m.word}"×${m.count}`).join("、");
+      const wordSummary = customMatches.map((m) => `"${m.word}"×${m.count}`).join(joiner);
       issues.push({
         severity: "warning",
-        category: "敏感词",
-        description: `检测到自定义敏感词:${wordSummary}`,
-        suggestion: "根据项目规则替换或删除这些词语",
+        category: isEnglish ? "Sensitive terms" : "敏感词",
+        description: isEnglish
+          ? `Detected custom sensitive term(s): ${wordSummary}`
+          : `检测到自定义敏感词:${wordSummary}`,
+        suggestion: isEnglish
+          ? "Replace or remove these terms according to project rules"
+          : "根据项目规则替换或删除这些词语",
       });
     }
   }