xiaolongmr commited on
Commit
42fac8d
·
1 Parent(s): 9a72a84

zuhe guize gai wei tong ju pipei; fangxing duanyu he changjing kaiguan ke zai shezhi ye peizhi

Browse files
services/content_filter.py CHANGED
@@ -175,6 +175,13 @@ _WEAK_COMBINATION_TERMS: frozenset[str] = frozenset({"骨骼", "血色"})
175
  # 送审时,两个词相隔很远并不构成组合风险,默认只在 120 字窗口内才算命中;0 = 不限制。
176
  _DEFAULT_COMBINATION_WINDOW = 120
177
 
 
 
 
 
 
 
 
178
  # --- 反混淆 ----------------------------------------------------------------
179
  # 稀有 CJK 区(扩展 A 区、扩展 B 及以后)几乎不出现在正常提示词里,却是
180
  # 「改革㐀历程」这类插入生僻字绕过词表匹配的惯用手法,规范化时直接剔除。
@@ -205,6 +212,8 @@ class _ReviewSettings(NamedTuple):
205
  negation_aware: bool
206
  multilingual_aware: bool
207
  deobfuscate: bool
 
 
208
 
209
 
210
  class _SearchText(NamedTuple):
@@ -329,6 +338,47 @@ def _normalize_sensitive_text(value: object) -> str:
329
  return _canonical_forms(str(value or ""), True)[1]
330
 
331
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  def _search_variants(raw: str, *, deobfuscate: bool = True) -> tuple[_SearchText, ...]:
333
  """一个请求可能被写成多种等价形态,全部作为搜索空间。"""
334
  if not raw:
@@ -542,6 +592,17 @@ def _local_review_settings(settings_override: dict[str, object] | None = None) -
542
  negation_aware = _flag(settings, "negation_aware")
543
  multilingual_aware = _flag(settings, "multilingual_aware")
544
  deobfuscate = _flag(settings, "deobfuscate")
 
 
 
 
 
 
 
 
 
 
 
545
  combination_window = _coerce_window(settings.get("combination_window", _DEFAULT_COMBINATION_WINDOW))
546
  raw_strong_terms = settings.get("strong_short_terms")
547
  strong_terms = (
@@ -564,6 +625,8 @@ def _local_review_settings(settings_override: dict[str, object] | None = None) -
564
  negation_aware,
565
  multilingual_aware,
566
  deobfuscate,
 
 
567
  )
568
  category_terms: dict[str, tuple[str, ...]] = {}
569
  category_block_terms: set[str] = set()
@@ -614,6 +677,8 @@ def _local_review_settings(settings_override: dict[str, object] | None = None) -
614
  negation_aware,
615
  multilingual_aware,
616
  deobfuscate,
 
 
617
  )
618
 
619
 
