feat(classify): zero title-only titles, penalize <10min, add discover.candidate_min_score
标题仅含 require_title 不再计分(必须咻咻满+任一正向词);时长 <10 分钟 -1(时长未知不扣);新增 discover.candidate_min_score(默认=notify.min_score)控制是否进候选池,只过标题关的不再入池。库内 796 条:仅过标题关的 8 条从 1/3 分降为 0/-1,其余不变。
This commit is contained in:
1 parent
aa9aa7c589
commit
c6790f0ba1
10 files changed
+138
-69
No files matched your search
+58
-38
@@ -29,8 +29,13 @@ CLASSIFY = {
|
||||
"duration_boost": {"gt_30min": 1, "gt_60min": 2},
|
||||
}
|
||||
|
||||
NEUTRAL = 600 # 10min:不触发 <10min 惩罚,也不触发 >30min 加权
|
||||
SHORT = 60 # 1min:<10min
|
||||
MID = 31 * 60 # >30min
|
||||
LONG = 61 * 60 # >60min
|
||||
|
||||
def score(title, up_mid=100, duration=0, classify=CLASSIFY):
|
||||
|
||||
def score(title, up_mid=100, duration=NEUTRAL, classify=CLASSIFY):
|
||||
return score_item(
|
||||
title=title, up_mid=up_mid,
|
||||
duration_seconds=duration, classify=classify,
|
||||
@@ -68,88 +73,103 @@ def test_date_hits():
|
||||
assert date_hits("咻咻满 reaction") == []
|
||||
|
||||
|
||||
def test_title_only_scores_zero():
|
||||
# 只过标题关(标题仅含 require_title、无任何正向词)不给分,时长也不计入
|
||||
assert score("咻咻满") == 0
|
||||
assert score("咻咻满", duration=LONG) == 0
|
||||
assert score("咻咻满 日常") == 0
|
||||
assert score("【咻咻满】交换余生(升2)") == 0
|
||||
# 但时长未知(0) vs <10min 的惩罚不同
|
||||
assert score("咻咻满", duration=0) == 0
|
||||
assert score("咻咻满", duration=SHORT) == -1
|
||||
|
||||
|
||||
def test_hit_count_is_superlinear():
|
||||
# 正向命中次数 n:require_title + reaction(0/1) + charge 词数 + weak 词数,得分 = n^2
|
||||
assert score("咻咻满", duration=60) == 1
|
||||
assert score("咻咻满 reaction", duration=60) == 4
|
||||
assert score("咻咻满 反应", duration=60) == 4
|
||||
assert score("咻咻满 reaction 充电", duration=60) == 9
|
||||
assert score("咻咻满 reaction 加急充电专场", duration=60) == 25
|
||||
assert score("咻咻满 充电", duration=60) == 4
|
||||
assert score("咻咻满 加急专场", duration=60) == 9
|
||||
assert score("咻咻满 双人", duration=60) == 4
|
||||
assert score("咻咻满 reaction 双人", duration=60) == 9
|
||||
# 正向命中次数 n:require_title(仅在有正向命中时计 1)+ reaction(0/1) + charge 词数 + weak 词数
|
||||
assert score("咻咻满 reaction") == 4
|
||||
assert score("咻咻满 反应") == 4
|
||||
assert score("咻咻满 reaction 充电") == 9
|
||||
assert score("咻咻满 reaction 加急充电专场") == 25
|
||||
assert score("咻咻满 充电") == 4
|
||||
assert score("咻咻满 加急专场") == 9
|
||||
assert score("咻咻满 双人") == 4
|
||||
assert score("咻咻满 reaction 双人") == 9
|
||||
|
||||
|
||||
def test_reaction_typo_and_gate():
|
||||
# "re" 兼容 reaction/reactio/reation 等拼写
|
||||
assert score("咻咻满 reactio", duration=60) == 4
|
||||
assert score("咻咻满 Reactuon 充电", duration=60) == 9
|
||||
assert score("咻咻满 reactio") == 4
|
||||
assert score("咻咻满 Reactuon 充电") == 9
|
||||
# 标题不含 require_title 直接 0 分
|
||||
assert score("其他 UP 的 reaction", duration=61 * 60) == 0
|
||||
assert score("普通视频 reactio", duration=61 * 60) == 0
|
||||
assert score("其他 UP 的 reaction", duration=LONG) == 0
|
||||
assert score("普通视频 reactio", duration=LONG) == 0
|
||||
|
||||
|
||||
def test_duration_boost():
|
||||
assert score("咻咻满 reaction", duration=61 * 60) == 6
|
||||
assert score("咻咻满 reaction", duration=31 * 60) == 5
|
||||
def test_duration_boost_and_short_penalty():
|
||||
assert score("咻咻满 reaction", duration=LONG) == 6
|
||||
assert score("咻咻满 reaction", duration=MID) == 5
|
||||
assert score("咻咻满 reaction", duration=30 * 60) == 4
|
||||
assert score("咻咻满", duration=0) == 1
|
||||
assert score("咻咻满", duration=61 * 60) == 3
|
||||
assert score("咻咻满 双人", duration=61 * 60) == 6
|
||||
assert score("咻咻满 reaction", duration=NEUTRAL) == 4
|
||||
# 时长 <10 分钟 -1
|
||||
assert score("咻咻满 reaction", duration=SHORT) == 3
|
||||
assert score("咻咻满 reaction 充电", duration=SHORT) == 8
|
||||
assert score("咻咻满 双人", duration=SHORT) == 3
|
||||
# 时长未知(0) 不扣
|
||||
assert score("咻咻满 reaction", duration=0) == 4
|
||||
|
||||
|
||||
def test_negative_single_and_multi():
|
||||
# 单个负向词 -2
|
||||
assert score("咻咻满 切片", duration=60) == -1
|
||||
assert score("咻咻满 reaction 切片", duration=60) == 2
|
||||
assert score("咻咻满 reaction 直播录像", duration=60) == 2
|
||||
# 单个负向词 -2("咻咻满 切片" 无正向命中,基础为 0)
|
||||
assert score("咻咻满 切片") == -2
|
||||
assert score("咻咻满 reaction 切片") == 2
|
||||
assert score("咻咻满 reaction 直播录像") == 2
|
||||
# 2 个负向词:-2*2 -2 = -6
|
||||
assert score("咻咻满 歌切 切片", duration=60) == -5
|
||||
assert score("咻咻满 歌切 切片") == -6
|
||||
# 3 个负向词:-2*3 -6 = -12
|
||||
assert score("咻咻满 reaction 歌切 切片 完整版", duration=60) == -8
|
||||
assert score("咻咻满 reaction 歌切 切片 完整版") == -8
|
||||
|
||||
|
||||
def test_self_penalty():
|
||||
assert score("咻咻满 reaction", up_mid=37754047, duration=60) == 1
|
||||
assert score("咻咻满 reaction", up_mid=37754047) == 1
|
||||
|
||||
|
||||
def test_date_negative():
|
||||
assert score("咻咻满 reaction 2026-10-28 完整录播", duration=60) == -2
|
||||
assert score("咻咻满 reaction 2026-10-28", duration=60) == 0
|
||||
assert score("咻咻满 充电 10月25日", duration=60) == 0
|
||||
assert score("咻咻满 reaction 2026-10-28 完整录播") == -2
|
||||
assert score("咻咻满 reaction 2026-10-28") == 0
|
||||
assert score("咻咻满 充电 10月25日") == 0
|
||||
disabled = dict(CLASSIFY, date_negative=False)
|
||||
assert score("咻咻满 reaction 2026-10-28", duration=60, classify=disabled) == 4
|
||||
assert score("咻咻满 reaction 2026-10-28", classify=disabled) == 4
|
||||
|
||||
|
||||
def test_removed_words():
|
||||
# 纯享不再算负向
|
||||
assert score("咻咻满 reaction 纯享受", duration=60) == 4
|
||||
# 转场不再算正向(作为错别字忽略)
|
||||
assert score("咻咻满 转场", duration=60) == 1
|
||||
assert score("咻咻满 reaction 纯享受") == 4
|
||||
# 转场不再算正向(作为错别字忽略),且只过标题关不给分
|
||||
assert score("咻咻满 转场") == 0
|
||||
|
||||
|
||||
def test_multi_hit_survives_single_negative():
|
||||
# 回归:命中 咻咻满+reaction+充电 的 204min reaction 标题,扣掉单个「切片」后仍应过线
|
||||
title = "【充电2.0 | 咻咻满reaction】翻唱和直播切片完全是在开盲盒呀!不知道今天会开到哪一款"
|
||||
assert score(title, duration=204 * 60) == 9
|
||||
assert score(title, duration=60) == 7
|
||||
assert score(title, duration=NEUTRAL) == 7
|
||||
|
||||
|
||||
def main():
|
||||
test_title_helpers()
|
||||
test_parse_duration()
|
||||
test_date_hits()
|
||||
test_title_only_scores_zero()
|
||||
test_hit_count_is_superlinear()
|
||||
test_reaction_typo_and_gate()
|
||||
test_duration_boost()
|
||||
test_duration_boost_and_short_penalty()
|
||||
test_negative_single_and_multi()
|
||||
test_self_penalty()
|
||||
test_date_negative()
|
||||
test_removed_words()
|
||||
test_multi_hit_survives_single_negative()
|
||||
|
||||
# 直播回放示例:命中 require+专场+路人+第一次 = 4 -> 16;39min > 30 -> +1 = 17
|
||||
# 直播回放示例:命中 require+专场+路人+第一次 = 4 次 -> 16;39min > 30 -> +1 = 17
|
||||
title = "6【直播回放】咻咻满专场 路人第一次听满满唱歌 真的超级绝超级好听耶~"
|
||||
assert score(title, duration=39 * 60) == 17
|
||||
print("OK: 候选标题归一化、时长解析与分类打分测试全部通过")
|
||||
|
||||
@@ -56,6 +56,7 @@ def test_dual_accounts_and_env_override():
|
||||
assert cfg.up_blacklist_seed == (1, 2)
|
||||
assert cfg.discover.keywords == ("咻咻满",)
|
||||
assert cfg.discover.pages == 3
|
||||
assert cfg.discover.candidate_min_score == 4
|
||||
assert cfg.classify["self_mid"] == 37754047
|
||||
assert cfg.classify["reaction"] == ["re", "reaction", "反应"]
|
||||
assert cfg.classify["date_negative"] is True
|
||||
|
||||
+28
-4
@@ -35,6 +35,7 @@ def write_config(tmp, **discover):
|
||||
"keywords": ["咻咻满 reaction"],
|
||||
"pages": 1,
|
||||
"window_days": 14,
|
||||
"candidate_min_score": 0,
|
||||
**discover,
|
||||
},
|
||||
"classify": {
|
||||
@@ -91,8 +92,8 @@ def item(aid, *, title="咻咻满 reaction", up_mid=100, pubdate=NOW - 3600,
|
||||
)
|
||||
|
||||
|
||||
def make_service(tmp, items, *, notifier=None):
|
||||
path = write_config(tmp)
|
||||
def make_service(tmp, items, *, notifier=None, **discover):
|
||||
path = write_config(tmp, **discover)
|
||||
store = ConfigStore(path, env={})
|
||||
db = Database(Path(tmp) / "app.db", backup_dir=Path(tmp) / "backup")
|
||||
db.initialize()
|
||||
@@ -128,9 +129,9 @@ def test_filters_and_scoring():
|
||||
assert summary.title_miss == 1
|
||||
assert summary.old == 1
|
||||
assert summary.blacklisted == 1
|
||||
assert summary.candidates == 2 # aid 6(1 分)与 aid 2(2 分)
|
||||
assert summary.candidates == 2 # aid 6(0 分)与 aid 2(2 分)
|
||||
assert summary.notified == 0
|
||||
assert repo.get_candidate(6)["score"] == 1
|
||||
assert repo.get_candidate(6)["score"] == 0 # 只过标题关:0 分
|
||||
assert repo.get_candidate(2)["score"] == 2 # reaction 命中后单个「切片」不再清零
|
||||
assert repo.get_candidate(4) is None
|
||||
assert repo.get_candidate(5) is None
|
||||
@@ -217,12 +218,35 @@ def test_duplicate_search_results_notified_once():
|
||||
db.close()
|
||||
|
||||
|
||||
def test_candidate_min_score_floor():
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
db, repo, service = make_service(
|
||||
tmp,
|
||||
[
|
||||
item(1, title="咻咻满 reaction 充电"), # 9 分 -> 入池
|
||||
item(2, title="咻咻满 日常"), # 0 分 -> 只过标题关,不入池
|
||||
item(3, title="咻咻满 reaction 切片"), # 2 分 -> 低于候选线,不入池
|
||||
],
|
||||
candidate_min_score=4,
|
||||
)
|
||||
try:
|
||||
summary = service.run()
|
||||
assert summary.scored == 3
|
||||
assert summary.candidates == 1
|
||||
assert repo.get_candidate(1) is not None
|
||||
assert repo.get_candidate(2) is None
|
||||
assert repo.get_candidate(3) is None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
test_filters_and_scoring()
|
||||
test_notification_batch_and_failure()
|
||||
test_search_error_does_not_abort_other_keywords()
|
||||
test_pending_candidate_notified_on_later_run()
|
||||
test_duplicate_search_results_notified_once()
|
||||
test_candidate_min_score_floor()
|
||||
print("OK: 发现过滤、打分、候选池与通知批次测试全部通过")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user