Skip to content

Commit 4dbf756

Browse files
authored
feat(classifier): 新增 notResource flag,AI 兜底拦表情包/dev URL/裸图片 (#21)
* feat(classifier): 新增 notResource flag,AI 兜底拦表情包/dev URL/裸图片等非资源链接 事故复盘:ChatBot listener 漏过表情包/GIF/自家 GitHub PR 链接,被 DeepSeek 走完分类后打 APPROVED 上架(#5/#18/#19)。listener 改了多轮黑名单(贴纸聚合站、媒体扩展名、self-org GitHub dev 子路径),但黑名单永远穷举不完。 改:ClassificationResult 加第 5 个 flag notResource,prompt 教模型识别'内容资源 vs 非资源':表情包/贴纸/GIF/裸图片/视频音频直链/登录墙/错误页/dev 子路径(PR/issue/commit)一律 notResource=true → 走 FLAGGED 进人工待审。仓库主页、文章、论文、项目主页等正常资源全 false 放行。 兼容性:parseResponse 用 .asBoolean(false) 读 notResource,旧模型/旧 cache 缺字段时降级为 false,不阻拦正常分享。flags map 多带一个 key,前端展示逻辑会自然 fallthrough。 测试:+1 场景 (notResource=true → FLAGGED),全 50 个 community.** 测试 pass。 * fix(worker): 把 notResource 真正塞进 flags Map 和 log(前一 commit Edit 漏了)
2 parents c1b05fc + 58f89d2 commit 4dbf756

4 files changed

Lines changed: 74 additions & 28 deletions

File tree

src/main/java/com/involutionhell/backend/community/service/ClassificationResult.java

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
* DeepSeek 分类结果(M3)。
55
*
66
* category 已由 LinkCategory.normalize() 保证合法;
7-
* flags 对应 DeepSeek 返回的安全判定
7+
* flags 对应 DeepSeek 返回的安全/质量判定
88
* - nsfw:色情/暴力等不适宜内容
99
* - ad:纯商业推广软文(技术公告/版本更新等不算)
1010
* - flame:引战/情绪化内容
1111
* - illegal:疑似违反中国法律法规(反动/颠覆/分裂/邪教/赌博/毒品等)
12+
* - notResource:链接本身不是"可分享的内容资源"(表情包/贴纸/GIF/裸图片/
13+
* 登录墙/错误页/dev PR 通知页等),客户端 listener 拦不住的兜底
1214
*
1315
* 任一 flag 为 true → worker 将 status 推到 FLAGGED(进人工复核)。
1416
*/
@@ -17,17 +19,18 @@ public record ClassificationResult(
1719
boolean nsfw,
1820
boolean ad,
1921
boolean flame,
20-
boolean illegal
22+
boolean illegal,
23+
boolean notResource
2124
) {
2225

23-
/** 是否命中任意安全 flag。 */
26+
/** 是否命中任意安全/质量 flag。 */
2427
public boolean anyFlagSet() {
25-
return nsfw || ad || flame || illegal;
28+
return nsfw || ad || flame || illegal || notResource;
2629
}
2730

2831
/** 降级结果:分类为 other,flags 全 false(网络/解析等**非内容过滤**原因的失败用)。 */
2932
public static ClassificationResult fallback() {
30-
return new ClassificationResult("other", false, false, false, false);
33+
return new ClassificationResult("other", false, false, false, false, false);
3134
}
3235

3336
/**
@@ -36,6 +39,6 @@ public static ClassificationResult fallback() {
3639
* 本系统将 illegal 置为 true 让其走 FLAGGED 进人工复核,而不是 fallback 静默放行。
3740
*/
3841
public static ClassificationResult blockedByContentFilter() {
39-
return new ClassificationResult("other", false, false, false, true);
42+
return new ClassificationResult("other", false, false, false, true, false);
4043
}
4144
}

src/main/java/com/involutionhell/backend/community/service/ClassificationService.java

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public class ClassificationService {
5353
中国大陆现行法律法规。根据输入信息,把链接分到以下分类之一:
5454
%s
5555
56-
同时判断内容是否存在 4 类安全问题。对 nsfw/ad/flame 采用"宁松勿严"
56+
同时判断内容是否存在 5 类问题。对 nsfw/ad/flame/notResource 采用"宁松勿严"
5757
策略(社群正常技术分享放行);对 illegal 必须严格,宁可误报。
5858
5959
- nsfw: 色情、裸露、血腥暴力、猎奇不适。仅当**明确**涉及时为 true。
@@ -67,6 +67,19 @@ public class ClassificationService {
6767
新闻报道、个人作品集。
6868
- flame: 明显引战 / 人身攻击 / 极端言论 / 刻意煽动对立。技术路线之争、
6969
理性观点分歧**不算**。
70+
- notResource: 链接本身不是"可分享的内容资源"(不是色情/广告/引战,
71+
只是没有信息价值,不该上架到社群分享库)。任一命中即 true:
72+
· 表情包 / 贴纸 / GIF(tenor / klipy / giphy / 微博表情等)
73+
· 单张图片 / 截图 / 头像(孤立的纯图片页面,非文章配图)
74+
· 视频/音频/媒体文件直链(路径以 .mp4 .mp3 .gif 等结尾)
75+
· 登录墙 / 错误页 / 验证码 / 404 / 维护页(OG 抓不到正文)
76+
· 内部 dev 通知页(GitHub PR/Issue/Commit、Jira 工单、CI 报告)
77+
· 空白页 / 广告聚合页 / 跳转中转页
78+
**反例(全部 false)**:技术博客文章、论文、开源项目主页(README)、
79+
新闻报道、文档教程、知乎/小红书/微博正常帖子、视频教程页
80+
(含正文/字幕的播放页,不是裸 .mp4 文件)。
81+
注意:仓库主页(如 github.com/foo/bar)允许,dev 子路径
82+
(github.com/foo/bar/pull/123)才命中本规则。
7083
- illegal: 疑似违反中国大陆法律法规的内容。任一命中即 true:
7184
· 反对宪法基本原则、颠覆国家政权、煽动分裂国家、破坏国家统一
7285
· 攻击党和政府、宣扬港独 / 台独 / 藏独 / 疆独
@@ -80,7 +93,7 @@ public class ClassificationService {
8093
技术讨论涉及敏感话题但论点中立且学术讨论 **不算** illegal。
8194
8295
严格只返回 JSON,不要任何解释、代码块标记(不要 ```json)或其他文字:
83-
{"category": "<slug>", "nsfw": false, "ad": false, "flame": false, "illegal": false}
96+
{"category": "<slug>", "nsfw": false, "ad": false, "flame": false, "illegal": false, "notResource": false}
8497
""";
8598

8699
private final HttpClient httpClient;
@@ -249,19 +262,20 @@ ClassificationResult parseResponse(String responseBody, String host) {
249262
boolean nsfw = result.path("nsfw").asBoolean(false);
250263
boolean ad = result.path("ad").asBoolean(false);
251264
boolean flame = result.path("flame").asBoolean(false);
252-
// 旧模型可能不返回 illegal 字段,缺失时按 false 降级(不阻拦),
253-
// 命中 nsfw/ad/flame 任一时已经会走 FLAGGED
254-
boolean illegal = result.path("illegal").asBoolean(false);
265+
// 旧模型可能不返回 illegal / notResource 字段,缺失时按 false 降级(不阻拦),
266+
// 反正命中其它 flag 任一时已经会走 FLAGGED
267+
boolean illegal = result.path("illegal").asBoolean(false);
268+
boolean notResource = result.path("notResource").asBoolean(false);
255269

256270
// normalize 兜底:非法 slug 转 other
257271
String category = LinkCategory.normalize(rawCategory);
258272
if (!category.equals(rawCategory)) {
259273
log.warn("classification 返回非法分类,降级为 other: host={} raw={}", host, rawCategory);
260274
}
261275

262-
log.debug("classification 完成: host={} category={} nsfw={} ad={} flame={} illegal={}",
263-
host, category, nsfw, ad, flame, illegal);
264-
return new ClassificationResult(category, nsfw, ad, flame, illegal);
276+
log.debug("classification 完成: host={} category={} nsfw={} ad={} flame={} illegal={} notResource={}",
277+
host, category, nsfw, ad, flame, illegal, notResource);
278+
return new ClassificationResult(category, nsfw, ad, flame, illegal, notResource);
265279

266280
} catch (Exception e) {
267281
log.warn("classification 响应解析失败,降级: host={} error={}", host, e.getMessage());

src/main/java/com/involutionhell/backend/community/service/SharedLinkEnrichmentWorker.java

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -110,19 +110,20 @@ private void doEnrich(Long linkId) {
110110
if (cls.anyFlagSet()) {
111111
// 任一安全 flag 命中 → FLAGGED,进人工待审
112112
finalStatus = SharedLinkStatus.FLAGGED;
113-
log.info("enrichment 标记 FLAGGED: linkId={} nsfw={} ad={} flame={} illegal={}",
114-
linkId, cls.nsfw(), cls.ad(), cls.flame(), cls.illegal());
113+
log.info("enrichment 标记 FLAGGED: linkId={} nsfw={} ad={} flame={} illegal={} notResource={}",
114+
linkId, cls.nsfw(), cls.ad(), cls.flame(), cls.illegal(), cls.notResource());
115115
} else {
116116
finalStatus = SharedLinkStatus.APPROVED;
117117
log.info("enrichment AI 放行 APPROVED: linkId={} host={}", linkId, host);
118118
}
119119

120120
// ── 步骤 4:回填数据库 ───────────────────────────────────────────
121121
Map<String, Boolean> flags = Map.of(
122-
"nsfw", cls.nsfw(),
123-
"ad", cls.ad(),
124-
"flame", cls.flame(),
125-
"illegal", cls.illegal()
122+
"nsfw", cls.nsfw(),
123+
"ad", cls.ad(),
124+
"flame", cls.flame(),
125+
"illegal", cls.illegal(),
126+
"notResource", cls.notResource()
126127
);
127128

128129
sharedLinkService.enrich(
@@ -157,7 +158,7 @@ private void tryFallbackStatus(Long linkId) {
157158
null, null, null, null,
158159
"enrichment worker 未捕获异常,降级",
159160
"other",
160-
Map.of("nsfw", false, "ad", false, "flame", false),
161+
Map.of("nsfw", false, "ad", false, "flame", false, "illegal", false, "notResource", false),
161162
SharedLinkStatus.PENDING_MANUAL
162163
);
163164
log.info("enrichment 降级完成: linkId={} -> PENDING_MANUAL", linkId);

src/test/java/com/involutionhell/backend/community/service/SharedLinkEnrichmentWorkerTests.java

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ void enrich_nonFlagged_doesNotFireWebhook() {
7474
when(ogFetchService.fetch(anyString())).thenReturn(
7575
new OgFetchResult("标题", null, null, null, null));
7676
when(classificationService.classify(any(), any(), any())).thenReturn(
77-
new ClassificationResult("other", false, false, false, false));
77+
new ClassificationResult("other", false, false, false, false, false));
7878

7979
worker.enrich(100L);
8080

@@ -91,7 +91,7 @@ void enrich_whitelistDomain_noFlags_statusBecomesApproved() {
9191
when(ogFetchService.fetch(anyString())).thenReturn(
9292
new OgFetchResult("标题", "描述", "https://cover.jpg", "某公众号", null));
9393
when(classificationService.classify(anyString(), anyString(), anyString())).thenReturn(
94-
new ClassificationResult("engineering", false, false, false, false));
94+
new ClassificationResult("engineering", false, false, false, false, false));
9595

9696
worker.enrich(1L);
9797

@@ -115,7 +115,7 @@ void enrich_nonWhitelistDomain_noFlags_statusBecomesApproved_afterSimplification
115115
when(ogFetchService.fetch(anyString())).thenReturn(
116116
new OgFetchResult("非白名单文章", null, null, null, null));
117117
when(classificationService.classify(any(), any(), any())).thenReturn(
118-
new ClassificationResult("other", false, false, false, false));
118+
new ClassificationResult("other", false, false, false, false, false));
119119

120120
worker.enrich(2L);
121121

@@ -136,7 +136,7 @@ void enrich_flaggedByAd_statusBecomesFlagged_regardlessOfWhitelist() {
136136
when(ogFetchService.fetch(anyString())).thenReturn(
137137
new OgFetchResult("限时特卖!", "买一送一", null, null, null));
138138
when(classificationService.classify(any(), any(), any())).thenReturn(
139-
new ClassificationResult("other", false, true, false, false)); // ad=true
139+
new ClassificationResult("other", false, true, false, false, false)); // ad=true
140140

141141
worker.enrich(3L);
142142

@@ -156,7 +156,7 @@ void enrich_nsfwFlag_statusBecomesFlagged() {
156156
when(ogFetchService.fetch(anyString())).thenReturn(
157157
new OgFetchResult("问题标题", null, null, null, null));
158158
when(classificationService.classify(any(), any(), any())).thenReturn(
159-
new ClassificationResult("lifestyle", true, false, false, false)); // nsfw=true
159+
new ClassificationResult("lifestyle", true, false, false, false, false)); // nsfw=true
160160

161161
worker.enrich(4L);
162162

@@ -178,7 +178,7 @@ void enrich_ogFetchFails_stillCompletesEnrichment() {
178178
when(ogFetchService.fetch(anyString())).thenReturn(
179179
OgFetchResult.failure("HTTP 403"));
180180
when(classificationService.classify(isNull(), isNull(), eq(host))).thenReturn(
181-
new ClassificationResult("other", false, false, false, false));
181+
new ClassificationResult("other", false, false, false, false, false));
182182

183183
worker.enrich(5L);
184184

@@ -236,7 +236,7 @@ void enrich_flameFlag_flagsMapContainsCorrectValues() {
236236
when(ogFetchService.fetch(anyString())).thenReturn(
237237
new OgFetchResult("引战标题", null, null, null, null));
238238
when(classificationService.classify(any(), any(), any())).thenReturn(
239-
new ClassificationResult("industry", false, false, true, false)); // flame=true
239+
new ClassificationResult("industry", false, false, true, false, false)); // flame=true
240240

241241
worker.enrich(7L);
242242

@@ -251,4 +251,32 @@ void enrich_flameFlag_flagsMapContainsCorrectValues() {
251251
assertThat(flags.get("ad")).isFalse();
252252
assertThat(flags.get("flame")).isTrue();
253253
}
254+
255+
// ── 场景 8:notResource=true → FLAGGED(兜底拦表情包/裸图片/dev URL) ────
256+
257+
@Test
258+
void enrich_notResourceFlag_routesToFlagged() {
259+
String host = "klipy.com";
260+
SharedLink link = stubLink(8L, "https://klipy.com/gifs/hello-1234", host);
261+
when(sharedLinkService.findById(8L)).thenReturn(Optional.of(link));
262+
when(ogFetchService.fetch(anyString())).thenReturn(
263+
new OgFetchResult(null, null, null, null, null));
264+
when(classificationService.classify(any(), any(), any())).thenReturn(
265+
new ClassificationResult("other", false, false, false, false, true)); // notResource=true
266+
267+
worker.enrich(8L);
268+
269+
@SuppressWarnings("unchecked")
270+
ArgumentCaptor<Map<String, Boolean>> flagsCaptor = ArgumentCaptor.forClass(Map.class);
271+
verify(sharedLinkService).enrich(eq(8L),
272+
any(), any(), any(), any(), any(),
273+
any(), flagsCaptor.capture(), eq(SharedLinkStatus.FLAGGED));
274+
275+
Map<String, Boolean> flags = flagsCaptor.getValue();
276+
assertThat(flags.get("nsfw")).isFalse();
277+
assertThat(flags.get("ad")).isFalse();
278+
assertThat(flags.get("flame")).isFalse();
279+
assertThat(flags.get("illegal")).isFalse();
280+
assertThat(flags.get("notResource")).isTrue();
281+
}
254282
}

0 commit comments

Comments
 (0)