Files

184 lines
6.6 KiB
Go
Raw Permalink Normal View History

2026-08-26 06:41:07 +08:00
package contentpolicy
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"data-terminal-cloud/internal/outbox"
)
type Decision struct {
AllowOrdinaryStory bool
Sensitivity string
Reason string
ProviderRef string
RequestRef string
}
type Classifier interface {
Classify(context.Context, string) (Decision, error)
}
type LocalClassifier struct{}
var (
verificationCodePattern = regexp.MustCompile(`(?i)(验证码|校验码|动态码|verification\s*code|otp)[^0-9]{0,8}[0-9]{4,8}`)
accountNumberPattern = regexp.MustCompile(`(?:^|[^0-9])[0-9]{12,19}(?:[^0-9]|$)`)
promptInjectionPattern = regexp.MustCompile(`(?i)(忽略.{0,16}(以上|之前|所有).{0,16}(指令|规则|提示)|(?:泄露|输出|显示).{0,20}(系统提示|密钥|其他.{0,8}(用户|长者|家庭))|ignore\s+(all\s+)?(previous|prior)\s+instructions|reveal.{0,20}(system\s+prompt|secret|other\s+user))`)
)
var sensitiveMedicalTerms = []string{"艾滋", "hiv", "癌症", "肿瘤", "精神病", "精神科", "诊断结果", "病理报告"}
var paymentTerms = []string{"银行卡", "银行账号", "信用卡", "支付密码", "取款密码", "转账"}
// Classify applies a conservative local safety baseline before ordinary story
// generation. It is intentionally rule-based and must be supplemented by a
// production content-safety service and evaluated policy set.
func Classify(content string) Decision {
normalized := strings.ToLower(strings.TrimSpace(content))
if verificationCodePattern.MatchString(normalized) {
return localDecision(false, "high", "verification_code")
}
if accountNumberPattern.MatchString(normalized) && containsAny(normalized, paymentTerms) {
return localDecision(false, "high", "financial_account")
}
if promptInjectionPattern.MatchString(normalized) {
return localDecision(false, "high", "prompt_injection")
}
if containsAny(normalized, sensitiveMedicalTerms) {
return localDecision(false, "sensitive", "sensitive_medical")
}
return localDecision(true, "normal", "")
}
func (LocalClassifier) Classify(_ context.Context, content string) (Decision, error) {
return Classify(content), nil
}
func localDecision(allow bool, sensitivity, reason string) Decision {
return Decision{
AllowOrdinaryStory: allow,
Sensitivity: sensitivity,
Reason: reason,
ProviderRef: "local-content-safety-policy",
}
}
type ManagedClassifier struct {
endpoint string
apiKey string
policyRef string
client *http.Client
}
func NewManagedClassifier(endpoint, apiKey, policyRef string, timeout time.Duration) (*ManagedClassifier, error) {
endpoint = strings.TrimSpace(endpoint)
apiKey = strings.TrimSpace(apiKey)
policyRef = strings.TrimSpace(policyRef)
parsed, err := url.Parse(endpoint)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, errors.New("content safety endpoint must be an absolute HTTP(S) URL")
}
if apiKey == "" || policyRef == "" || timeout <= 0 {
return nil, errors.New("content safety API key, policy reference and positive timeout are required")
}
return &ManagedClassifier{
endpoint: endpoint, apiKey: apiKey, policyRef: policyRef,
client: &http.Client{Timeout: timeout},
}, nil
}
// Classify always applies the local high-risk rules before sending content to
// the managed policy service. Local matches therefore never leave the process.
func (c *ManagedClassifier) Classify(ctx context.Context, content string) (Decision, error) {
if decision := Classify(content); !decision.AllowOrdinaryStory {
return decision, nil
}
requestBody, err := json.Marshal(struct {
PolicyRef string `json:"policy_ref"`
Content string `json:"content"`
}{PolicyRef: c.policyRef, Content: content})
if err != nil {
return Decision{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(requestBody))
if err != nil {
return Decision{}, fmt.Errorf("build content safety request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "voice-platform-content-safety/1.0")
resp, err := c.client.Do(req)
if err != nil {
return Decision{}, outbox.Retryable(fmt.Errorf("call content safety service: %w", err))
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil {
return Decision{}, outbox.Retryable(fmt.Errorf("read content safety response: %w", readErr))
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
requestErr := fmt.Errorf("content safety service returned HTTP %d", resp.StatusCode)
if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooEarly || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
return Decision{}, outbox.Retryable(requestErr)
}
return Decision{}, requestErr
}
var result struct {
Decision string `json:"decision"`
Sensitivity string `json:"sensitivity"`
ReasonCode string `json:"reason_code"`
RequestRef string `json:"request_ref"`
}
if err = json.Unmarshal(body, &result); err != nil {
return Decision{}, outbox.Retryable(errors.New("content safety response is not valid JSON"))
}
result.Decision = strings.ToUpper(strings.TrimSpace(result.Decision))
result.Sensitivity = strings.ToLower(strings.TrimSpace(result.Sensitivity))
result.ReasonCode = strings.ToLower(strings.TrimSpace(result.ReasonCode))
if (result.Decision != "ALLOW" && result.Decision != "BLOCK" && result.Decision != "REVIEW") ||
(result.Sensitivity != "normal" && result.Sensitivity != "sensitive" && result.Sensitivity != "high") ||
!validReasonCode(result.ReasonCode, result.Decision == "ALLOW") {
return Decision{}, outbox.Retryable(errors.New("content safety response failed schema validation"))
}
return Decision{
AllowOrdinaryStory: result.Decision == "ALLOW",
Sensitivity: result.Sensitivity,
Reason: result.ReasonCode,
ProviderRef: "managed-content-safety:" + c.policyRef,
RequestRef: strings.TrimSpace(result.RequestRef),
}, nil
}
func validReasonCode(value string, allowEmpty bool) bool {
if value == "" {
return allowEmpty
}
if len(value) > 64 {
return false
}
for _, character := range value {
if (character < 'a' || character > 'z') && (character < '0' || character > '9') && character != '_' && character != '-' {
return false
}
}
return true
}
func containsAny(content string, terms []string) bool {
for _, term := range terms {
if strings.Contains(content, term) {
return true
}
}
return false
}