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 }