feat: implement t-604 keyword prompt moderation
This commit is contained in:
@@ -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},
|
||||
)
|
||||
Reference in New Issue
Block a user