|
| 1 | +//! 会话标题规则层(REQ-282,v0.19.6)——零 AI 标题内容化 A 层纯函数。 |
| 2 | +//! |
| 3 | +//! @ai-context: 痛点——抖音等客户端窗口标题固定「抖音」,连续采集多视频产生 |
| 4 | +//! 同名会话(用户反馈 #2)。本层三规则:① 来源名净化(剥平台 |
| 5 | +//! 尾缀:`视频名 - 抖音` → `视频名`);② 同源去重(近 90 天同 |
| 6 | +//! 归一标题 → 追加「 #N」序号);③ 首句升级(会话结束取首个 |
| 7 | +//! 稳定转写句 8–40 字作标题)。全部纯函数可单测;AI 标题建议 |
| 8 | +//! (v0.19.8)在此层之上叠加,本层离线零依赖。 |
| 9 | +//! |
| 10 | +//! @ai-context: title_kind 语义在 DB 层(source/manual)——自动升级仅当 |
| 11 | +//! kind=source 时写(manual=用户已改名,永不被覆写)。 |
| 12 | +
|
| 13 | +/// 平台尾缀品牌(v0.19.2 评分表同源白名单;只收"标题固定无内容"的平台)。 |
| 14 | +const BRAND_SUFFIXES: &[&str] = &["抖音", "douyin", "Douyin", "抖音短视频", "快手", "kuaishou"]; |
| 15 | +/// 品牌前分隔符(含空格变体)。 |
| 16 | +const BRAND_SEPARATORS: &[&str] = &[" - ", "-", "|", "|", "_"]; |
| 17 | + |
| 18 | +/// 剥离平台尾缀:仅当标题以「分隔符+品牌」结尾时剥除(`视频名 - 抖音` → |
| 19 | +/// `视频名`;标题恰为「抖音」时原样返回——去重层负责加序号)。 |
| 20 | +pub fn normalize_source_title(raw: &str) -> String { |
| 21 | + let trimmed = raw.trim(); |
| 22 | + for brand in BRAND_SUFFIXES { |
| 23 | + for sep in BRAND_SEPARATORS { |
| 24 | + let suffix = format!("{}{}", sep, brand); |
| 25 | + if trimmed.ends_with(&suffix) { |
| 26 | + return trimmed[..trimmed.len() - suffix.len()].trim().to_string(); |
| 27 | + } |
| 28 | + } |
| 29 | + } |
| 30 | + trimmed.to_string() |
| 31 | +} |
| 32 | + |
| 33 | +/// 同源去重:existing 含 base → 追加「 #N」(N=现有最大后缀 +1,首个为 #2)。 |
| 34 | +/// |
| 35 | +/// @ai-context: 纯比较不猜测——只处理「恰好等于 base」与「base #N」两类; |
| 36 | +/// 与标题规范化解耦(调用方先 normalize 再入参)。 |
| 37 | +pub fn dedupe_title(existing: &[String], base: &str) -> String { |
| 38 | + if !existing.iter().any(|t| t == base) { |
| 39 | + return base.to_string(); |
| 40 | + } |
| 41 | + let mut max = 1; |
| 42 | + for t in existing { |
| 43 | + if let Some(rest) = t.strip_prefix(base) { |
| 44 | + if let Some(num) = rest.strip_prefix(" #") { |
| 45 | + if let Ok(n) = num.parse::<u32>() { |
| 46 | + max = max.max(n); |
| 47 | + } |
| 48 | + } |
| 49 | + } |
| 50 | + } |
| 51 | + format!("{} #{}", base, max + 1) |
| 52 | +} |
| 53 | + |
| 54 | +/// 标题长度下限/上限(截断基准,单位=字符)。 |
| 55 | +pub const TITLE_MIN_CHARS: usize = 8; |
| 56 | +pub const TITLE_MAX_CHARS: usize = 40; |
| 57 | + |
| 58 | +/// 取首个可用转写句作标题候选(首句升级规则): |
| 59 | +/// |
| 60 | +/// @ai-context: 可用=trim 后字符数 ∈ [8, 40](8 下=口播碎句无信息;40 上截断) |
| 61 | +/// 且非纯符号;纯音乐/无文本/全为无效句 → None(不升级,诚实)。 |
| 62 | +/// 输入=按时间序的段文本(字幕/语音/融合段同轴)。 |
| 63 | +pub fn first_line_title<'a>(lines: impl Iterator<Item = &'a str>) -> Option<String> { |
| 64 | + for line in lines { |
| 65 | + let cleaned = line.trim(); |
| 66 | + if cleaned.is_empty() { |
| 67 | + continue; |
| 68 | + } |
| 69 | + let chars: Vec<char> = cleaned.chars().collect(); |
| 70 | + if chars.len() < TITLE_MIN_CHARS { |
| 71 | + continue; |
| 72 | + } |
| 73 | + // 纯符号/标点句(无实义字符)跳过——防「。。。」/「♪♪♪」当标题 |
| 74 | + if !chars.iter().any(|c| c.is_alphanumeric() || is_han(*c)) { |
| 75 | + continue; |
| 76 | + } |
| 77 | + let mut out: String = chars.into_iter().take(TITLE_MAX_CHARS).collect(); |
| 78 | + if cleaned.chars().count() > TITLE_MAX_CHARS { |
| 79 | + out.push('…'); |
| 80 | + } |
| 81 | + return Some(out); |
| 82 | + } |
| 83 | + None |
| 84 | +} |
| 85 | + |
| 86 | +fn is_han(c: char) -> bool { |
| 87 | + ('\u{4e00}'..='\u{9fff}').contains(&c) |
| 88 | +} |
| 89 | + |
| 90 | +#[cfg(test)] |
| 91 | +mod tests { |
| 92 | + use super::*; |
| 93 | + |
| 94 | + fn s(items: &[&str]) -> Vec<String> { |
| 95 | + items.iter().map(|x| x.to_string()).collect() |
| 96 | + } |
| 97 | + |
| 98 | + #[test] |
| 99 | + fn normalize_strips_douyin_browser_suffix() { |
| 100 | + assert_eq!(normalize_source_title("眼影晕染教程 - 抖音"), "眼影晕染教程"); |
| 101 | + assert_eq!(normalize_source_title("眼影晕染教程_抖音"), "眼影晕染教程"); |
| 102 | + assert_eq!(normalize_source_title("视频 | 快手"), "视频"); |
| 103 | + } |
| 104 | + |
| 105 | + #[test] |
| 106 | + fn normalize_keeps_bare_brand_as_base() { |
| 107 | + // 抖音客户端标题固定「抖音」——无内容可剥,原样留给去重层加序号 |
| 108 | + assert_eq!(normalize_source_title("抖音"), "抖音"); |
| 109 | + assert_eq!(normalize_source_title(" 抖音 "), "抖音"); |
| 110 | + } |
| 111 | + |
| 112 | + #[test] |
| 113 | + fn normalize_does_not_touch_content_titles() { |
| 114 | + assert_eq!(normalize_source_title("数据库系统概论"), "数据库系统概论"); |
| 115 | + assert_eq!(normalize_source_title("B站纪录片:深海"), "B站纪录片:深海"); |
| 116 | + } |
| 117 | + |
| 118 | + #[test] |
| 119 | + fn dedupe_first_occurrence_keeps_base() { |
| 120 | + assert_eq!(dedupe_title(&s(&["物理课", "数学课"]), "抖音"), "抖音"); |
| 121 | + assert_eq!(dedupe_title(&[], "抖音"), "抖音"); |
| 122 | + } |
| 123 | + |
| 124 | + #[test] |
| 125 | + fn dedupe_second_is_hash_two_then_increments() { |
| 126 | + assert_eq!(dedupe_title(&s(&["抖音"]), "抖音"), "抖音 #2"); |
| 127 | + assert_eq!(dedupe_title(&s(&["抖音", "抖音 #2"]), "抖音"), "抖音 #3"); |
| 128 | + assert_eq!(dedupe_title(&s(&["抖音", "抖音 #7", "抖音 #2"]), "抖音"), "抖音 #8"); |
| 129 | + } |
| 130 | + |
| 131 | + #[test] |
| 132 | + fn dedupe_ignores_unrelated_prefixes() { |
| 133 | + // 「抖音官方账号」不应被当作已用序号 |
| 134 | + assert_eq!(dedupe_title(&s(&["抖音官方账号", "抖音极速版"]), "抖音"), "抖音 #2"); |
| 135 | + } |
| 136 | + |
| 137 | + #[test] |
| 138 | + fn first_line_skips_short_and_picks_usable() { |
| 139 | + let lines = ["嗯", "大家好", "今天我们讲眼影晕染的三种基本手法和常见误区"]; |
| 140 | + assert_eq!( |
| 141 | + first_line_title(lines.iter().copied()).unwrap(), |
| 142 | + "今天我们讲眼影晕染的三种基本手法和常见误区" |
| 143 | + ); |
| 144 | + } |
| 145 | + |
| 146 | + #[test] |
| 147 | + fn first_line_truncates_long_text_at_40() { |
| 148 | + let long = "这是一段非常非常非常非常非常非常非常非常非常长的开场白句子用来测试标题截断逻辑"; |
| 149 | + let out = first_line_title(std::iter::once(long)).unwrap(); |
| 150 | + assert!(out.chars().count() == TITLE_MAX_CHARS + 1); // 40 + '…' |
| 151 | + assert!(out.ends_with('…')); |
| 152 | + } |
| 153 | + |
| 154 | + #[test] |
| 155 | + fn first_line_rejects_pure_symbols_and_all_empty() { |
| 156 | + assert_eq!(first_line_title(["♪♪♪".to_string()].iter().map(|x| x.as_str())), None); |
| 157 | + assert_eq!( |
| 158 | + first_line_title(["。。。".to_string(), " ".to_string()].iter().map(|x| x.as_str())), |
| 159 | + None |
| 160 | + ); |
| 161 | + assert_eq!(first_line_title(std::iter::empty::<&str>()), None); |
| 162 | + } |
| 163 | +} |
0 commit comments