@@ -625,6 +690,7 @@ def _local_rule_match(text: str, words: list[str], settings: dict[str, object] |
625
  variants = _search_variants(text, deobfuscate=review.deobfuscate)
626
  if not variants:
627
  return None
 
628
  category_match = _first_live_term(variants, review.category_block_terms, negation_aware=negation_aware)
629
  if category_match:
630
  return {"rule_id": "category-direct-block", "matched_terms": [category_match]}
@@ -642,16 +708,23 @@ def _local_rule_match(text: str, words: list[str], settings: dict[str, object] |
642
  continue
643
  if _configured_word_match(variants, word, negation_aware=negation_aware):
644
  return {"rule_id": "configured-block-word", "matched_terms": [str(word).strip()]}
645
- for rule_id, left_terms, right_terms in review.combination_rules:
646
- pair = _closest_rule_pair(
647
- variants,
648
- left_terms,
649
- right_terms,
650
- review.combination_window,
651
- negation_aware=negation_aware,
652
- )
653
- if pair:
654
- return {"rule_id": rule_id, "matched_terms": [pair[0], pair[1]]}
 
 
 
 
 
 
 
655
  return None
656
 
657
 
 
175
  # 送审时,两个词相隔很远并不构成组合风险,默认只在 120 字窗口内才算命中;0 = 不限制。
176
  _DEFAULT_COMBINATION_WINDOW = 120
177
 
178
+ # 组合规则还要求左右词「在同一句话里」。列表式提示词(尤其几十条负面提示词)每行
179
+ # 描述一个独立缺陷,「服装撕裂破损」和「身体拉伸扭曲」分属两条、语义无关,却会落进
180
+ # 同一个 120 字窗口被误配(2026-10-17 实例:整段被判「写实血腥」)。
181
+ # 句边界只认换行与句末标点;逗号 / 顿号不算,否则「血肉模糊的人体,写实风格」这种
182
+ # 同一口气写完的真违规也会被切开。可用 local_review.combination_sentence_aware 关闭。
183
+ _SENTENCE_SPLIT = re.compile(r"[\n\r。!?;!?;]+")
184
+
185
  # --- 反混淆 ----------------------------------------------------------------
186
  # 稀有 CJK 区(扩展 A 区、扩展 B 及以后)几乎不出现在正常提示词里,却是
187
  # 「改革㐀历程」这类插入生僻字绕过词表匹配的惯用手法,规范化时直接剔除。
 
212
  negation_aware: bool
213
  multilingual_aware: bool
214
  deobfuscate: bool
215
+ sentence_aware: bool
216
+ benign_phrases: frozenset[str]
217
 
218
 
219
  class _SearchText(NamedTuple):
 
338
  return _canonical_forms(str(value or ""), True)[1]
339
 
340
 
341
+ def _sentence_variants(raw: str, *, deobfuscate: bool = True) -> tuple[tuple[_SearchText, ...], ...]:
342
+ """按句切开分别构造搜索空间,供组合规则做「同句才配对」的近邻判断。
343
+
344
+ 只在确实切成多句时返回内容;单句返回空元组,调用方回落到整段搜索空间
345
+ (结果等价,还省一次规范化)。
346
+ """
347
+ parts = [part for part in _SENTENCE_SPLIT.split(raw) if part.strip()]
348
+ if len(parts) <= 1:
349
+ return ()
350
+ return tuple((_SearchText(*_canonical_forms(part, deobfuscate)),) for part in parts)
351
+
352
+
353
+ def _apply_benign_phrases(
354
+ variants: tuple[_SearchText, ...],
355
+ phrases: frozenset[str],
356
+ ) -> tuple[_SearchText, ...]:
357
+ """把配置里的良性短语从搜索空间里抹掉,作用等同扩写内置 `_BENIGN_COMPOUNDS`。
358
+
359
+ 内置表按「敏感词 → 良性复合词」分组,只能覆盖开发时预想到的搭配。这里是一份
360
+ 平铺的短语清单,站长在设置页加一条就能放行一类误伤,不必改代码重新部署
361
+ (2026-10-17 用户要求:不要每次误伤都改代码推代码)。
362
+
363
+ 每个形态各用自己那套归一化结果去替换,所以「服 装 撕 裂」这类插空写法同样有效。
364
+ """
365
+ if not phrases:
366
+ return variants
367
+ needles = [_canonical_forms(phrase, True) for phrase in phrases]
368
+ if not needles:
369
+ return variants
370
+ masked: list[_SearchText] = []
371
+ for search in variants:
372
+ parts = list(search)
373
+ for forms in needles:
374
+ for index in range(len(parts)):
375
+ needle = forms[index]
376
+ if needle and needle in parts[index]:
377
+ parts[index] = parts[index].replace(needle, _BENIGN_COMPOUND_FILLER * len(needle))
378
+ masked.append(_SearchText(*parts))
379
+ return tuple(masked)
380
+
381
+
382
  def _search_variants(raw: str, *, deobfuscate: bool = True) -> tuple[_SearchText, ...]:
383
  """一个请求可能被写成多种等价形态,全部作为搜索空间。"""
384
  if not raw:
 
592
  negation_aware = _flag(settings, "negation_aware")
593
  multilingual_aware = _flag(settings, "multilingual_aware")
594
  deobfuscate = _flag(settings, "deobfuscate")
595
+ sentence_aware = _flag(settings, "combination_sentence_aware")
596
+ raw_phrases = settings.get("benign_phrases")
597
+ benign_phrases = (
598
+ frozenset(
599
+ normalized
600
+ for phrase in raw_phrases
601
+ if (normalized := _normalize_sensitive_text(phrase))
602
+ )
603
+ if isinstance(raw_phrases, list)
604
+ else frozenset()
605
+ )
606
  combination_window = _coerce_window(settings.get("combination_window", _DEFAULT_COMBINATION_WINDOW))
607
  raw_strong_terms = settings.get("strong_short_terms")
608
  strong_terms = (
 
625
  negation_aware,
626
  multilingual_aware,
627
  deobfuscate,
628
+ sentence_aware,
629
+ benign_phrases,
630
  )
631
  category_terms: dict[str, tuple[str, ...]] = {}
632
  category_block_terms: set[str] = set()
 
677
  negation_aware,
678
  multilingual_aware,
679
  deobfuscate,
680
+ sentence_aware,
681
+ benign_phrases,
682
  )
683
 
684
 
 
690
  variants = _search_variants(text, deobfuscate=review.deobfuscate)
691
  if not variants:
692
  return None
693
+ variants = _apply_benign_phrases(variants, review.benign_phrases)
694
  category_match = _first_live_term(variants, review.category_block_terms, negation_aware=negation_aware)
695
  if category_match:
696
  return {"rule_id": "category-direct-block", "matched_terms": [category_match]}
 
708
  continue
709
  if _configured_word_match(variants, word, negation_aware=negation_aware):
710
  return {"rule_id": "configured-block-word", "matched_terms": [str(word).strip()]}
711
+ # 组合规则要求左右词在同一句话里。列表式提示词每行描述一个独立缺陷,跨行的两个词
712
+ # 语义无关却会落进 120 字窗口被误配,因此逐句判断而不是整段扫。
713
+ search_spaces = (
714
+ _sentence_variants(text, deobfuscate=review.deobfuscate) if review.sentence_aware else ()
715
+ ) or (variants,)
716
+ for search_space in search_spaces:
717
+ search_space = _apply_benign_phrases(search_space, review.benign_phrases)
718
+ for rule_id, left_terms, right_terms in review.combination_rules:
719
+ pair = _closest_rule_pair(
720
+ search_space,
721
+ left_terms,
722
+ right_terms,
723
+ review.combination_window,
724
+ negation_aware=negation_aware,
725
+ )
726
+ if pair:
727
+ return {"rule_id": rule_id, "matched_terms": [pair[0], pair[1]]}
728
  return None
729
 
730
 
test/test_content_filter.py CHANGED
@@ -344,6 +344,46 @@ class ContentFilterTests(unittest.TestCase):
344
  with self.assertRaises(HTTPException):
345
  content_filter.check_request("身体被撕裂的人形,真实画面")
346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  def test_interior_design_prompt_is_not_treated_as_intimate_photography(self) -> None:
348
  settings = {
349
  "enabled": True,
 
344
  with self.assertRaises(HTTPException):
345
  content_filter.check_request("身体被撕裂的人形,真实画面")
346
 
347
+ def test_combination_rule_does_not_pair_terms_across_lines(self) -> None:
348
+ # 结构性保障,不针对具体词:列表式提示词每行描述一个独立缺陷,
349
+ # 「身体」在第 3 行、「溃烂」在第 4 行,整段送审时二者落进同一个
350
+ # 120 字窗口,逐行判断后不再配对。(溃烂没有任何良性复合词豁免,
351
+ # 所以这条测试验证的是句子边界本身。)
352
+ with self._patched(self._gore_rule()):
353
+ content_filter.check_request(
354
+ "帮我生成一个20岁男性,CG质感,近景构图,浅景深\n"
355
+ "负面提示词\n"
356
+ "负面:身体拉伸扭曲、躯干变形\n"
357
+ "局部溃烂的画面、材质破损\n"
358
+ "衣物褶皱错乱、布料纹理扭曲\n"
359
+ )
360
+ # 同一行里同时出现时,仍然按写实血腥拦截。
361
+ with self.assertRaises(HTTPException) as raised:
362
+ content_filter.check_request("身体大面积溃烂,写实画面")
363
+ self.assertEqual(raised.exception.detail["rule_id"], "写实血腥")
364
+
365
+ def test_combination_sentence_aware_can_be_disabled(self) -> None:
366
+ # 关掉句子边界后回到旧的「整段 120 字窗口」行为,交给用户按需选择。
367
+ settings = self._gore_rule(combination_sentence_aware=False)
368
+ with self._patched(settings):
369
+ with self.assertRaises(HTTPException):
370
+ content_filter.check_request("身体拉伸扭曲\n局部溃烂的画面")
371
+
372
+ def test_configured_benign_phrases_excuse_single_line_lists(self) -> None:
373
+ # 单行逗号写的负面词列表整段算一句,句子边界救不了,只能靠设置页里的
374
+ # 「良性短语」豁免:加一条就放行,不需要改代码重新部署。
375
+ # 用「溃烂」是因为它没有任何内置良性复合词,能干净地验证这条通路。
376
+ text = "帮我生成一个20岁男性,CG质感\n负面提示词:身体拉伸扭曲、局部溃烂的画面、衣物褶皱错乱"
377
+ with self._patched(self._gore_rule()):
378
+ with self.assertRaises(HTTPException):
379
+ content_filter.check_request(text)
380
+ with self._patched(self._gore_rule(benign_phrases=["局部溃烂"])):
381
+ content_filter.check_request(text)
382
+ # 豁免只针对那一个短语,真血腥语境不受影响。
383
+ with self.assertRaises(HTTPException):
384
+ content_filter.check_request("身体大面积溃烂,写实画面")
385
+
386
+
387
  def test_interior_design_prompt_is_not_treated_as_intimate_photography(self) -> None:
388
  settings = {
389
  "enabled": True,
web/src/app/settings/components/config-card.tsx CHANGED
@@ -972,6 +972,35 @@ export function ConfigCard() {
972
  />
973
  <span className="text-xs text-stone-500">左右两侧命中词必须落在同一段上下文里;0 表示不限距离,默认 120 字。</span>
974
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
975
  </div>
976
  <div className="space-y-3 border-t border-stone-200 pt-4">
977
  <div>
 
972
  />
973
  <span className="text-xs text-stone-500">左右两侧命中词必须落在同一段上下文里;0 表示不限距离,默认 120 字。</span>
974
  </div>
975
+ <label className="flex items-start gap-3 text-sm font-medium text-stone-900">
976
+ <Checkbox
977
+ className="mt-0.5"
978
+ checked={config?.local_review?.combination_sentence_aware !== false}
979
+ onCheckedChange={(checked) => setLocalReviewField("combination_sentence_aware", Boolean(checked))}
980
+ disabled={config?.local_review?.enabled === false}
981
+ />
982
+ <span>
983
+ 组合词只在同一句内配对
984
+ <span className="mt-1 block text-xs font-normal leading-6 text-stone-500">
985
+ 负面提示词常一行写一个缺陷,「服装撕裂破损」和「身体拉伸扭曲」分属两条、语义无关,却会落进同一个间距窗口被误判成血腥。开启后只认同句(换行 / 句号 / 分号分隔)内的配对;逗号、顿号连写的长句不受影响,同一句里的真违规照旧拦截。
986
+ </span>
987
+ </span>
988
+ </label>
989
+ <div className="space-y-2">
990
+ <label className="text-sm text-stone-700" htmlFor="local-review-benign-phrases">自定义放行短语</label>
991
+ <Textarea
992
+ id="local-review-benign-phrases"
993
+ rows={4}
994
+ value={(config?.local_review?.benign_phrases || []).join("\n")}
995
+ onChange={(event) => setLocalReviewField("benign_phrases", event.target.value.split("\n").map((phrase) => phrase.trim()).filter(Boolean))}
996
+ className="rounded-lg text-sm"
997
+ placeholder={"一行一条,例如:\n服装撕裂\n布料撕裂\n唇形崩坏"}
998
+ disabled={config?.local_review?.enabled === false}
999
+ />
1000
+ <p className="text-xs text-stone-500">
1001
+ 写在这里的短语会在送审前被整段抹掉,其中的敏感词不再触发拦截。用来放行「服装撕裂」「唇形崩坏」这类在正常提示词里必然出现、但被词表当成风险的多义词搭配。<span className="text-stone-700">保存后立刻生效,不需要重新部署。</span>先用下方「规则测试」粘一条被拦的提示词确认效果。
1002
+ </p>
1003
+ </div>
1004
  </div>
1005
  <div className="space-y-3 border-t border-stone-200 pt-4">
1006
  <div>
web/src/app/settings/store.ts CHANGED
@@ -173,6 +173,8 @@ const DEFAULT_LOCAL_REVIEW = {
173
  ],
174
  negation_aware: true,
175
  combination_window: 120,
 
 
176
  multilingual_aware: true,
177
  deobfuscate: true,
178
  };
@@ -224,6 +226,8 @@ function normalizeLocalReview(value: SettingsConfig["local_review"], legacyWords
224
  })) : DEFAULT_LOCAL_REVIEW.combination_rules,
225
  negation_aware: value?.negation_aware !== false,
226
  combination_window: Number.isFinite(rawWindow) && rawWindow >= 0 ? Math.round(rawWindow) : DEFAULT_LOCAL_REVIEW.combination_window,
 
 
227
  multilingual_aware: value?.multilingual_aware !== false,
228
  deobfuscate: value?.deobfuscate !== false,
229
  };
