feat(models): 组织、用户、角色、客户、会话与消息模型

按组织边界定义数据关系,补齐仓储层与通用工具,迁移脚本可重复执行。
This commit is contained in:
WangShen007 committed 2026-09-23 22:05:00 +08:00
1 parent 2aee6286d6
commit 5e0615f36d
126 files changed
+17436

No files matched your search

+574
View File
@@ -0,0 +1,574 @@
"""
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import re
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, Tuple
from enum import Enum
from app.core.logger import get_logger
logger = get_logger(__name__)
class GuardrailType(str, Enum):
"""Types of guardrails that can be applied"""
PII = "pii"
JAILBREAK = "jailbreak"
class GuardrailAction(str, Enum):
"""Actions to take when guardrail is triggered"""
BLOCK = "block"
REDACT = "redact"
WARNING = "warning"
LOG = "log"
class GuardrailResult:
"""Result of guardrail evaluation"""
def __init__(
self,
passed: bool,
guardrail_type: GuardrailType,
violations: List[str] = None,
redacted_text: Optional[str] = None,
confidence: float = 0.0
):
self.passed = passed
self.guardrail_type = guardrail_type
self.violations = violations or []
self.redacted_text = redacted_text
self.confidence = confidence
def to_dict(self) -> Dict:
"""Convert result to dictionary"""
return {
"passed": self.passed,
"guardrail_type": self.guardrail_type.value,
"violations": self.violations,
"redacted_text": self.redacted_text,
"confidence": self.confidence
}
class PIIDetector:
"""
Detector for Personally Identifiable Information (PII)
Detects common PII patterns like emails, phone numbers, SSNs, credit cards, etc.
"""
# Regex patterns for common PII types
PATTERNS = {
"email": r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b',
"phone": r'\b(?:\+?1[-.]?)?\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})\b',
"ssn": r'\b\d{3}-\d{2}-\d{4}\b',
"credit_card": r'\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b',
"ip_address": r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b',
"date_of_birth": r'\b(?:0?[1-9]|1[0-2])[/-](?:0?[1-9]|[12][0-9]|3[01])[/-](?:19|20)\d{2}\b',
# Add more patterns as needed
}
@staticmethod
def detect(text: str, action: GuardrailAction = GuardrailAction.BLOCK) -> GuardrailResult:
"""
Detect PII in text
Args:
text: Text to scan for PII
action: Action to take (BLOCK, REDACT, WARNING, LOG)
Returns:
GuardrailResult with detection results
"""
violations = []
redacted_text = text
for pii_type, pattern in PIIDetector.PATTERNS.items():
matches = re.finditer(pattern, text)
for match in matches:
violations.append(f"{pii_type} at position {match.start()} (length {len(match.group())})")
# Redact if action is REDACT
if action == GuardrailAction.REDACT:
redacted_text = redacted_text.replace(
match.group(),
f"[{pii_type.upper()}_REDACTED]"
)
passed = len(violations) == 0
# Calculate confidence based on pattern matching (simple heuristic)
confidence = 1.0 if violations else 0.0
logger.info(f"PII Detection - Passed: {passed}, Violations: {len(violations)}")
return GuardrailResult(
passed=passed,
guardrail_type=GuardrailType.PII,
violations=violations,
redacted_text=redacted_text if action == GuardrailAction.REDACT else None,
confidence=confidence
)
# --- Injection pattern sets -------------------------------------------------
#
# Two tiers. A strong (Tier A) pattern is high-precision evidence of a prompt
# injection and scores 1.0 on its own; weak (Tier B) signals are phrasings that
# only matter in combination and score 0.25 each. When the message contains
# technical content (code, logs, shell commands) the weak subtotal is halved —
# support chats are full of pasted snippets that echo these phrasings.
#
# Deliberately NOT detected — each is normal in a support conversation, and
# removing them is a contract backed one-for-one by tests
# (tests/utils/test_guardrails_jailbreak.py):
# "sudo" self-hosting / CLI help
# "debug mode", "developer mode" real product features (bare mention)
# "theoretically" ordinary hedging
# bare "DAN" the first name Dan (the old pattern matched
# it because text was lowercased first)
# "ignore/disregard my previous message|ticket|email"
# an override needs an instruction NOUN
# bare "what are your instructions"
# "...for returning an item?" is a product
# question; disclosure is the prompt policy's job
# Literal chat-template tokens. A human visitor never types these; they only
# appear when someone is smuggling a fake system/assistant turn.
_TEMPLATE_MARKER_PATTERNS = [
re.compile(r'<\|im_(?:start|end)\|>'),
re.compile(r'<\|eot_id\|>'),
re.compile(r'<\|(?:system|user|assistant|endoftext)\|>'),
re.compile(r'\[/?INST\]'),
re.compile(r'<<SYS>>'),
re.compile(r'\bBEGIN SYSTEM PROMPT\b', re.IGNORECASE),
re.compile(r'(?m)^\s*\[SYSTEM\]\s*:?\s*$', re.IGNORECASE),
]
# "ignore the previous..." only counts with an instruction noun in range:
# "ignore my previous message" is normal traffic, "ignore your previous
# instructions" is not.
_OVERRIDE_PATTERNS = [
re.compile(
r'\b(?:ignore|disregard|forget|override|bypass)\b[^.\n]{0,40}?'
r'\b(?:previous|prior|above|earlier|initial|original|system|all)\b[^.\n]{0,20}?'
r'\b(?:instructions?|rules?|prompts?|directives?|guidelines?)\b',
re.IGNORECASE,
),
re.compile(
r'\byour\s+(?:new|real|actual|true|updated)\s+'
r'(?:instructions?|rules?|system\s+prompt)\s+(?:are|is)\b',
re.IGNORECASE,
),
re.compile(
r'\bthis\s+(?:overrides|supersedes|replaces|cancels)\s+'
r'(?:all|any|everything|your|the)\b',
re.IGNORECASE,
),
]
_EXFIL_PATTERNS = [
re.compile(
r'\b(?:reveal|repeat|print|output|show|display|dump|recite|reproduce)\b'
r'[^.\n]{0,40}?'
r'\b(?:system\s+(?:prompt|message)|initial\s+(?:prompt|instructions)'
r'|original\s+instructions|instructions\s+verbatim|prompt\s+verbatim'
r'|everything\s+above)\b',
re.IGNORECASE,
),
]
_PERSONA_PATTERNS = [
re.compile(
r'\byou\s+are\s+now\s+(?:an?\s+)?'
r'(?:dan\b|unrestricted|unfiltered|jailbroken|uncensored'
r'|different\s+(?:assistant|ai|bot|model|persona))',
re.IGNORECASE,
),
re.compile(r'\bdo\s+anything\s+now\b', re.IGNORECASE),
# Case-sensitive on purpose: "DAN mode" is a jailbreak, "dan mode..." in
# lowercase prose is far more likely to be about a person called Dan.
re.compile(r'\bDAN\s+(?:mode|prompt|jailbreak)\b'),
re.compile(
r'\b(?:developer|dev|god|admin(?:istrator)?|unrestricted|jailbreak)\s+'
r'mode\s+(?:on|enabled|activated|engaged)\b',
re.IGNORECASE,
),
re.compile(
r'\bpretend\s+(?:you\s+are|to\s+be)\s+(?:an?\s+)?'
r'(?:unrestricted|uncensored|jailbroken|evil)',
re.IGNORECASE,
),
]
# Stable rule ids, stored in guardrail_events.rule and referenced by the
# runtime layer — named so the two cannot drift apart.
RULE_FRAME_TOKENS = "injection.frame_tokens"
RULE_OVERRIDE_INSTRUCTIONS = "injection.override_instructions"
RULE_PROMPT_EXFIL = "injection.prompt_exfil"
RULE_ROLE_HIJACK = "injection.role_hijack"
_STRONG_PATTERNS = {
RULE_FRAME_TOKENS: _TEMPLATE_MARKER_PATTERNS,
RULE_OVERRIDE_INSTRUCTIONS: _OVERRIDE_PATTERNS,
RULE_PROMPT_EXFIL: _EXFIL_PATTERNS,
RULE_ROLE_HIJACK: _PERSONA_PATTERNS,
}
_WEAK_SIGNAL_PATTERNS = [
re.compile(r'\bact\s+(?:as|like)\s+an?\b', re.IGNORECASE),
re.compile(r'\bfrom\s+now\s+on\b', re.IGNORECASE),
re.compile(r'\brole[- ]?play\b', re.IGNORECASE),
re.compile(r'\bpretend\s+(?:you\s+are|to\s+be)\b', re.IGNORECASE),
re.compile(r'\bhypothetically\b', re.IGNORECASE),
re.compile(r'\bfor\s+educational\s+purposes\b', re.IGNORECASE),
re.compile(
r'\bin\s+an?\s+(?:fictional|hypothetical|alternate)\s+'
r'(?:scenario|world|universe)\b',
re.IGNORECASE,
),
re.compile(
r'\bno\s+(?:restrictions?|limitations?|rules|boundaries|filters?)\b',
re.IGNORECASE,
),
re.compile(
r'\bwithout\s+(?:any\s+)?(?:restrictions?|limits?|limitations?|filters?)\b',
re.IGNORECASE,
),
re.compile(
r'\byou\s+(?:must|should|have\s+to)\s+(?:obey|follow|comply)\b',
re.IGNORECASE,
),
re.compile(r'\bunrestricted\s+mode\b', re.IGNORECASE),
re.compile(
r'\bwhat\s+(?:is|are)\s+your\s+(?:instructions|rules|system\s+prompt)\b',
re.IGNORECASE,
),
re.compile(r'\btotally\s+different\s+persona\b', re.IGNORECASE),
]
# Markers of pasted technical content (code, logs, shell). Their presence
# halves the weak-signal score — see the suppressor note above.
_TECHNICAL_CONTEXT_PATTERNS = [
re.compile(r'```'),
re.compile(r'Traceback \(most recent call last\)'),
re.compile(r'File "[^"]+", line \d+'),
re.compile(r'(?m)^\s*(?:\$ |sudo |npm |pip |docker |curl |git )'),
re.compile(r'\{"'),
re.compile(r'\b(?:GET|POST|PUT|PATCH|DELETE)\s+/\S+'),
re.compile(r'\bat\s+\w+\('),
re.compile(r'\S+\.(?:log|ya?ml|env|conf)\b'),
]
_WEAK_SIGNAL_WEIGHT = 0.25
_TECHNICAL_SUPPRESSION = 0.5
# --- Off-topic exercise briefs ----------------------------------------------
#
# Prompt text alone does not hold here. A long, well-structured algorithms or
# system-design brief was answered in full in production despite the policy
# forbidding it, three wordings running — the model reads the length and polish
# as "technical depth" and helps. This detector catches only that narrow shape,
# deterministically, before inference.
#
# Precision comes from the THIRD condition: a real customer question about the
# product almost always names something about the business or its failure mode.
# An exercise brief names none of it. All three must hold, so a pasted traceback
# ("error", "exception"), a long integration question ("webhook", "api key") or
# anything naming the org is never caught.
RULE_OFFTOPIC_EXERCISE = "offtopic.exercise_brief"
_EXERCISE_MIN_CHARS = 500
_EXERCISE_MIN_MARKERS = 4
_EXERCISE_MARKERS = [
re.compile(r'\bO\(\s*(?:1|n|log\s*n|n\s*log\s*n|n\^?2)\s*\)', re.IGNORECASE),
re.compile(r'\b(?:time|space)\s+complexity\b', re.IGNORECASE),
re.compile(r'\bdata\s+structure\b', re.IGNORECASE),
re.compile(r'\balgorithm\b', re.IGNORECASE),
re.compile(r'(?m)^\s*constraints\s*:', re.IGNORECASE),
re.compile(r'\byour\s+task\s*:', re.IGNORECASE),
re.compile(r'\bdesign\s+(?:a|an)\b[^.\n]{0,60}\bthat\s+supports\b', re.IGNORECASE),
re.compile(r'\bimplement\s+the\b', re.IGNORECASE),
re.compile(r'(?m)^\s*bonus\s*:', re.IGNORECASE),
re.compile(r'\bedge\s+cases?\b', re.IGNORECASE),
re.compile(r'\bgiven\s+(?:an?\s+)?(?:array|string|list|integer|tree|graph)\b', re.IGNORECASE),
re.compile(r'\bduplicates?\s+(?:are\s+)?allowed\b', re.IGNORECASE),
re.compile(r'\bworst[- ]case\b', re.IGNORECASE),
]
# Any one of these means the message is anchored to a real business context.
_BUSINESS_ANCHORS = [
re.compile(r'\b(?:pricing|price|plan|billing|invoice|subscription|refund|trial|upgrade|quote)\b', re.IGNORECASE),
re.compile(r'\b(?:account|login|log in|sign ?in|sign ?up|password|onboard)\b', re.IGNORECASE),
re.compile(r'\b(?:install|setup|set up|deploy|self[- ]host|docker|compose|container)\b', re.IGNORECASE),
re.compile(r'\b(?:widget|dashboard|webhook|api[- ]key|integrat|plugin|embed|sdk)\w*', re.IGNORECASE),
re.compile(r'\b(?:error|exception|traceback|stack trace|bug|crash|fail|broken|not working)\w*', re.IGNORECASE),
re.compile(r'\b(?:support|ticket|agent|chatbot|knowledge base|faq|inbox|conversation)\b', re.IGNORECASE),
re.compile(r'\b(?:your (?:product|service|platform|tool|app)|you offer|do you support)\b', re.IGNORECASE),
]
def detect_offtopic_exercise(text: str, business_terms: Sequence[str] = ()) -> bool:
"""True for a long, self-contained exercise brief with no business context.
Deliberately narrow: it exists to catch the generic-LLM-task abuse that the
prompt policy cannot hold, not to classify topics in general.
"""
body = text or ""
if len(body) < _EXERCISE_MIN_CHARS:
return False
markers = sum(1 for p in _EXERCISE_MARKERS if p.search(body))
if markers < _EXERCISE_MIN_MARKERS:
return False
if any(p.search(body) for p in _BUSINESS_ANCHORS):
return False
lowered = body.lower()
return not any(term and term.lower() in lowered for term in business_terms)
@dataclass(frozen=True)
class InjectionResult:
"""Strong-signal (Tier A) injection evidence for the global guardrail layer."""
rule_ids: Tuple[str, ...]
matched: Tuple[str, ...] # short matched tokens only — safe for plain-JSON storage
@property
def triggered(self) -> bool:
return bool(self.rule_ids)
@property
def has_template_marker(self) -> bool:
return RULE_FRAME_TOKENS in self.rule_ids
def detect_injection(text: str) -> InjectionResult:
"""Scan text for strong injection signals only (no weak/contextual scoring).
This is the pre-LLM entry point used by the global chat guardrail: weak
signals are advisory and never block outside an explicitly configured
workflow guardrails node, so they are not reported here.
"""
rule_ids: List[str] = []
matched: List[str] = []
for rule_id, patterns in _STRONG_PATTERNS.items():
for pattern in patterns:
match = pattern.search(text or "")
if match:
if rule_id not in rule_ids:
rule_ids.append(rule_id)
matched.append(match.group()[:60])
break # one hit per rule id is enough
return InjectionResult(rule_ids=tuple(rule_ids), matched=tuple(matched))
class JailbreakDetector:
"""
Detector for jailbreak / prompt-injection attempts.
Scoring counts DISTINCT patterns (repeating one phrase scores once):
every strong hit scores 1.0, weak signals 0.25 each — halved when the
message contains technical content. `passed = score < sensitivity`, so at
the default 0.7 a single strong hit or three clean weak signals fail.
"""
@staticmethod
def detect(text: str, sensitivity: float = 0.7) -> GuardrailResult:
"""
Detect jailbreak attempts in text
Args:
text: Text to scan for jailbreak attempts
sensitivity: Detection sensitivity (0.0 to 1.0, default 0.7)
Returns:
GuardrailResult with detection results
"""
violations = []
strong = detect_injection(text)
for rule_id, token in zip(strong.rule_ids, strong.matched):
violations.append(f"Jailbreak pattern [{rule_id}]: '{token}'")
weak_hits = 0
for pattern in _WEAK_SIGNAL_PATTERNS:
match = pattern.search(text or "")
if match:
weak_hits += 1
violations.append(f"Suspicious phrase: '{match.group()[:60]}'")
suppression = 1.0
if weak_hits and any(p.search(text or "") for p in _TECHNICAL_CONTEXT_PATTERNS):
suppression = _TECHNICAL_SUPPRESSION
confidence = min(
1.0,
len(strong.rule_ids) + weak_hits * _WEAK_SIGNAL_WEIGHT * suppression,
)
passed = confidence < sensitivity
logger.info(
f"Jailbreak Detection - Passed: {passed}, "
f"Confidence: {confidence:.2f}, "
f"Strong Rules: {list(strong.rule_ids)}, "
f"Weak Signals: {weak_hits}"
)
return GuardrailResult(
passed=passed,
guardrail_type=GuardrailType.JAILBREAK,
violations=violations,
redacted_text=None,
confidence=confidence
)
class GuardrailsEngine:
"""
Main guardrails engine that orchestrates multiple guardrail checks
"""
def __init__(self):
self.pii_detector = PIIDetector()
self.jailbreak_detector = JailbreakDetector()
def evaluate(
self,
text: str,
enabled_guardrails: List[GuardrailType],
actions: Dict[GuardrailType, GuardrailAction] = None,
jailbreak_sensitivity: float = 0.7
) -> Tuple[bool, List[GuardrailResult]]:
"""
Evaluate text against enabled guardrails
Args:
text: Text to evaluate
enabled_guardrails: List of guardrail types to check
actions: Dictionary mapping guardrail types to actions
jailbreak_sensitivity: Sensitivity for jailbreak detection (0.0-1.0)
Returns:
Tuple of (all_passed, list_of_results)
"""
if actions is None:
actions = {}
results = []
all_passed = True
for guardrail_type in enabled_guardrails:
action = actions.get(guardrail_type, GuardrailAction.BLOCK)
if guardrail_type == GuardrailType.PII:
result = self.pii_detector.detect(text, action)
results.append(result)
if not result.passed:
all_passed = False
elif guardrail_type == GuardrailType.JAILBREAK:
result = self.jailbreak_detector.detect(text, jailbreak_sensitivity)
results.append(result)
if not result.passed:
all_passed = False
return all_passed, results
def get_default_block_message(self, results: List[GuardrailResult]) -> str:
"""
Generate a user-friendly message when content is blocked
Args:
results: List of guardrail results
Returns:
Message to display to user
"""
failed_guardrails = [r for r in results if not r.passed]
if not failed_guardrails:
return "Your message has been processed successfully."
if GuardrailType.PII in [r.guardrail_type for r in failed_guardrails]:
return (
"I noticed your message contains sensitive personal information. "
"For your privacy and security, please avoid sharing personal details "
"like email addresses, phone numbers, or other identifying information."
)
if GuardrailType.JAILBREAK in [r.guardrail_type for r in failed_guardrails]:
return (
"I'm designed to help you with legitimate questions and tasks. "
"I can't process requests that ask me to ignore my guidelines or "
"behave in ways I'm not designed for. How else can I assist you?"
)
return (
"I'm unable to process your message as it doesn't meet our "
"content guidelines. Please rephrase your request."
)
# Convenience function for quick guardrail checks
def check_guardrails(
text: str,
guardrail_types: List[str],
pii_action: str = "block",
jailbreak_sensitivity: float = 0.7
) -> Tuple[bool, List[Dict], str]:
"""
Convenience function to check text against guardrails
Args:
text: Text to check
guardrail_types: List of guardrail type strings ("pii", "jailbreak")
pii_action: Action for PII ("block", "redact", "warning", "log")
jailbreak_sensitivity: Sensitivity for jailbreak detection
Returns:
Tuple of (passed, results_list, block_message)
"""
engine = GuardrailsEngine()
# Convert string types to enums
enabled_guardrails = []
actions = {}
for gt in guardrail_types:
try:
guardrail_type = GuardrailType(gt.lower())
enabled_guardrails.append(guardrail_type)
if guardrail_type == GuardrailType.PII:
actions[guardrail_type] = GuardrailAction(pii_action.lower())
except ValueError:
logger.warning(f"Unknown guardrail type: {gt}")
passed, results = engine.evaluate(
text,
enabled_guardrails,
actions,
jailbreak_sensitivity
)
block_message = engine.get_default_block_message(results)
results_dict = [r.to_dict() for r in results]
return passed, results_dict, block_message