// 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 }