@@ -715,7 +719,7 @@ type SettingsStore = {
715
  setReversePromptField: (key: "provider" | "base_url" | "api_key" | "model", value: string) => void;
716
  setProductVisualAgentField: (key: "provider" | "base_url" | "api_key" | "model", value: string) => void;
717
  setSensitiveWordsText: (value: string) => void;
718
- setLocalReviewField: (key: "enabled" | "strong_short_terms" | "categories" | "combination_rules" | "negation_aware" | "combination_window" | "multilingual_aware" | "deobfuscate", value: boolean | number | string[] | NonNullable<SettingsConfig["local_review"]>["categories"] | NonNullable<SettingsConfig["local_review"]>["combination_rules"]) => void;
719
  setAIReviewField: (key: "enabled" | "base_url" | "api_key" | "model" | "prompt" | "fail_open", value: string | boolean) => void;
720
  setImageStorageField: (key: keyof ImageStorageSettings, value: string | boolean) => void;
721
  setProxyRuntimeField: <K extends keyof ProxyRuntimeSettings>(key: K, value: ProxyRuntimeSettings[K]) => void;
 
173
  ],
174
  negation_aware: true,
175
  combination_window: 120,
176
+ combination_sentence_aware: true,
177
+ benign_phrases: [],
178
  multilingual_aware: true,
