Skip to content

Commit f992d8a

Browse files
committed
feat(series): 合集检测与档案记忆课程分组联动
1 parent d99edc8 commit f992d8a

6 files changed

Lines changed: 680 additions & 4 deletions

File tree

‎app/src-tauri/src/commands_session.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,13 @@ pub async fn session_outline(
335335
///
336336
/// @ai-context: 纯函数(派生方案——sessions 表不加 course 列,零迁移);
337337
/// 前端按此分组折叠;用户标记覆盖(前端本地)留 UI 层。
338+
/// @ai-context: v0.7.2(REQ-152):合集检测优先——P/第X集/EP/括号/数字后缀式标题
339+
/// 归组到**系列名**(B站合集 P1/P2/P3 自动同组);"第X章/节/讲"式
340+
/// 保持原语义(每章一组,现状零回归)。
338341
pub fn course_of(title: &str) -> String {
342+
if let Some(info) = crate::series_detect::extract_series(title) {
343+
return info.series;
344+
}
339345
let t = title.trim();
340346
let chars: Vec<char> = t.chars().collect();
341347
let mut i = 0;

‎app/src-tauri/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ mod subtitle;
149149
mod subtitle_detect;
150150
mod subtitle_ocr;
151151
mod speaker_change;
152+
// v0.7.2(REQ-152):视频系列(合集)检测——标题序列号提取/平台后缀剥离(纯逻辑)
153+
mod series_detect;
152154
// v0.7.0 M1.5(REQ-108):统一信号事件域(类型/分级/容量守卫;数据层在 db_session_events)
153155
mod session_events;
154156
mod symbol_normalize;

‎app/src-tauri/src/series_detect.rs‎

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
//! 视频系列(合集)检测(REQ-152 / v0.7.2:标题序列号提取与平台后缀剥离)。
2+
//!
3+
//! @ai-context: 从窗口标题/文件名剥离"系列名 + 集号"(B站分P `P3`/`第3集`/`EP03`/
4+
//! `(2)`/数字后缀 `03` 等),供档案检测**系列名投票**、记忆偏好
5+
//! **系列键**、课程分组使用——同一系列的 P1/P5 标题不同导致的投票
6+
//! 漂移与记忆失配,是检测准确度的真实短板(Foresight 头脑风暴
7+
//! brainstorming-video-profile-detection.md,根因 6)。
8+
//! @ai-context: 纯逻辑模块(无 IO/DB),决策矩阵可注入 fake 标题单测(AAA);
9+
//! 五模式按优先级匹配、命中即返回、全部未命中 → None 诚实不识别;
10+
//! 边界反例(`Python3`/`2026`/`UP主`/序号开头)必须挡掉——误判
11+
//! 比漏判更伤(会把普通标题错分系列导致记忆串台)。
12+
13+
/// 系列信息(extract_series 产出)。
14+
#[derive(Debug, Clone, PartialEq, Eq)]
15+
pub struct SeriesInfo {
16+
/// 系列名(剥离序号与平台后缀后的标题公共部分)
17+
pub series: String,
18+
/// 集号(可解析为数字时 Some;"第X话"中文数字也会规范化)
19+
pub episode: Option<u32>,
20+
}
21+
22+
/// 平台后缀剥离表(窗口标题常见形态;命中剥离,未命中原样返回)。
23+
const PLATFORM_SUFFIXES: &[&str] = &[
24+
"_哔哩哔哩_bilibili",
25+
" - YouTube",
26+
" - 腾讯视频",
27+
"_爱奇艺",
28+
" - 优酷",
29+
" - 芒果TV",
30+
" - 西瓜视频",
31+
" - 抖音",
32+
];
33+
34+
/// 模式 2 集号后缀(中文"第X"式;**不含"章节讲课部分"**——course_of 的
35+
/// "第X章"分组语义保留现状零回归,series 检测只管集/话/期/回)。
36+
const EPISODE_UNITS: &str = "集话期回";
37+
38+
/// 标题预处理:剥离平台后缀(窗口标题污染源,必须先剥离再识别系列)。
39+
pub fn normalize_title(title: &str) -> String {
40+
let t = title.trim();
41+
for suffix in PLATFORM_SUFFIXES {
42+
if let Some(rest) = t.strip_suffix(suffix) {
43+
let rest = rest.trim();
44+
if !rest.is_empty() {
45+
return rest.to_string();
46+
}
47+
}
48+
}
49+
t.to_string()
50+
}
51+
52+
/// 序号识别(五模式按优先级,命中即返回;全部未命中 → None 诚实不识别)。
53+
///
54+
/// @ai-context: 模式顺序:P 分P(B站最常见)→ 第X集 → EP → 括号序号 → 数字后缀;
55+
/// 系列名 = 序号之前部分去尾分隔符;序号在开头 → 系列名为空 → 放弃
56+
/// (取不到公共部分,诚实降级走现状路径)。
57+
pub fn extract_series(title: &str) -> Option<SeriesInfo> {
58+
let t = normalize_title(title);
59+
if t.chars().count() < 2 {
60+
return None;
61+
}
62+
if let Some((series, ep)) = match_p_episode(&t) {
63+
return Some(SeriesInfo { series, episode: Some(ep) });
64+
}
65+
if let Some((series, ep)) = match_cn_episode(&t) {
66+
return Some(SeriesInfo { series, episode: ep });
67+
}
68+
if let Some((series, ep)) = match_ep_prefix(&t) {
69+
return Some(SeriesInfo { series, episode: Some(ep) });
70+
}
71+
if let Some((series, ep)) = match_bracket_suffix(&t) {
72+
return Some(SeriesInfo { series, episode: ep });
73+
}
74+
if let Some((series, ep)) = match_numeric_suffix(&t) {
75+
return Some(SeriesInfo { series, episode: Some(ep) });
76+
}
77+
None
78+
}
79+
80+
/// 边界字符判定(P 前须为边界,防 `UP主`/`C#P` 误判)。
81+
fn is_boundary(c: char) -> bool {
82+
c.is_whitespace() || "-_·.,,。、()()【】[]".contains(c)
83+
}
84+
85+
/// 系列名尾部分隔符清理("零基础化妆 -" → "零基础化妆")。
86+
fn trim_trailing_sep(s: &str) -> String {
87+
let trimmed = s.trim();
88+
let chars: Vec<char> = trimmed.chars().collect();
89+
let mut end = chars.len();
90+
while end > 0 && is_boundary(chars[end - 1]) {
91+
end -= 1;
92+
}
93+
chars[..end].iter().collect::<String>().trim().to_string()
94+
}
95+
96+
/// 模式 1:B站分P——`xxx P3` / `xxx P 12`(P 前须为边界;数字 1-3 位)。
97+
fn match_p_episode(t: &str) -> Option<(String, u32)> {
98+
let chars: Vec<char> = t.chars().collect();
99+
for i in 0..chars.len() {
100+
if (chars[i] == 'P' || chars[i] == 'p') && (i == 0 || is_boundary(chars[i - 1])) {
101+
let mut j = i + 1;
102+
while j < chars.len() && chars[j] == ' ' {
103+
j += 1;
104+
}
105+
let mut k = j;
106+
while k < chars.len() && chars[k].is_ascii_digit() {
107+
k += 1;
108+
}
109+
if k > j && k - j <= 3 {
110+
let ep: u32 = chars[j..k].iter().collect::<String>().parse().ok()?;
111+
let series = trim_trailing_sep(&chars[..i].iter().collect::<String>());
112+
if series.chars().count() >= 2 {
113+
return Some((series, ep));
114+
}
115+
}
116+
}
117+
}
118+
None
119+
}
120+
121+
/// 中文数字判定(模式 2 与 normalize_episode 共用;含"零")。
122+
pub(crate) fn is_cjk_num_char(c: char) -> bool {
123+
"零〇一二三四五六七八九十百".contains(c)
124+
}
125+
126+
/// 模式 2:中文集号——`xxx 第3集` / `xxx 第十二话`(后缀:集/话/期/回)。
127+
fn match_cn_episode(t: &str) -> Option<(String, Option<u32>)> {
128+
let chars: Vec<char> = t.chars().collect();
129+
for i in 0..chars.len() {
130+
if chars[i] == '第' {
131+
let mut j = i + 1;
132+
while j < chars.len() && (chars[j].is_ascii_digit() || is_cjk_num_char(chars[j])) {
133+
j += 1;
134+
}
135+
if j > i + 1 && j < chars.len() && EPISODE_UNITS.contains(chars[j]) {
136+
let num_str: String = chars[i + 1..j].iter().collect();
137+
let episode = normalize_episode(&num_str);
138+
let series = trim_trailing_sep(&chars[..i].iter().collect::<String>());
139+
if series.chars().count() >= 2 {
140+
return Some((series, episode));
141+
}
142+
}
143+
}
144+
}
145+
None
146+
}
147+
148+
/// 模式 3:EP 前缀——`xxx EP03` / `xxx EP12` / `xxx E12`(单 E 需 ≥2 位数字防误判)。
149+
fn match_ep_prefix(t: &str) -> Option<(String, u32)> {
150+
let chars: Vec<char> = t.chars().collect();
151+
for i in 0..chars.len() {
152+
// EP(任意大小写组合)后跟 1-3 位数字;EP 前须为边界
153+
let ep = (i + 1 < chars.len())
154+
&& ((chars[i] == 'E' && chars[i + 1] == 'P')
155+
|| (chars[i] == 'e' && chars[i + 1] == 'p')
156+
|| (chars[i] == 'E' && chars[i + 1] == 'p')
157+
|| (chars[i] == 'e' && chars[i + 1] == 'P'));
158+
if ep && (i == 0 || is_boundary(chars[i - 1])) {
159+
let mut k = i + 2;
160+
while k < chars.len() && chars[k].is_ascii_digit() {
161+
k += 1;
162+
}
163+
if k > i + 2 && k - (i + 2) <= 3 {
164+
let ep_num: u32 = chars[i + 2..k].iter().collect::<String>().parse().ok()?;
165+
let series = trim_trailing_sep(&chars[..i].iter().collect::<String>());
166+
if series.chars().count() >= 2 {
167+
return Some((series, ep_num));
168+
}
169+
}
170+
continue; // EP 已尝试(无论成败),不落单 E 分支
171+
}
172+
// 单 E/e + 2-3 位数字(1 位太易误判,如 "E3")
173+
if (chars[i] == 'E' || chars[i] == 'e')
174+
&& (i == 0 || is_boundary(chars[i - 1]))
175+
&& i + 1 < chars.len()
176+
&& chars[i + 1].is_ascii_digit()
177+
{
178+
let mut k = i + 1;
179+
while k < chars.len() && chars[k].is_ascii_digit() {
180+
k += 1;
181+
}
182+
if k - (i + 1) >= 2 && k - (i + 1) <= 3 {
183+
let ep_num: u32 = chars[i + 1..k].iter().collect::<String>().parse().ok()?;
184+
let series = trim_trailing_sep(&chars[..i].iter().collect::<String>());
185+
if series.chars().count() >= 2 {
186+
return Some((series, ep_num));
187+
}
188+
}
189+
}
190+
}
191+
None
192+
}
193+
194+
/// 模式 4:尾部括号序号——`xxx(2)` / `xxx(2)` / `xxx【3】` / `xxx[3]`。
195+
fn match_bracket_suffix(t: &str) -> Option<(String, Option<u32>)> {
196+
let chars: Vec<char> = t.chars().collect();
197+
let len = chars.len();
198+
// 找尾部配对括号(最后一对)
199+
let close = chars[..].iter().rposition(|c| "))]】".contains(*c))?;
200+
if close + 1 != len {
201+
return None; // 括号后还有内容(如 "(2)合集")→ 不按尾部序号处理
202+
}
203+
let open = chars[..close].iter().rposition(|c| "(([【".contains(*c))?;
204+
let inner: String = chars[open + 1..close].iter().collect();
205+
// 括号内纯数字(1-3 位)才当集号("(2024)"年份 4 位排除;"(1/3)"含斜杠排除)
206+
if inner.is_empty()
207+
|| inner.chars().count() > 3
208+
|| !inner.chars().all(|c| c.is_ascii_digit())
209+
{
210+
return None;
211+
}
212+
let episode = inner.parse::<u32>().ok();
213+
let series = trim_trailing_sep(&chars[..open].iter().collect::<String>());
214+
if series.chars().count() >= 2 {
215+
Some((series, episode))
216+
} else {
217+
None
218+
}
219+
}
220+
221+
/// 模式 5:尾部数字后缀——`xxx 03` / `xxx-03` / `xxx_03`(2-3 位,前置分隔符)。
222+
fn match_numeric_suffix(t: &str) -> Option<(String, u32)> {
223+
let chars: Vec<char> = t.chars().collect();
224+
let len = chars.len();
225+
// 尾部连续数字(2-3 位;1 位太易误判如 "教程1")
226+
let mut start = len;
227+
while start > 0 && chars[start - 1].is_ascii_digit() {
228+
start -= 1;
229+
}
230+
let digits = len - start;
231+
if !(2..=3).contains(&digits) || start == 0 {
232+
return None;
233+
}
234+
// 前置必须是分隔符(空格/连字符/下划线),防 "Python3"/"Win11" 紧贴误判
235+
if !is_boundary(chars[start - 1]) {
236+
return None;
237+
}
238+
let ep: u32 = chars[start..len].iter().collect::<String>().parse().ok()?;
239+
let series = trim_trailing_sep(&chars[..start - 1].iter().collect::<String>());
240+
if series.chars().count() >= 2 {
241+
Some((series, ep))
242+
} else {
243+
None
244+
}
245+
}
246+
247+
/// 中文数字 → 值映射(显式表——Unicode 码点不连续('二'=U+4E8C 在 '九'=U+4E5D 之后),
248+
/// 不可用 range 相减推算('三'-'一'=9 会算出 10))。
249+
const CJK_DIGIT_VALUES: &[(char, u32)] = &[
250+
('一', 1), ('二', 2), ('三', 3), ('四', 4), ('五', 5),
251+
('六', 6), ('七', 7), ('八', 8), ('九', 9),
252+
];
253+
254+
/// 集号规范化:阿拉伯数字直出;中文数字转阿拉伯("十二"→12,"二十三"→23,"十"→10)。
255+
pub fn normalize_episode(s: &str) -> Option<u32> {
256+
if s.is_empty() {
257+
return None;
258+
}
259+
if s.chars().all(|c| c.is_ascii_digit()) {
260+
return s.parse().ok();
261+
}
262+
let mut total = 0u32;
263+
let mut section = 0u32;
264+
for c in s.chars() {
265+
if let Some(v) = CJK_DIGIT_VALUES.iter().find(|(ch, _)| *ch == c) {
266+
section = v.1;
267+
} else {
268+
match c {
269+
'零' | '〇' => {}
270+
'十' => {
271+
total += if section == 0 { 10 } else { section * 10 };
272+
section = 0;
273+
}
274+
'百' => {
275+
total += if section == 0 { 100 } else { section * 100 };
276+
section = 0;
277+
}
278+
_ => return None,
279+
}
280+
}
281+
}
282+
total += section;
283+
if total == 0 {
284+
None
285+
} else {
286+
Some(total)
287+
}
288+
}
289+
290+
/// 单测独立文件(保持本文件 ≤300 行,AGENTS.md §3)。
291+
#[cfg(test)]
292+
#[path = "series_detect_tests.rs"]
293+
mod tests;

0 commit comments

Comments
 (0)