feat: implement t-604 keyword prompt moderation
This commit is contained in:
@@ -43,6 +43,13 @@ IMAGE_URL_CONNECT_TIMEOUT_SECONDS=10
|
||||
IMAGE_URL_READ_TIMEOUT_SECONDS=60
|
||||
RECHARGE_MAX_AMOUNT_CNY=100000.00
|
||||
|
||||
# Content moderation
|
||||
MODERATION_ENABLED=false
|
||||
MODERATION_PROVIDER=keyword
|
||||
MODERATION_FAIL_CLOSED=true
|
||||
MODERATION_BLOCK_ON_REVIEW=false
|
||||
MODERATION_CACHE_VERSION_KEY=moderation:sensitive_words:version
|
||||
|
||||
# Payment callback verification
|
||||
PAYMENT_CALLBACK_MODE=mock
|
||||
PAYMENT_MOCK_CALLBACK_SECRET=change-me-mock-callback-secret
|
||||
|
||||
@@ -29,6 +29,8 @@ from apps.billing.services import (
|
||||
mark_call_success,
|
||||
)
|
||||
|
||||
from apps.moderation.services import moderate_prompt
|
||||
|
||||
from .errors import api_error
|
||||
from .storage import save_generated_image
|
||||
|
||||
@@ -60,6 +62,7 @@ def generate_title_response(*, user, api_key, request_data: Mapping[str, Any]) -
|
||||
alias = request_data.get("model") or None
|
||||
resolution = normalize_resolution(request_data.get("resolution") or "1K") or "1K"
|
||||
parameters = dict(request_data.get("parameters") or {})
|
||||
moderate_prompt_or_raise(user=user, api_key=api_key, prompt=prompt)
|
||||
image_input = load_image_input(request_data)
|
||||
|
||||
model_alias = resolve_model_alias_or_raise(CallRecord.OperationType.TITLE, alias)
|
||||
@@ -134,6 +137,7 @@ def generate_image_response(*, user, api_key, request, request_data: Mapping[str
|
||||
resolution = normalize_resolution(request_data.get("resolution") or "1K") or "1K"
|
||||
aspect_ratio = request_data.get("aspect_ratio") or "1:1"
|
||||
parameters = dict(request_data.get("parameters") or {})
|
||||
moderate_prompt_or_raise(user=user, api_key=api_key, prompt=prompt)
|
||||
image_input = load_image_input(request_data)
|
||||
|
||||
model_alias = resolve_model_alias_or_raise(CallRecord.OperationType.IMAGE, alias)
|
||||
@@ -202,6 +206,12 @@ def generate_image_response(*, user, api_key, request, request_data: Mapping[str
|
||||
}
|
||||
|
||||
|
||||
def moderate_prompt_or_raise(*, user, api_key, prompt: str) -> None:
|
||||
outcome = moderate_prompt(user=user, api_key=api_key, prompt=prompt)
|
||||
if outcome.blocked:
|
||||
raise ApiRequestError("content_blocked", "输入内容未通过安全审核", status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
def resolve_model_alias_or_raise(operation_type: str, alias: str | None):
|
||||
try:
|
||||
return resolve_model_alias(operation_type, alias)
|
||||
|
||||
@@ -39,6 +39,8 @@ from apps.billing.payment_gateways import (
|
||||
build_mock_body_signature,
|
||||
)
|
||||
from apps.billing.services import RechargePayment
|
||||
from apps.moderation.models import SensitiveWord
|
||||
from apps.moderation.providers.keyword import reset_keyword_matcher_cache
|
||||
from apps.users.models import ApiKey
|
||||
from apps.users.models import UserWallet
|
||||
|
||||
@@ -824,9 +826,11 @@ def dns_result(address: str):
|
||||
class GenerateApiTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
reset_keyword_matcher_cache()
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
self.media_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.media_dir.cleanup)
|
||||
self.addCleanup(reset_keyword_matcher_cache)
|
||||
media_override = override_settings(
|
||||
MEDIA_ROOT=self.media_dir.name,
|
||||
MEDIA_URL="/media/",
|
||||
@@ -905,6 +909,53 @@ class GenerateApiTests(TestCase):
|
||||
self.assertFalse(CallRecord.objects.filter(user=self.user).exists())
|
||||
self.assertFalse(PointsLedger.objects.filter(user=self.user).exists())
|
||||
|
||||
@override_settings(
|
||||
MODERATION_ENABLED=True,
|
||||
MODERATION_PROVIDER="keyword",
|
||||
MODERATION_CACHE_VERSION_KEY="test:api:moderation:sensitive_words:version",
|
||||
)
|
||||
def test_blocked_prompt_returns_content_blocked_before_image_download_or_charge(self):
|
||||
SensitiveWord.objects.create(word="敏感词", category="policy")
|
||||
|
||||
with (
|
||||
patch("apps.api.generation.socket.getaddrinfo") as dns_lookup,
|
||||
patch("apps.api.generation.requests.Session.get") as image_get,
|
||||
):
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/image",
|
||||
{
|
||||
"prompt": "请生成敏-感\u200b 词图片",
|
||||
"model": self.image_alias,
|
||||
"image_url": "https://safe.example.com/input.jpg",
|
||||
"resolution": "1K",
|
||||
"aspect_ratio": "1:1",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["error"]["code"], "content_blocked")
|
||||
dns_lookup.assert_not_called()
|
||||
image_get.assert_not_called()
|
||||
self.assertEqual(self.provider.image_calls, [])
|
||||
self.assert_generation_not_charged()
|
||||
|
||||
@override_settings(
|
||||
MODERATION_ENABLED=False,
|
||||
MODERATION_PROVIDER="keyword",
|
||||
MODERATION_CACHE_VERSION_KEY="test:api:moderation:sensitive_words:version",
|
||||
)
|
||||
def test_disabled_moderation_does_not_block_matching_prompt(self):
|
||||
SensitiveWord.objects.create(word="敏感词")
|
||||
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/title",
|
||||
{"prompt": "敏感词", "model": self.title_alias},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["points_cost"], 2)
|
||||
self.assertEqual(len(self.provider.text_calls), 1)
|
||||
|
||||
def test_generate_title_uses_default_alias_charges_points_and_writes_call_record(self):
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/title",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import SensitiveWord
|
||||
|
||||
|
||||
@admin.register(SensitiveWord)
|
||||
class SensitiveWordAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"word",
|
||||
"normalized_word",
|
||||
"category",
|
||||
"action",
|
||||
"is_active",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("category", "action", "is_active")
|
||||
search_fields = ("word", "normalized_word", "category")
|
||||
readonly_fields = ("normalized_word", "created_at", "updated_at")
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ModerationConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.moderation"
|
||||
verbose_name = "内容安全"
|
||||
|
||||
def ready(self) -> None:
|
||||
from . import signals # noqa: F401
|
||||
@@ -0,0 +1,46 @@
|
||||
# Generated by Codex for T-604.
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SensitiveWord",
|
||||
fields=[
|
||||
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("word", models.CharField(max_length=255, verbose_name="敏感词")),
|
||||
("normalized_word", models.CharField(editable=False, max_length=255, verbose_name="归一化敏感词")),
|
||||
("category", models.CharField(blank=True, default="custom", max_length=64, verbose_name="分类")),
|
||||
("action", models.CharField(choices=[("block", "拦截")], default="block", max_length=16, verbose_name="动作")),
|
||||
("is_active", models.BooleanField(default=True, verbose_name="启用")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
|
||||
("updated_at", models.DateTimeField(auto_now=True, verbose_name="更新时间")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "敏感词",
|
||||
"verbose_name_plural": "敏感词",
|
||||
"db_table": "sensitive_word",
|
||||
"ordering": ("category", "word"),
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="sensitiveword",
|
||||
index=models.Index(fields=["is_active", "category"], name="sw_active_category_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="sensitiveword",
|
||||
index=models.Index(fields=["normalized_word"], name="sw_normalized_word_idx"),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="sensitiveword",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("category", "normalized_word"),
|
||||
name="unique_sensitive_word_per_category",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
|
||||
from .normalization import normalize_text
|
||||
|
||||
|
||||
class SensitiveWord(models.Model):
|
||||
class Action(models.TextChoices):
|
||||
BLOCK = "block", "拦截"
|
||||
|
||||
word = models.CharField("敏感词", max_length=255)
|
||||
normalized_word = models.CharField("归一化敏感词", max_length=255, editable=False)
|
||||
category = models.CharField("分类", max_length=64, default="custom", blank=True)
|
||||
action = models.CharField("动作", max_length=16, choices=Action.choices, default=Action.BLOCK)
|
||||
is_active = models.BooleanField("启用", default=True)
|
||||
created_at = models.DateTimeField("创建时间", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("更新时间", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "sensitive_word"
|
||||
verbose_name = "敏感词"
|
||||
verbose_name_plural = "敏感词"
|
||||
ordering = ("category", "word")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("category", "normalized_word"),
|
||||
name="unique_sensitive_word_per_category",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=("is_active", "category"), name="sw_active_category_idx"),
|
||||
models.Index(fields=("normalized_word",), name="sw_normalized_word_idx"),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.word
|
||||
|
||||
def clean(self) -> None:
|
||||
self.word = (self.word or "").strip()
|
||||
self.category = (self.category or "custom").strip() or "custom"
|
||||
self.normalized_word = normalize_text(self.word)
|
||||
if not self.normalized_word:
|
||||
raise ValidationError({"word": "敏感词归一化后不能为空"})
|
||||
if self.action != self.Action.BLOCK:
|
||||
raise ValidationError({"action": "MVP 只支持 block 动作"})
|
||||
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
from functools import lru_cache
|
||||
|
||||
_ZERO_WIDTH_CHARS = {
|
||||
"\u200b",
|
||||
"\u200c",
|
||||
"\u200d",
|
||||
"\ufeff",
|
||||
"\u2060",
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _opencc_converter():
|
||||
try:
|
||||
from opencc import OpenCC
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
return OpenCC("t2s")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _to_simplified(value: str) -> str:
|
||||
converter = _opencc_converter()
|
||||
if converter is None:
|
||||
return value
|
||||
return converter.convert(value)
|
||||
|
||||
|
||||
def normalize_text(value: str | None) -> str:
|
||||
"""Normalize text before keyword matching.
|
||||
|
||||
The local keyword MVP removes obvious bypass characters only; it does not
|
||||
attempt semantic rewriting or fuzzy matching.
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
normalized = unicodedata.normalize("NFKC", value).lower()
|
||||
normalized = _to_simplified(normalized)
|
||||
|
||||
chars: list[str] = []
|
||||
for char in normalized:
|
||||
if char in _ZERO_WIDTH_CHARS:
|
||||
continue
|
||||
category = unicodedata.category(char)
|
||||
if category[0] in {"P", "Z"}:
|
||||
continue
|
||||
chars.append(char)
|
||||
return "".join(chars)
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from .providers import ModerationConfigError, ModerationProvider, Verdict
|
||||
|
||||
|
||||
def moderation_enabled() -> bool:
|
||||
return bool(getattr(settings, "MODERATION_ENABLED", False))
|
||||
|
||||
|
||||
def fail_closed() -> bool:
|
||||
return bool(getattr(settings, "MODERATION_FAIL_CLOSED", True))
|
||||
|
||||
|
||||
def block_on_review() -> bool:
|
||||
return bool(getattr(settings, "MODERATION_BLOCK_ON_REVIEW", False))
|
||||
|
||||
|
||||
def verdict_is_blocking(verdict: Verdict) -> bool:
|
||||
if verdict == Verdict.BLOCK:
|
||||
return True
|
||||
if verdict == Verdict.REVIEW:
|
||||
return block_on_review()
|
||||
return False
|
||||
|
||||
|
||||
def selected_provider_name() -> str:
|
||||
return str(getattr(settings, "MODERATION_PROVIDER", "") or "").strip().lower()
|
||||
|
||||
|
||||
def _build_keyword() -> ModerationProvider:
|
||||
from .providers.keyword import KeywordModerationProvider
|
||||
|
||||
return KeywordModerationProvider()
|
||||
|
||||
|
||||
_PROVIDER_BUILDERS = {
|
||||
"keyword": _build_keyword,
|
||||
}
|
||||
|
||||
|
||||
def biz_type(stage: str) -> str:
|
||||
return stage
|
||||
|
||||
|
||||
def _build_provider() -> ModerationProvider:
|
||||
"""按 MODERATION_PROVIDER 选具体厂商。未选 / 未知 → ModerationConfigError(上层按 fail-closed 处理)。"""
|
||||
name = selected_provider_name()
|
||||
if not name:
|
||||
raise ModerationConfigError("MODERATION_PROVIDER 未设置(内容安全厂商未选定)")
|
||||
builder = _PROVIDER_BUILDERS.get(name)
|
||||
if builder is None:
|
||||
raise ModerationConfigError(f"未知内容安全厂商: {name}")
|
||||
return builder()
|
||||
|
||||
|
||||
def get_text_provider() -> ModerationProvider:
|
||||
return _build_provider()
|
||||
@@ -0,0 +1,17 @@
|
||||
from .base import (
|
||||
ModerationConfigError,
|
||||
ModerationError,
|
||||
ModerationProvider,
|
||||
ModerationResult,
|
||||
ModerationUnavailableError,
|
||||
Verdict,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ModerationConfigError",
|
||||
"ModerationError",
|
||||
"ModerationProvider",
|
||||
"ModerationResult",
|
||||
"ModerationUnavailableError",
|
||||
"Verdict",
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class ModerationError(RuntimeError):
|
||||
"""Base error for content-moderation failures."""
|
||||
|
||||
|
||||
class ModerationUnavailableError(ModerationError):
|
||||
"""Provider could not return a verdict (down / timeout / not implemented).
|
||||
|
||||
The orchestration layer converts this into a fail-closed BLOCK or a
|
||||
fail-open PASS depending on ``MODERATION_FAIL_CLOSED`` policy.
|
||||
"""
|
||||
|
||||
|
||||
class ModerationConfigError(ModerationError):
|
||||
"""Provider is misconfigured (missing credentials / biz type / endpoint)."""
|
||||
|
||||
|
||||
class Verdict(str, Enum):
|
||||
PASS = "pass" # 放行
|
||||
REVIEW = "review" # 建议人工复审(是否等同拦截由 policy 决定)
|
||||
BLOCK = "block" # 拦截
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModerationResult:
|
||||
"""Normalized moderation verdict, decoupled from any vendor's response shape.
|
||||
|
||||
NOTE: ``raw`` is only for debugging in-process; never persist it verbatim
|
||||
(may contain the moderated content / vendor internals). Compliance logging
|
||||
stores a summary (verdict + labels + request_id), not ``raw``.
|
||||
"""
|
||||
|
||||
verdict: Verdict
|
||||
labels: tuple[str, ...] = ()
|
||||
score: int | None = None
|
||||
keywords: tuple[str, ...] = ()
|
||||
request_id: str = ""
|
||||
raw: Any = field(default=None, repr=False, compare=False)
|
||||
|
||||
|
||||
class ModerationProvider(Protocol):
|
||||
def moderate_text(self, text: str, *, biz_type: str = "") -> ModerationResult:
|
||||
...
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
|
||||
try:
|
||||
from ahocorapy.keywordtree import KeywordTree
|
||||
except ImportError: # pragma: no cover - exercised only when dependency is missing.
|
||||
KeywordTree = None
|
||||
|
||||
from apps.moderation.models import SensitiveWord
|
||||
from apps.moderation.normalization import normalize_text
|
||||
from apps.moderation.versioning import get_sensitive_words_version
|
||||
|
||||
from .base import ModerationResult, ModerationUnavailableError, Verdict
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SensitiveWordMatch:
|
||||
id: int
|
||||
word: str
|
||||
normalized_word: str
|
||||
category: str
|
||||
|
||||
|
||||
class KeywordMatcher:
|
||||
def __init__(self, records: list[SensitiveWordMatch]) -> None:
|
||||
self._records_by_keyword: dict[str, list[SensitiveWordMatch]] = {}
|
||||
if KeywordTree is None:
|
||||
raise ModerationUnavailableError("ahocorapy is not installed")
|
||||
self._tree = KeywordTree(case_insensitive=False)
|
||||
for record in records:
|
||||
self._records_by_keyword.setdefault(record.normalized_word, []).append(record)
|
||||
for keyword in self._records_by_keyword:
|
||||
self._tree.add(keyword)
|
||||
self._tree.finalize()
|
||||
|
||||
def search(self, text: str) -> list[SensitiveWordMatch]:
|
||||
normalized = normalize_text(text)
|
||||
if not normalized:
|
||||
return []
|
||||
|
||||
matches: list[SensitiveWordMatch] = []
|
||||
seen: set[int] = set()
|
||||
for keyword, _index in self._tree.search_all(normalized):
|
||||
for record in self._records_by_keyword.get(keyword, ()):
|
||||
if record.id in seen:
|
||||
continue
|
||||
seen.add(record.id)
|
||||
matches.append(record)
|
||||
return matches
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _MatcherState:
|
||||
version: str
|
||||
matcher: KeywordMatcher
|
||||
|
||||
|
||||
_matcher_lock = threading.Lock()
|
||||
_matcher_state: _MatcherState | None = None
|
||||
|
||||
|
||||
def reset_keyword_matcher_cache() -> None:
|
||||
global _matcher_state
|
||||
with _matcher_lock:
|
||||
_matcher_state = None
|
||||
|
||||
|
||||
def _load_active_records() -> list[SensitiveWordMatch]:
|
||||
rows = SensitiveWord.objects.filter(
|
||||
is_active=True,
|
||||
action=SensitiveWord.Action.BLOCK,
|
||||
).only("id", "word", "normalized_word", "category")
|
||||
return [
|
||||
SensitiveWordMatch(
|
||||
id=row.id,
|
||||
word=row.word,
|
||||
normalized_word=row.normalized_word,
|
||||
category=row.category,
|
||||
)
|
||||
for row in rows.order_by("id")
|
||||
if row.normalized_word
|
||||
]
|
||||
|
||||
|
||||
def get_keyword_matcher() -> KeywordMatcher:
|
||||
global _matcher_state
|
||||
version = get_sensitive_words_version()
|
||||
state = _matcher_state
|
||||
if state is not None and state.version == version:
|
||||
return state.matcher
|
||||
|
||||
with _matcher_lock:
|
||||
state = _matcher_state
|
||||
if state is not None and state.version == version:
|
||||
return state.matcher
|
||||
matcher = KeywordMatcher(_load_active_records())
|
||||
_matcher_state = _MatcherState(version=version, matcher=matcher)
|
||||
return matcher
|
||||
|
||||
|
||||
class KeywordModerationProvider:
|
||||
def moderate_text(self, text: str, *, biz_type: str = "") -> ModerationResult:
|
||||
matches = get_keyword_matcher().search(text)
|
||||
if not matches:
|
||||
return ModerationResult(verdict=Verdict.PASS)
|
||||
|
||||
labels = tuple(dict.fromkeys(match.category for match in matches))
|
||||
keywords = tuple(dict.fromkeys(match.word for match in matches))
|
||||
word_ids = tuple(match.id for match in matches)
|
||||
return ModerationResult(
|
||||
verdict=Verdict.BLOCK,
|
||||
labels=labels,
|
||||
keywords=keywords,
|
||||
raw={"word_ids": word_ids},
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Prompt moderation orchestration.
|
||||
|
||||
T-604 只做本地关键词 prompt 审核:在图片下载、定价、预扣点和上游调用之前执行。
|
||||
MVP 不做输出审核、不做图片审核,也不保存用户 prompt 原文。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from . import policy
|
||||
from .providers import ModerationError, ModerationResult, Verdict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModerationOutcome:
|
||||
stage: str
|
||||
verdict: Verdict
|
||||
blocked: bool
|
||||
labels: tuple[str, ...] = ()
|
||||
keywords: tuple[str, ...] = ()
|
||||
matched_word_ids: tuple[int, ...] = ()
|
||||
request_id: str = ""
|
||||
reason: str = ""
|
||||
result: ModerationResult | None = field(default=None, repr=False)
|
||||
|
||||
|
||||
def _passed(stage: str) -> ModerationOutcome:
|
||||
return ModerationOutcome(stage=stage, verdict=Verdict.PASS, blocked=False)
|
||||
|
||||
|
||||
def _on_provider_failure(stage: str, exc: Exception) -> ModerationOutcome:
|
||||
"""provider 不可用时的兜底:fail-closed → BLOCK,fail-open → PASS。"""
|
||||
if policy.fail_closed():
|
||||
logger.warning("moderation fail-closed at %s: %s", stage, exc.__class__.__name__)
|
||||
return ModerationOutcome(
|
||||
stage=stage,
|
||||
verdict=Verdict.BLOCK,
|
||||
blocked=True,
|
||||
reason=f"fail-closed: {exc.__class__.__name__}",
|
||||
)
|
||||
logger.warning("moderation fail-open at %s: %s", stage, exc.__class__.__name__)
|
||||
return ModerationOutcome(stage=stage, verdict=Verdict.PASS, blocked=False, reason="fail-open")
|
||||
|
||||
|
||||
def _evaluate(stage: str, call) -> ModerationOutcome:
|
||||
if not policy.moderation_enabled():
|
||||
return _passed(stage)
|
||||
try:
|
||||
result = call()
|
||||
except ModerationError as exc:
|
||||
outcome = _on_provider_failure(stage, exc)
|
||||
_record(outcome)
|
||||
return outcome
|
||||
|
||||
outcome = ModerationOutcome(
|
||||
stage=stage,
|
||||
verdict=result.verdict,
|
||||
blocked=policy.verdict_is_blocking(result.verdict),
|
||||
labels=result.labels,
|
||||
keywords=result.keywords,
|
||||
matched_word_ids=tuple(result.raw.get("word_ids", ())) if isinstance(result.raw, dict) else (),
|
||||
request_id=result.request_id,
|
||||
result=result,
|
||||
)
|
||||
_record(outcome)
|
||||
return outcome
|
||||
|
||||
|
||||
def _record(outcome: ModerationOutcome) -> None:
|
||||
logger.info(
|
||||
"moderation stage=%s verdict=%s blocked=%s labels=%s word_ids=%s request_id=%s",
|
||||
outcome.stage,
|
||||
outcome.verdict.value,
|
||||
outcome.blocked,
|
||||
",".join(outcome.labels),
|
||||
",".join(str(word_id) for word_id in outcome.matched_word_ids),
|
||||
outcome.request_id,
|
||||
)
|
||||
|
||||
|
||||
def moderate_prompt(*, user=None, api_key=None, prompt: str) -> ModerationOutcome:
|
||||
return _evaluate(
|
||||
"prompt",
|
||||
lambda: policy.get_text_provider().moderate_text(
|
||||
prompt, biz_type=policy.biz_type("input")
|
||||
),
|
||||
)
|
||||
|
||||
def moderate_input(*, user=None, prompt: str, **kwargs) -> ModerationOutcome:
|
||||
return moderate_prompt(user=user, prompt=prompt)
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .models import SensitiveWord
|
||||
from .versioning import bump_sensitive_words_version
|
||||
|
||||
|
||||
@receiver(post_save, sender=SensitiveWord)
|
||||
@receiver(post_delete, sender=SensitiveWord)
|
||||
def invalidate_sensitive_words_cache(**kwargs) -> None:
|
||||
bump_sensitive_words_version()
|
||||
try:
|
||||
from .providers.keyword import reset_keyword_matcher_cache
|
||||
except Exception:
|
||||
return
|
||||
reset_keyword_matcher_cache()
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from .models import SensitiveWord
|
||||
from .normalization import normalize_text
|
||||
from .providers.keyword import reset_keyword_matcher_cache
|
||||
from .services import moderate_prompt
|
||||
from .versioning import get_sensitive_words_version
|
||||
|
||||
|
||||
@override_settings(MODERATION_CACHE_VERSION_KEY="test:moderation:sensitive_words:version")
|
||||
class SensitiveWordNormalizationTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
reset_keyword_matcher_cache()
|
||||
|
||||
def tearDown(self):
|
||||
reset_keyword_matcher_cache()
|
||||
cache.clear()
|
||||
|
||||
def test_normalize_text_removes_separators_zero_width_and_case(self):
|
||||
self.assertEqual(normalize_text("Bad-\u200b Word"), "badword")
|
||||
self.assertEqual(normalize_text("敏 感-词"), normalize_text("敏感词"))
|
||||
|
||||
def test_sensitive_word_saves_normalized_word_and_rejects_empty_normalized_word(self):
|
||||
word = SensitiveWord.objects.create(word=" 敏 感-词 ", category="")
|
||||
|
||||
self.assertEqual(word.category, "custom")
|
||||
self.assertEqual(word.normalized_word, "敏感词")
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
SensitiveWord.objects.create(word=" - \u200b ")
|
||||
|
||||
def test_word_save_bumps_shared_cache_version(self):
|
||||
before = get_sensitive_words_version()
|
||||
|
||||
SensitiveWord.objects.create(word="敏感词")
|
||||
|
||||
self.assertNotEqual(get_sensitive_words_version(), before)
|
||||
|
||||
|
||||
@override_settings(
|
||||
MODERATION_ENABLED=True,
|
||||
MODERATION_PROVIDER="keyword",
|
||||
MODERATION_CACHE_VERSION_KEY="test:moderation:sensitive_words:version",
|
||||
)
|
||||
class KeywordModerationTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
reset_keyword_matcher_cache()
|
||||
|
||||
def tearDown(self):
|
||||
reset_keyword_matcher_cache()
|
||||
cache.clear()
|
||||
|
||||
def test_prompt_blocks_normalized_keyword_without_storing_prompt(self):
|
||||
word = SensitiveWord.objects.create(word="敏感词", category="policy")
|
||||
prompt = "请处理敏-感\u200b 词内容"
|
||||
|
||||
with self.assertLogs("apps.moderation.services", level="INFO") as logs:
|
||||
outcome = moderate_prompt(prompt=prompt)
|
||||
|
||||
self.assertTrue(outcome.blocked)
|
||||
self.assertEqual(outcome.labels, ("policy",))
|
||||
self.assertEqual(outcome.keywords, ("敏感词",))
|
||||
self.assertEqual(outcome.matched_word_ids, (word.id,))
|
||||
self.assertNotIn(prompt, "\n".join(logs.output))
|
||||
|
||||
def test_non_matching_prompt_passes(self):
|
||||
SensitiveWord.objects.create(word="敏感词")
|
||||
|
||||
outcome = moderate_prompt(prompt="正常业务标题")
|
||||
|
||||
self.assertFalse(outcome.blocked)
|
||||
|
||||
def test_matcher_rebuilds_after_shared_version_changes(self):
|
||||
self.assertFalse(moderate_prompt(prompt="新增词").blocked)
|
||||
|
||||
SensitiveWord.objects.create(word="新增词")
|
||||
|
||||
self.assertTrue(moderate_prompt(prompt="这个提示包含新增词").blocked)
|
||||
|
||||
|
||||
@override_settings(
|
||||
MODERATION_ENABLED=False,
|
||||
MODERATION_PROVIDER="keyword",
|
||||
MODERATION_CACHE_VERSION_KEY="test:moderation:sensitive_words:version",
|
||||
)
|
||||
class DisabledModerationTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
reset_keyword_matcher_cache()
|
||||
|
||||
def tearDown(self):
|
||||
reset_keyword_matcher_cache()
|
||||
cache.clear()
|
||||
|
||||
def test_disabled_moderation_is_noop(self):
|
||||
SensitiveWord.objects.create(word="敏感词")
|
||||
|
||||
outcome = moderate_prompt(prompt="敏感词")
|
||||
|
||||
self.assertFalse(outcome.blocked)
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
|
||||
def sensitive_words_version_key() -> str:
|
||||
return str(
|
||||
getattr(
|
||||
settings,
|
||||
"MODERATION_CACHE_VERSION_KEY",
|
||||
"moderation:sensitive_words:version",
|
||||
)
|
||||
or "moderation:sensitive_words:version"
|
||||
)
|
||||
|
||||
|
||||
def get_sensitive_words_version() -> str:
|
||||
return str(cache.get(sensitive_words_version_key()) or "0")
|
||||
|
||||
|
||||
def bump_sensitive_words_version() -> str:
|
||||
version = uuid4().hex
|
||||
cache.set(sensitive_words_version_key(), version, timeout=None)
|
||||
return version
|
||||
@@ -117,6 +117,14 @@ IMAGE_URL_MAX_REDIRECTS = env_int("IMAGE_URL_MAX_REDIRECTS", 3)
|
||||
IMAGE_URL_CONNECT_TIMEOUT_SECONDS = env_int("IMAGE_URL_CONNECT_TIMEOUT_SECONDS", 10)
|
||||
IMAGE_URL_READ_TIMEOUT_SECONDS = env_int("IMAGE_URL_READ_TIMEOUT_SECONDS", 60)
|
||||
RECHARGE_MAX_AMOUNT_CNY = env_decimal("RECHARGE_MAX_AMOUNT_CNY", "100000.00")
|
||||
MODERATION_ENABLED = env_bool("MODERATION_ENABLED", False)
|
||||
MODERATION_PROVIDER = os.environ.get("MODERATION_PROVIDER", "keyword").strip().lower()
|
||||
MODERATION_FAIL_CLOSED = env_bool("MODERATION_FAIL_CLOSED", True)
|
||||
MODERATION_BLOCK_ON_REVIEW = env_bool("MODERATION_BLOCK_ON_REVIEW", False)
|
||||
MODERATION_CACHE_VERSION_KEY = os.environ.get(
|
||||
"MODERATION_CACHE_VERSION_KEY",
|
||||
"moderation:sensitive_words:version",
|
||||
)
|
||||
|
||||
|
||||
# Application definition
|
||||
@@ -135,6 +143,7 @@ INSTALLED_APPS = [
|
||||
'apps.portal',
|
||||
'apps.billing',
|
||||
'apps.ai',
|
||||
'apps.moderation',
|
||||
'apps.api',
|
||||
]
|
||||
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@
|
||||
| T-602 | django-admin 中文化(第 1-3 层) | - | 让运营后台表名/分组/框架文字显示中文,**仅显示层、不改业务逻辑与 DB 结构**。**第 1 层**:`config/settings.py` 设 `LANGUAGE_CODE='zh-hans'` + `USE_I18N=True`,使 Django 自带 admin 界面与内置 `auth`(用户/组/权限)中文化(无迁移)。**第 2 层**:各 app `AppConfig.verbose_name` 设中文分组名(`users`/`billing`/`ai`/`api`/`portal`),无迁移。**第 3 层**:各模型 `Meta.verbose_name`/`verbose_name_plural` 设中文表名(`User`/`UserWallet`/`ApiKey`/`PointsLedger`/`CallRecord`/`PricingRule`/`ExchangeRate`/`RechargeOrder`/`AiModel`/`ModelAlias`/`AiConfigAuditLog`)。**不含第 4 层字段级 `verbose_name`(字段列名保持英文,见 T-603)**。第 3 层会产出 `AlterModelOptions` 等**无 DB 变更**迁移;验收:`makemigrations`(仅 options 变更)→ `migrate` → `check` 0 issues → `test` 全绿 → `/admin/` 目视中文化,并在 `../progress.md` 留证据 | DONE |
|
||||
| T-603 | django-admin 中文化(第 4 层·字段级) | T-602 | 给各模型**字段**加中文 `verbose_name`,使 admin 列表/编辑页的字段标签显示中文,在 T-602(1-3 层)之上做。范围覆盖 `users`/`billing`/`ai`(及 portal 相关)模型的业务字段,例如 `points_balance`→点数余额、`key_prefix`→Key 前缀、`points_delta`→点数变动、`balance_after`→变动后余额、`amount_money`→金额、`exchange_rate`→汇率、`points_granted`→到账点数、`api_type`/`capabilities`/`is_active`/`created_at` 等;`admin.py` 里自定义显示方法(`@admin.display(description=...)`)同步中文。**仅显示层**:`verbose_name` 只改展示,**不改字段名、不改代码引用、英文字段名保持不变**;产出的是 `AlterField`(metadata-only,**无 DB schema 变更**)迁移。验收:`makemigrations`(仅 AlterField,无 schema 变更)→ `migrate` → `check` 0 issues → `test` 全绿 → `/admin/` 字段标签目视中文,并在 `../progress.md` 留证据。T-605 已解除邮箱验证口径阻塞;当前待在稳定 MySQL 测试库上补跑完整验证后再标 `DONE` | TODO |
|
||||
| T-605 | 落实「免邮箱验证」策略 | T-501 | 把 `ACCOUNT_EMAIL_VERIFICATION="none"`(注册即可用、不发验证邮件、邮箱仍必填且唯一)作为**既定策略**清理落地:① `config/settings.py` 把 `ACCOUNT_EMAIL_VERIFICATION = "none"#"mandatory"` 改为干净的 `"none"`(去行内注释),并把免验证下无实际意义的 `ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION` 设为 `False`;② 更新 `apps/portal/tests.py` 里假设 mandatory 的 2 条测试(signup 不再依赖验证邮件、未验证也可直接登录),改为断言「注册后可直接登录」;③ 同步全项目文档口径(`02-requirements`/`05-coding-rules`/`api`/`04-architecture`/`03-tech-stack`/`routes`/`env`/`deployment`/`00-ai-start-here` 中「邮箱验证」→「免邮箱验证,邮箱仍唯一」)。验收:`check` 通过,`apps.portal` 26 tests OK;全量 `manage.py test` 已尝试,跑到 95/131 后因远程 MySQL 连接超时失败(WinError 10051/10060),非本任务断言失败,详见 `../progress.md` | DONE |
|
||||
| T-604 | 中文敏感词本地过滤(本地 keyword provider) | T-302, T-401 | 按 [`moderation.md`](moderation.md) 实施。**范围收紧**:T-604 只做输入 prompt 的本地敏感词快筛,不做云内容安全、不做输出审核、不做图片审核。**关键时序**:serializer 后先审 prompt,命中即 `400 content_blocked`;不得先下载 `image_url`,不得预扣点,不写 `CallRecord` / `PointsLedger`,不调上游。**核心实现**:新增 `apps.moderation`、`SensitiveWord` 模型/admin/迁移、keyword provider、归一化管线、Aho-Corasick matcher;`ahocorapy` 作为候选依赖,编码前必须验证 PyPI 可用性和 API 形状。**缓存**:matcher 进程内缓存,词库变更用共享 cache 版本号失效,不能只靠 `post_save` signal;生产依赖共享 cache。**配置**:`MODERATION_ENABLED=false` 默认 no-op;启用时 `MODERATION_PROVIDER=keyword`;MVP `SensitiveWord.action` 只支持 `block`。**验收**:`check`/`test` 全绿,覆盖 no-op、命中拦截且不扣点/不建记录/不调上游/不下载图片、归一化防绕过、词库变更后 matcher 重建;真实词库数据不进仓库,`__pycache__` 不进 Git | TODO |
|
||||
| T-604 | 中文敏感词本地过滤(本地 keyword provider) | T-302, T-401 | 按 [`moderation.md`](moderation.md) 实施。**范围收紧**:T-604 只做输入 prompt 的本地敏感词快筛,不做云内容安全、不做输出审核、不做图片审核。**关键时序**:serializer 后先审 prompt,命中即 `400 content_blocked`;不得先下载 `image_url`,不得预扣点,不写 `CallRecord` / `PointsLedger`,不调上游。**核心实现**:新增 `apps.moderation`、`SensitiveWord` 模型/admin/迁移、keyword provider、归一化管线、Aho-Corasick matcher;`ahocorapy` 作为候选依赖,编码前必须验证 PyPI 可用性和 API 形状。**缓存**:matcher 进程内缓存,词库变更用共享 cache 版本号失效,不能只靠 `post_save` signal;生产依赖共享 cache。**配置**:`MODERATION_ENABLED=false` 默认 no-op;启用时 `MODERATION_PROVIDER=keyword`;MVP `SensitiveWord.action` 只支持 `block`。**验收**:`check`/`test` 全绿,覆盖 no-op、命中拦截且不扣点/不建记录/不调上游/不下载图片、归一化防绕过、词库变更后 matcher 重建;真实词库数据不进仓库,`__pycache__` 不进 Git | DONE |
|
||||
|
||||
## 里程碑
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
+4
-2
@@ -1,6 +1,6 @@
|
||||
# 内容安全与本地敏感词过滤
|
||||
|
||||
> T-604 的实施口径。目标是先做「中文 prompt 本地敏感词快筛」,命中在扣点和调上游之前拦截;云内容安全与输出审核保留接口,不在本任务做实。
|
||||
> T-604 的实施口径与实现记录。目标是先做「中文 prompt 本地敏感词快筛」,命中在扣点和调上游之前拦截;云内容安全与输出审核不在本任务做实。
|
||||
|
||||
## 定位
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
6. 预扣点、写 pending 调用和 consume 流水。
|
||||
7. 调上游,成功后写成功记录;失败走既有退点路径。
|
||||
|
||||
说明:当前 `apps/moderation` 骨架里有 `moderate_output_*` 钩子,但 T-604 不启用输出审核。输出审核若后续落地,必须重新定义「命中是否退点」的产品策略,并让 `MODERATION_REFUND_ON_OUTPUT_BLOCK` 真的生效;在 T-604 中不要暴露一个未实现的配置。
|
||||
说明:T-604 已清理早期骨架里的 `moderate_output_*` 钩子和云厂商 provider stub,只保留 `moderate_prompt()` 本地关键词入口。输出审核若后续落地,必须重新定义「命中是否退点」的产品策略,并让对应配置真的生效;在 T-604 中不要暴露一个未实现的配置。
|
||||
|
||||
## 数据模型
|
||||
|
||||
@@ -83,6 +83,8 @@ admin 要求:
|
||||
|
||||
## 验收
|
||||
|
||||
当前实现状态:DONE。已落地 `apps.moderation`、`SensitiveWord` 模型/admin/迁移、`ahocorapy` keyword provider、归一化管线和共享 cache 版本号失效;生成接口已改为 serializer 后先审 prompt,命中时不会下载 `image_url`、不会扣点、不会写调用/流水、不会调上游。
|
||||
|
||||
T-604 完成前至少覆盖:
|
||||
|
||||
- `MODERATION_ENABLED=false` 时生成接口 no-op,既有用例不变。
|
||||
|
||||
+27
@@ -1241,3 +1241,30 @@
|
||||
- 单独复跑失败起点 `apps.billing.tests.BillingServiceTests apps.billing.tests.BillingAdminTests apps.billing.tests.ConcurrentDebitTests`:17 条业务用例 OK;并发扣点用例本体打印 `ok` 后在测试库 flush 阶段再次因 MySQL 连接超时 / WinError 10060 记 ERROR。
|
||||
- 结论:T-605 相关测试已通过,全量单次绿受远程 MySQL 长跑稳定性阻塞,不是本任务断言失败。
|
||||
- 下一步:回到 T-603,在更稳定的 MySQL 测试库或网络条件下补跑完整验证,再决定是否标 `DONE`。
|
||||
|
||||
## 2026-07-06 T-604:中文敏感词本地过滤
|
||||
|
||||
- 状态:DONE。
|
||||
- 目标:按 `docs/moderation.md` 落地本地 keyword provider,只审核输入 prompt;命中必须在下载 `image_url`、解析别名、计费、预扣点和上游调用前返回 `400 content_blocked`。
|
||||
- 依赖确认:编码前已验证 `ahocorapy==1.6.2` 可安装,API 使用 `KeywordTree.add()` / `finalize()` / `search_all()`,`search_all()` 返回 `(matched_keyword, index)`。
|
||||
- 代码变更:
|
||||
- `config/settings.py` / `.env.example` / `requirements.txt`:接入 `apps.moderation`、`MODERATION_*` 配置和 `ahocorapy` 依赖。
|
||||
- `apps/moderation`:新增 `SensitiveWord` 模型/admin/迁移、归一化管线、共享 cache 版本号、signal 失效、keyword provider 与 prompt 审核 service;清理早期 Tencent stub 和输出审核骨架。
|
||||
- `apps/api/generation.py`:生成 title/image 的入口改为 serializer 后先 `moderate_prompt()`;命中直接 `content_blocked`,未命中才读取图片并继续别名、计费、扣点、上游调用。
|
||||
- `apps/api/tests.py` / `apps/moderation/tests.py`:覆盖 no-op、归一化、防绕过、词库版本重建、命中不下载图片/不扣点/不建记录/不调上游。
|
||||
- 文档变更:
|
||||
- `docs/moderation.md`:从设计口径更新为实现态,明确 T-604 已清理输出审核和云厂商 stub。
|
||||
- `docs/06-tasks.md`:T-604 标为 DONE。
|
||||
- `docs/current-state.md`:同步依赖、当前状态、配置基线、验证证据和任务看板。
|
||||
- 验证:
|
||||
- `py -3.12 -m py_compile config\settings.py apps\api\generation.py apps\api\tests.py apps\moderation\apps.py apps\moderation\models.py apps\moderation\normalization.py apps\moderation\policy.py apps\moderation\services.py apps\moderation\signals.py apps\moderation\versioning.py apps\moderation\providers\base.py apps\moderation\providers\keyword.py apps\moderation\tests.py`:通过。
|
||||
- `py -3.12 manage.py check`:通过,0 issues。
|
||||
- `py -3.12 manage.py makemigrations --check --dry-run`:通过,No changes detected。
|
||||
- `py -3.12 manage.py test apps.moderation --keepdb --noinput --verbosity 2`:通过,7 tests OK。
|
||||
- `py -3.12 manage.py test apps.api.tests.GenerateApiTests.test_blocked_prompt_returns_content_blocked_before_image_download_or_charge apps.api.tests.GenerateApiTests.test_disabled_moderation_does_not_block_matching_prompt --keepdb --noinput --verbosity 2`:通过,2 tests OK。
|
||||
- `py -3.12 manage.py test apps.api.tests.GenerateApiTests --keepdb --noinput --verbosity 2`:通过,16 tests OK。
|
||||
- 测试期仅保留 allauth 在 MySQL 条件唯一约束上的既有 `models.W036` 警告,本项目邮箱唯一性由 `user.email` 承担。
|
||||
- 注意:
|
||||
- 真实敏感词库数据不入仓库,需要运营在 admin 中维护或后续另做导入命令。
|
||||
- 生产多 worker 必须使用共享 cache(DatabaseCache/Redis/Memcached)承载 `MODERATION_CACHE_VERSION_KEY`,不能依赖 `LocMemCache` 做跨进程刷新。
|
||||
- 下一个看板任务仍是 T-603:在稳定 MySQL 测试库上补跑完整验证并收尾。
|
||||
|
||||
@@ -4,5 +4,6 @@ django-allauth>=65.18,<66
|
||||
PyMySQL>=1.1,<1.2
|
||||
cryptography>=42,<50
|
||||
requests>=2.32,<3
|
||||
ahocorapy>=1.6,<1.7
|
||||
wechatpayv3>=2.0,<3
|
||||
python-alipay-sdk>=3.4,<4
|
||||
|
||||
Reference in New Issue
Block a user