179
  deobfuscate: true,
180
  };
 
226
  })) : DEFAULT_LOCAL_REVIEW.combination_rules,
227
  negation_aware: value?.negation_aware !== false,
228
  combination_window: Number.isFinite(rawWindow) && rawWindow >= 0 ? Math.round(rawWindow) : DEFAULT_LOCAL_REVIEW.combination_window,
229
+ combination_sentence_aware: value?.combination_sentence_aware !== false,
230
+ benign_phrases: Array.isArray(value?.benign_phrases) ? value.benign_phrases.map((phrase) => String(phrase || "").trim()).filter(Boolean) : [],
231
  multilingual_aware: value?.multilingual_aware !== false,
232
  deobfuscate: value?.deobfuscate !== false,
233
  };
 
719
  setReversePromptField: (key: "provider" | "base_url" | "api_key" | "model", value: string) => void;
720
  setProductVisualAgentField: (key: "provider" | "base_url" | "api_key" | "model", value: string) => void;
721
  setSensitiveWordsText: (value: string) => void;
722
+ setLocalReviewField: (key: "enabled" | "strong_short_terms" | "categories" | "combination_rules" | "negation_aware" | "combination_window" | "combination_sentence_aware" | "benign_phrases" | "multilingual_aware" | "deobfuscate", value: boolean | number | string[] | NonNullable<SettingsConfig["local_review"]>["categories"] | NonNullable<SettingsConfig["local_review"]>["combination_rules"]) => void;
723
  setAIReviewField: (key: "enabled" | "base_url" | "api_key" | "model" | "prompt" | "fail_open", value: string | boolean) => void;
