feat(classify): gate loose re token, non-linear negatives, drop 纯享/转场

reaction 词表用 re|reaction|反应 但须与咻咻满或充电同现,兼容 reactio/reation 等拼写又避免通用词加分;负向改为每词 -2 且命中多条额外叠加(k*(k-1)),日期 -4;纯享(误伤纯享受)移出负向,转场移出正向。库内 796 条有效视频复测:线以下由 14 降至 8。
This commit is contained in:
kairee committed 2026-11-01 19:18:20 +08:00
1 parent 337e379514
commit aa9aa7c589
8 files changed
+84 -47

No files matched your search

+2 -3
View File
@@ -78,13 +78,12 @@
"classify": { "classify": {
"require_title": "咻咻满", "require_title": "咻咻满",
"self_mid": 37754047, "self_mid": 37754047,
"reaction": ["reaction", "反应"], "reaction": ["re", "reaction", "反应"],
"charge": ["加急", "特急", "充电", "专场", "转场"], "charge": ["加急", "特急", "充电", "专场"],
"weak_positive": ["双人", "路人", "第一次", "首次"], "weak_positive": ["双人", "路人", "第一次", "首次"],
"negative": [ "negative": [
"歌切", "歌切",
"切片", "切片",
"纯享",
"搬运", "搬运",
"完整版", "完整版",
"录播", "录播",
+3 -3
View File
@@ -88,10 +88,10 @@
"classify": { "classify": {
"require_title": "咻咻满", "require_title": "咻咻满",
"self_mid": 37754047, "self_mid": 37754047,
"reaction": ["reaction", "反应"], "reaction": ["re", "reaction", "反应"],
"charge": ["加急", "特急", "充电", "专场", "转场"], "charge": ["加急", "特急", "充电", "专场"],
"weak_positive": ["双人", "路人", "第一次", "首次"], "weak_positive": ["双人", "路人", "第一次", "首次"],
"negative": ["歌切", "切片", "纯享", "搬运", "完整版", "录播", "录像"], "negative": ["歌切", "切片", "搬运", "完整版", "录播", "录像"],
"date_negative": true, "date_negative": true,
"duration_boost": { "gt_30min": 1, "gt_60min": 2 } "duration_boost": { "gt_30min": 1, "gt_60min": 2 }
}, },
+10 -8
View File
@@ -262,15 +262,16 @@ up_mid 命中黑名单 → 直接排除(永不通知,连候选池都不进
# 正向命中次数 n(每个命中记 1) # 正向命中次数 n(每个命中记 1)
n = 1 # 标题含 "咻咻满" n = 1 # 标题含 "咻咻满"
+ count(reaction|反应) + 1 # 命中 reaction 类(re|reaction|反应;须与"咻咻满"或"充电"同现)
+ count(加急|特急|充电|专场|转场) + count(加急|特急|充电|专场)
+ count(双人|路人|第一次|首次) + count(双人|路人|第一次|首次)
score = n * n # 超线性:1→1、2→4、3→9、4→16(1+1>2、1+1+1>5) score = n * n # 超线性:1→1、2→4、3→9、4→16(1+1>2、1+1+1>5)
+ 2 # 时长 > 60 分钟(与下一条不叠加,取大) + 2 # 时长 > 60 分钟(与下一条不叠加,取大)
+ 1 # 时长 > 30 分钟 + 1 # 时长 > 30 分钟
- 6 # 标题含 歌切|切片|纯享|搬运|完整版|录播|录像 - 2 * k # 每个负向词:歌切|切片|搬运|完整版|录播|录像
- 6 # 标题带日期(2026-10-28 / 2026/10/25 / 2026.10.26 / 2024年11月24日 / 10月25日) - k*(k-1) # 同时命中 2 个额外 -2、3 个额外 -6、4 个额外 -12
- 4 # 标题带日期(2026-10-28 / 2026/10/25 / 2026.10.26 / 2024年11月24日 / 10月25日)
- 3 # UP 为 咻咻满本人(mid=37754047) - 3 # UP 为 咻咻满本人(mid=37754047)
通知阈值 = 4 通知阈值 = 4
@@ -280,10 +281,11 @@ score < 0 → 丢弃
**几条设计依据**: **几条设计依据**:
- **强正向必须"咻咻满 + reaction"同现**:B 站搜索会命中标题根本不含关键词的结果(简介/标签/相似内容),所以"标题含 reaction"本身不足以判定为高置信,必须与 `require_title` 同时成立; - **reaction 类用宽松 `re` 但须同现**:`re|reaction|反应` 必须与"咻咻满"或"充电"同时出现才计命中——既避免通用词(任意含 "re" 的拉丁串)加分,又能兼容 `reactio`/`reation`/`recation` 等拼写;
- **正向按命中次数超线性加权(n²)**:多个正向词同现(如"咻咻满 + reaction + 充电")时不能简单线性和,否则一个负向词(-6)就能把多信号真 reaction 打到线下;`1+1>2`、`1+1+1>5` 用平方实现; - **正向按命中次数超线性加权(n²)**:多个正向词同现(如"咻咻满 + reaction + 充电")时不能简单线性和,否则一个负向词就能把多信号真 reaction 打到线下;`1+1>2`、`1+1+1>5` 用平方实现;
- **"听/看"等宽泛词已移除**:几乎所有 reaction 标题都含,无区分度; - **负向也非线性**:每个负向词 -2,命中 2 个额外 -2、3 个额外 -6(`k*(k-1)`),带日期再 -4;单一负向词不再一票否决多信号 reaction;
- **时长只在已命中 reaction/charge 时加权**:避免"完整录播/直播录像"这类既无 reaction 也无 charge 的长视频仅靠时长进池; - **"听/看"等宽泛词已移除**;`纯享`(会误伤"纯享受")已从负向移除,`转场`("专场"错别字)已从正向移除;
- **时长只在已命中正向词时加权**:避免"完整录播/直播录像"这类既无 reaction 也无 charge 的长视频仅靠时长进池;
- **带日期的标题按强负向处理**:`2026-10-28`、`10月25日` 这类通常对应某场直播的录播/回放/录像/自用收藏;实测对真 reaction 标题零误伤,代价是极少数"真 reaction 恰好写日期"会被漏掉; - **带日期的标题按强负向处理**:`2026-10-28`、`10月25日` 这类通常对应某场直播的录播/回放/录像/自用收藏;实测对真 reaction 标题零误伤,代价是极少数"真 reaction 恰好写日期"会被漏掉;
- **`录播`/`录像` 计负向**:`完整版` 覆盖不到 `完整录播`;`录像` 覆盖 `直播录像`。裸 `回放` 不列入,因为 `【回放/咻咻满reaction】` 之类确为 reaction; - **`录播`/`录像` 计负向**:`完整版` 覆盖不到 `完整录播`;`录像` 覆盖 `直播录像`。裸 `回放` 不列入,因为 `【回放/咻咻满reaction】` 之类确为 reaction;
- 时长直接取**搜索结果自带的 `duration` 字段**(格式是 `分钟:秒`,超过 60 分钟写作 `105:5`),**不需要额外请求**; - 时长直接取**搜索结果自带的 `duration` 字段**(格式是 `分钟:秒`,超过 60 分钟写作 `105:5`),**不需要额外请求**;
+20 -7
View File
@@ -74,9 +74,9 @@ _DATE_PATTERNS = (
) )
_DATE_RE = re.compile("|".join(_DATE_PATTERNS)) _DATE_RE = re.compile("|".join(_DATE_PATTERNS))
# 负向权重(见 project.md §3.3)。正向改为「命中次数」超线性加权,见 score_item。 # 负向权重(见 project.md §3.3):每个负向词 -2,命中多条额外叠加;日期单独 -4。
NEGATIVE_SCORE = 6 NEGATIVE_PER_WORD = 2
DATE_SCORE = 6 DATE_SCORE = 4
SELF_SCORE = 3 SELF_SCORE = 3
@@ -97,16 +97,26 @@ def score_item(
正向按**命中次数**超线性加权:每命中一个正向词(``require_title`` / ``reaction`` 正向按**命中次数**超线性加权:每命中一个正向词(``require_title`` / ``reaction``
/ ``charge`` / ``weak_positive``)记 1 次,正向得分 = 命中次数²(1→1、2→4、 / ``charge`` / ``weak_positive``)记 1 次,正向得分 = 命中次数²(1→1、2→4、
3→9……),使多信号同现的真 reaction 不会被单个负向词打穿(1+1>2、1+1+1>5)。 3→9……),使多信号同现的真 reaction 不会被单个负向词打穿(1+1>2、1+1+1>5)。
标题不含 ``require_title`` 直接 0 分;时长只在此基础上已有命中时加权。
``reaction`` 词表含 ``re`` 这类通用子串(用于兼容 reaction/reactio/reation 等
拼写),因此必须与 ``require_title`` 或 ``充电`` 同现才计一次命中。标题不含
``require_title`` 直接 0 分;时长只在此基础上已有命中时加权。
负向按命中词数递减:每个词 -2,命中 2 个额外 -2、3 个额外 -6(k*(k-1)),
带日期再 -4。
""" """
require_title = str(classify.get("require_title") or "") require_title = str(classify.get("require_title") or "")
if require_title and not passes_required_title(title, require_title): if require_title and not passes_required_title(title, require_title):
return 0 return 0
# reaction 类通用词必须与 require_title 或 "充电" 同现才算命中(避免通用词加分)。
reaction_gate = passes_required_title(title, require_title) or contains_any(title, ["充电"])
reaction_hit = 1 if (matched_words(title, classify.get("reaction")) and reaction_gate) else 0
hits = 0 hits = 0
if require_title: if require_title:
hits += 1 hits += 1
hits += len(matched_words(title, classify.get("reaction"))) hits += reaction_hit
hits += len(matched_words(title, classify.get("charge"))) hits += len(matched_words(title, classify.get("charge")))
hits += len(matched_words(title, classify.get("weak_positive"))) hits += len(matched_words(title, classify.get("weak_positive")))
@@ -121,8 +131,11 @@ def score_item(
elif duration_seconds > 30 * 60: elif duration_seconds > 30 * 60:
score += gt_30 score += gt_30
if matched_words(title, classify.get("negative")): neg_words = matched_words(title, classify.get("negative"))
score -= NEGATIVE_SCORE k = len(neg_words)
if k:
score -= NEGATIVE_PER_WORD * k
score -= k * (k - 1) # 命中 2 个额外 -2、3 个额外 -6、4 个额外 -12
if classify.get("date_negative", True) and date_hits(title): if classify.get("date_negative", True) and date_hits(title):
score -= DATE_SCORE score -= DATE_SCORE
if up_mid is not None and int(up_mid) == int(classify.get("self_mid", 37754047)): if up_mid is not None and int(up_mid) == int(classify.get("self_mid", 37754047)):
+3 -3
View File
@@ -25,10 +25,10 @@ from .client_ip import (
DEFAULT_CLASSIFY: dict[str, Any] = { DEFAULT_CLASSIFY: dict[str, Any] = {
"require_title": "咻咻满", "require_title": "咻咻满",
"self_mid": 37754047, "self_mid": 37754047,
"reaction": ["reaction", "反应"], "reaction": ["re", "reaction", "反应"],
"charge": ["加急", "特急", "充电", "专场", "转场"], "charge": ["加急", "特急", "充电", "专场"],
"weak_positive": ["双人", "路人", "第一次", "首次"], "weak_positive": ["双人", "路人", "第一次", "首次"],
"negative": ["歌切", "切片", "纯享", "搬运", "完整版", "录播", "录像"], "negative": ["歌切", "切片", "搬运", "完整版", "录播", "录像"],
"date_negative": True, "date_negative": True,
"duration_boost": {"gt_30min": 1, "gt_60min": 2}, "duration_boost": {"gt_30min": 1, "gt_60min": 2},
} }
+43 -20
View File
@@ -21,10 +21,10 @@ from reaction_board.classify import ( # noqa: E402
CLASSIFY = { CLASSIFY = {
"require_title": "咻咻满", "require_title": "咻咻满",
"self_mid": 37754047, "self_mid": 37754047,
"reaction": ["reaction", "反应"], "reaction": ["re", "reaction", "反应"],
"charge": ["加急", "特急", "充电", "专场", "转场"], "charge": ["加急", "特急", "充电", "专场"],
"weak_positive": ["双人", "路人", "第一次", "首次"], "weak_positive": ["双人", "路人", "第一次", "首次"],
"negative": ["歌切", "切片", "纯享", "搬运", "完整版", "录播", "录像"], "negative": ["歌切", "切片", "搬运", "完整版", "录播", "录像"],
"date_negative": True, "date_negative": True,
"duration_boost": {"gt_30min": 1, "gt_60min": 2}, "duration_boost": {"gt_30min": 1, "gt_60min": 2},
} }
@@ -69,8 +69,7 @@ def test_date_hits():
def test_hit_count_is_superlinear(): def test_hit_count_is_superlinear():
# 正向命中次数 n:require_title + reaction + charge + weak,得分 = n^2 # 正向命中次数 n:require_title + reaction(0/1) + charge 词数 + weak 词数,得分 = n^2
# 1 -> 1, 2 -> 4, 3 -> 9(满足 1+1>2、1+1+1>5)
assert score("咻咻满", duration=60) == 1 assert score("咻咻满", duration=60) == 1
assert score("咻咻满 reaction", duration=60) == 4 assert score("咻咻满 reaction", duration=60) == 4
assert score("咻咻满 反应", duration=60) == 4 assert score("咻咻满 反应", duration=60) == 4
@@ -82,41 +81,59 @@ def test_hit_count_is_superlinear():
assert score("咻咻满 reaction 双人", duration=60) == 9 assert score("咻咻满 reaction 双人", duration=60) == 9
def test_reaction_requires_title(): def test_reaction_typo_and_gate():
# 标题不含 require_title 直接 0 分,即使命中 reaction # "re" 兼容 reaction/reactio/reation 等拼写
assert score("咻咻满 reactio", duration=60) == 4
assert score("咻咻满 Reactuon 充电", duration=60) == 9
# 标题不含 require_title 直接 0 分
assert score("其他 UP 的 reaction", duration=61 * 60) == 0 assert score("其他 UP 的 reaction", duration=61 * 60) == 0
assert score("普通视频 reactio", duration=61 * 60) == 0
def test_duration_boost(): def test_duration_boost():
assert score("咻咻满 reaction", duration=61 * 60) == 6 assert score("咻咻满 reaction", duration=61 * 60) == 6
assert score("咻咻满 reaction", duration=31 * 60) == 5 assert score("咻咻满 reaction", duration=31 * 60) == 5
assert score("咻咻满 reaction", duration=30 * 60) == 4 assert score("咻咻满 reaction", duration=30 * 60) == 4
# 只有 require_title 一个命中时,时长仍计入(基础分偏低,不足以过线)
assert score("咻咻满", duration=0) == 1 assert score("咻咻满", duration=0) == 1
assert score("咻咻满", duration=61 * 60) == 3 assert score("咻咻满", duration=61 * 60) == 3
assert score("咻咻满 双人", duration=61 * 60) == 6 assert score("咻咻满 双人", duration=61 * 60) == 6
def test_negative_and_self(): def test_negative_single_and_multi():
assert score("咻咻满 切片", duration=60) == -5 # 单个负向词 -2
assert score("咻咻满 reaction 切片", duration=60) == -2 assert score("咻咻满 切片", duration=60) == -1
assert score("咻咻满 reaction 直播录像", duration=60) == -2 assert score("咻咻满 reaction 切片", duration=60) == 2
assert score("咻咻满 reaction 直播录像", duration=60) == 2
# 2 个负向词:-2*2 -2 = -6
assert score("咻咻满 歌切 切片", duration=60) == -5
# 3 个负向词:-2*3 -6 = -12
assert score("咻咻满 reaction 歌切 切片 完整版", duration=60) == -8
def test_self_penalty():
assert score("咻咻满 reaction", up_mid=37754047, duration=60) == 1 assert score("咻咻满 reaction", up_mid=37754047, duration=60) == 1
def test_date_negative(): def test_date_negative():
assert score("咻咻满 reaction 2026-10-28 完整录播", duration=60) == -8 assert score("咻咻满 reaction 2026-10-28 完整录播", duration=60) == -2
assert score("咻咻满 reaction 2026-10-28", duration=60) == -2 assert score("咻咻满 reaction 2026-10-28", duration=60) == 0
assert score("咻咻满 充电 10月25日", duration=60) == -2 assert score("咻咻满 充电 10月25日", duration=60) == 0
disabled = dict(CLASSIFY, date_negative=False) disabled = dict(CLASSIFY, date_negative=False)
assert score("咻咻满 reaction 2026-10-28", duration=60, classify=disabled) == 4 assert score("咻咻满 reaction 2026-10-28", duration=60, classify=disabled) == 4
def test_removed_words():
# 纯享不再算负向
assert score("咻咻满 reaction 纯享受", duration=60) == 4
# 转场不再算正向(作为错别字忽略)
assert score("咻咻满 转场", duration=60) == 1
def test_multi_hit_survives_single_negative(): def test_multi_hit_survives_single_negative():
# 回归:命中 咻咻满+reaction+充电 的 204min reaction 标题,扣掉「切片」后仍应过线 # 回归:命中 咻咻满+reaction+充电 的 204min reaction 标题,扣掉单个「切片」后仍应过线
title = "【充电2.0 | 咻咻满reaction】翻唱和直播切片完全是在开盲盒呀!不知道今天会开到哪一款" title = "【充电2.0 | 咻咻满reaction】翻唱和直播切片完全是在开盲盒呀!不知道今天会开到哪一款"
assert score(title, duration=204 * 60) == 5 assert score(title, duration=204 * 60) == 9
assert score(title, duration=60) == 3 assert score(title, duration=60) == 7
def main(): def main():
@@ -124,11 +141,17 @@ def main():
test_parse_duration() test_parse_duration()
test_date_hits() test_date_hits()
test_hit_count_is_superlinear() test_hit_count_is_superlinear()
test_reaction_requires_title() test_reaction_typo_and_gate()
test_duration_boost() test_duration_boost()
test_negative_and_self() test_negative_single_and_multi()
test_self_penalty()
test_date_negative() test_date_negative()
test_removed_words()
test_multi_hit_survives_single_negative() test_multi_hit_survives_single_negative()
# 直播回放示例:命中 require+专场+路人+第一次 = 4 -> 16;39min > 30 -> +1 = 17
title = "6【直播回放】咻咻满专场 路人第一次听满满唱歌 真的超级绝超级好听耶~"
assert score(title, duration=39 * 60) == 17
print("OK: 候选标题归一化、时长解析与分类打分测试全部通过") print("OK: 候选标题归一化、时长解析与分类打分测试全部通过")
+1 -1
View File
@@ -57,7 +57,7 @@ def test_dual_accounts_and_env_override():
assert cfg.discover.keywords == ("咻咻满",) assert cfg.discover.keywords == ("咻咻满",)
assert cfg.discover.pages == 3 assert cfg.discover.pages == 3
assert cfg.classify["self_mid"] == 37754047 assert cfg.classify["self_mid"] == 37754047
assert cfg.classify["reaction"] == ["reaction", "反应"] assert cfg.classify["reaction"] == ["re", "reaction", "反应"]
assert cfg.classify["date_negative"] is True assert cfg.classify["date_negative"] is True
+2 -2
View File
@@ -128,10 +128,10 @@ def test_filters_and_scoring():
assert summary.title_miss == 1 assert summary.title_miss == 1
assert summary.old == 1 assert summary.old == 1
assert summary.blacklisted == 1 assert summary.blacklisted == 1
assert summary.candidates == 1 # aid 6 assert summary.candidates == 2 # aid 6(1 分)与 aid 2(2 分)
assert summary.notified == 0 assert summary.notified == 0
assert repo.get_candidate(6)["score"] == 1 assert repo.get_candidate(6)["score"] == 1
assert repo.get_candidate(2) is None assert repo.get_candidate(2)["score"] == 2 # reaction 命中后单个「切片」不再清零
assert repo.get_candidate(4) is None assert repo.get_candidate(4) is None
assert repo.get_candidate(5) is None assert repo.get_candidate(5) is None
assert notifier.batches == [] assert notifier.batches == []