Files
tamabox/internal/security/censor/policy.go
T
2026-09-10 18:04:19 +08:00

217 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2022 E99p1ant. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package censor
// Strictness 内容审核严格度档位。
//
// 背景:审核服务(本项目为七牛云文本审核)对「辱骂」等维度的召回偏激进,
// 正常提问/对话经常被判为不通过而被整条拒绝。但我们既不能让审核服务判得更准,
// 也不能让它判得更松(七牛文本审核不支持白名单,自定义词库需提交工单),
// 唯一能控制的是「审核命中之后到底拦不拦」。
//
// 因此把「是否拦截」从审核服务自身的 Pass 判定中剥离出来,改为由本档位策略裁决,
// 让箱主在「少误伤」与「少漏放」之间自行取舍。
type Strictness string
const (
// StrictnessLax 宽松:仅拦截底线维度,其余一律放行。误伤最小,漏放最多。
StrictnessLax Strictness = "lax"
// StrictnessStandard 标准:底线维度 + 高置信度的辱骂/违禁。
StrictnessStandard Strictness = "standard"
// StrictnessStrict 严格:底线维度 + 全部非正常维度(不限置信度)+ 采信审核服务的 block 建议。
//
// 这是三个档位中最严的一档,且是唯一保留「采信审核服务整体 block 建议」的档位。
// 改造前的拦截条件是 `!Pass`(即 涉政/暴恐/色情 命中,或审核服务建议为 block),
// 该条件可被本档完全覆盖,但本档会额外拦截全部带维度的非正常内容(如 review 的辱骂)。
// 换言之:严格档 ⊇ 改造前的行为,是「至少与改造前一样严」,并非完全等价。
StrictnessStrict Strictness = "strict"
)
// DefaultStrictness 缺省档位。
//
// 取 strict 的取舍:本能力只用于「让箱主把审核调松」,从不用于「在用户未选择时把审核调松」。
// 存量账号从未设置过该值,缺省落到最严档,保证部署本功能这一动作本身不会让任何账号
// 的拦截面变小(合规上更保守);想减少误伤的箱主再显式切到 标准 / 宽松。
//
// ⚠️ 注意方向:因为严格档比改造前更严(见 StrictnessStrict 注释),缺省为 strict 意味着
// 部署后默认拦截面会略有扩大 —— 这是刻意的取舍(已于 2026-09-10 确认维持 strict):
// 合规上宁紧勿松,且「调松」应当是箱主的显式动作,部署本身不应悄悄放宽任何账号。
// 若哪天希望默认更少误伤,把下面这行改成 StrictnessStandard 即可(一行常量)。
const DefaultStrictness = StrictnessStrict
// Suggestion 审核服务自身给出的管控建议取值。
const (
SuggestionBlock = "block"
SuggestionReview = "review"
SuggestionPass = "pass"
)
// baselineBlockLabels 底线拦截维度:涉政 / 暴恐 / 色情 / 自定义词库。
//
// 这是合规底线:任何档位下都强制生效、不受置信度门槛约束、不可关闭,
// 也不能通过任何用户配置绕过 —— 见 Decide 中它始终排在最先、无条件返回拦截。
//
// ForbiddenTypeCustomized 归入底线的原因:它是运营方在审核服务控制台(阿里云)
// 显式配置的词库命中,属「用户明写的规则」,与箱主自定义屏蔽词同一性质,
// 不应被档位调低后放行。运营方若要放开,正确的做法是调整控制台词库,而不是调档位。
var baselineBlockLabels = map[ForbiddenType]struct{}{
ForbiddenTypePolitics: {},
ForbiddenTypeTerrorism: {},
ForbiddenTypePorn: {},
ForbiddenTypeCustomized: {},
}
// abuseScoreFloor 标准档位下「辱骂」维度生效所需的最低置信度。
//
// 辱骂是审核服务误伤最集中的维度(普通吐槽、语气较冲的表达常被判为辱骂),
// 因此标准档要求高置信度才拦截;严格档则为任意置信度。
//
// 注:置信度的来源因服务商而异。七牛直接给 0~1 的 score;阿里云内容安全 2.0 只给
// 整条内容的 riskLevel,折算为 1 / 0.6 / 0.3(high / medium / low),因此本阈值
// 在阿里云下的实际语义是「标准档只拦 riskLevel=high 的辱骂」。
const abuseScoreFloor = 0.9
// levelPolicy 单一档位的拦截规则。
type levelPolicy struct {
// blockLabels 该档位额外拦截的维度 -> 生效所需最低置信度(0 表示不限置信度)。
// 注意:底线维度不在此表中,由 Decide 无条件前置判定。
blockLabels map[ForbiddenType]float64
// blockOnSuggestion 是否采信审核服务自身的 block 建议。
//
// 该建议不携带具体维度(可能是任何原因),无法做置信度收敛,误伤面最大,
// 也是此前正常对话被整条拒绝的主要来源,因此仅严格档启用。
blockOnSuggestion bool
}
var strictnessPolicies = map[Strictness]levelPolicy{
// 宽松:只留底线,其余全部放行。
StrictnessLax: {
blockLabels: map[ForbiddenType]float64{},
blockOnSuggestion: false,
},
// 标准:底线 + 高置信度辱骂 + 违禁。
StrictnessStandard: {
blockLabels: map[ForbiddenType]float64{
ForbiddenTypeAbuse: abuseScoreFloor,
ForbiddenTypeContraband: 0,
},
blockOnSuggestion: false,
},
// 严格:底线 + 全部非正常维度(不限置信度)+ 采信审核服务 block 建议。
StrictnessStrict: {
blockLabels: map[ForbiddenType]float64{
ForbiddenTypeSpam: 0,
ForbiddenTypeAd: 0,
ForbiddenTypeAbuse: 0,
ForbiddenTypeFlood: 0,
ForbiddenTypeContraband: 0,
ForbiddenTypeMeaningless: 0,
ForbiddenTypeHarmful: 0,
},
blockOnSuggestion: true,
},
}
// Decision 对单条内容的最终裁决。
type Decision int
const (
// DecisionAllow 放行。
DecisionAllow Decision = iota
// DecisionBlock 拒绝提交。
DecisionBlock
)
// Blocked 是否应当拒绝提交。
func (d Decision) Blocked() bool { return d == DecisionBlock }
// IsValidStrictness 判断取值是否为合法档位,供后台保存前校验。
// 非法取值一律拒绝写入,避免脏数据把账号静默切到非预期档位。
func IsValidStrictness(s string) bool {
switch Strictness(s) {
case StrictnessLax, StrictnessStandard, StrictnessStrict:
return true
default:
return false
}
}
// ParseStrictness 归一化严格度取值,未知/空值回退到 DefaultStrictness(保守方向,即最严)。
// 方向刻意选保守:宁可沿用严格判定,也不要因为识别失败把账号静默放到最松档。
func ParseStrictness(s string) Strictness {
if !IsValidStrictness(s) {
return DefaultStrictness
}
return Strictness(s)
}
// Decide 按档位裁决一次内容审核结果,返回裁决结果与触发拦截的维度(未命中具体维度时为 nil)。
//
// 判定顺序固定为「底线维度 → 档位维度 → 审核服务建议」,且不可调换:
// 1. 底线维度排在最前且无条件返回拦截,保证任何档位都无法把它关掉;
// 2. 档位维度带各自的置信度门槛,用于收敛误伤最集中的维度;
// 3. 审核服务自身的 block 建议排在最后,且仅严格档启用。
//
// Details 为空(例如审核服务返回了 suggestion=block 但没给出任何非 normal 维度)时,
// 只有开启 blockOnSuggestion 的档位会拦截。
func Decide(strictness string, r *TextCensorResponse) (Decision, *LabelScore) {
if r == nil {
return DecisionAllow, nil
}
policy := strictnessPolicies[ParseStrictness(strictness)]
// 1. 底线:合规底线,任何档位强制生效、不可关闭。
for i := range r.Details {
detail := &r.Details[i]
if _, ok := baselineBlockLabels[detail.Label]; ok {
return DecisionBlock, detail
}
}
// 2. 档位维度:命中所配维度且达到该维度的置信度门槛。
for i := range r.Details {
detail := &r.Details[i]
scoreFloor, ok := policy.blockLabels[detail.Label]
if !ok {
continue
}
if scoreFloor > 0 && detail.Score < scoreFloor {
continue
}
return DecisionBlock, detail
}
// 3. 审核服务自身的 block 建议:仅严格档采信。
if policy.blockOnSuggestion && r.ProviderSuggestion == SuggestionBlock {
return DecisionBlock, nil
}
return DecisionAllow, nil
}
// BlockedLabels 返回指定档位最终会拦截的全部维度(含底线维度)。
//
// 用途:策略层的自描述 API —— 目前由 policy_test.go 用来守住「底线维度在每个档位都必须出现
// 且不重复」这条不变量。设置页展示的拦截范围是 i18n 静态文案
// (templates/user/moderation-strictness.html),尚未消费本函数;若将来要让 UI 直接由策略层
// 生成文案,这里是唯一事实来源。
func BlockedLabels(strictness string) []ForbiddenType {
policy := strictnessPolicies[ParseStrictness(strictness)]
labels := make([]ForbiddenType, 0, len(baselineBlockLabels)+len(policy.blockLabels))
for label := range baselineBlockLabels {
labels = append(labels, label)
}
for label := range policy.blockLabels {
// 底线维度已在上面加入,避免重复展示。
if _, ok := baselineBlockLabels[label]; ok {
continue
}
labels = append(labels, label)
}
return labels
}