724
  setImageStorageField: (key: keyof ImageStorageSettings, value: string | boolean) => void;
725
  setProxyRuntimeField: <K extends keyof ProxyRuntimeSettings>(key: K, value: ProxyRuntimeSettings[K]) => void;
web/src/lib/api.ts CHANGED
@@ -727,6 +727,10 @@ export type SettingsConfig = {
727
  negation_aware?: boolean;
728
  /** 组合规则左右两侧命中词的最大间距(按归一化后字符计)。0 = 不限制。默认 120。 */
729
  combination_window?: number;
 
 
 
 
730
  /** 内置跨语言违规词(英/日/韩/俄/德/法/西/越/泰/阿拉伯…)恒先生效。默认开启。 */
731
  multilingual_aware?: boolean;
732
  /** 送审前先还原混淆写法:繁→简、URL/HTML/\\u 转义、Base64、稀字剔除、倒序词。默认开启。 */
 
727
  negation_aware?: boolean;
728
  /** 组合规则左右两侧命中词的最大间距(按归一化后字符计)。0 = 不限制。默认 120。 */
729
  combination_window?: number;
730
+ /** 组合规则要求左右词落在同一句内(换行 / 句号 / 分号分隔)。默认开启。 */
731
+ combination_sentence_aware?: boolean;
732
+ /** 送审前整段抹掉的良性短语,用于放行「服装撕裂」这类多数场合无害的搭配。改完即生效。 */
733
+ benign_phrases?: string[];
734
  /** 内置跨语言违规词(英/日/韩/俄/德/法/西/越/泰/阿拉伯…)恒先生效。默认开启。 */
735
  multilingual_aware?: boolean;
736
  /** 送审前先还原混淆写法:繁→简、URL/HTML/\\u 转义、Base64、稀字剔除、倒序词。默认开启。 */