Skip to content

Commit 4c7dad0

Browse files
committed
feat(session): 会话标题内容化——同源去重/首句升级/改名入口(REQ-282)
1 parent 5ebdd3b commit 4c7dad0

9 files changed

Lines changed: 344 additions & 2 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@ pub async fn start_live_session(
6464
trimmed.chars().take(100).collect()
6565
};
6666

67+
// REQ-282(v0.19.6):标题内容化 A 层——来源名净化(剥「 - 抖音」等固定
68+
// 平台尾缀)+ 近 90 天同源去重(抖音 #2)。查询失败不阻断采集(降级原标题)。
69+
let title = match state.db.recent_session_titles(90) {
70+
Ok(recent) => {
71+
crate::title_rules::dedupe_title(&recent, &crate::title_rules::normalize_source_title(&title))
72+
}
73+
Err(e) => {
74+
eprintln!("[title] 同源去重候选查询失败(降级原标题): {}", e);
75+
title
76+
}
77+
};
78+
6779
let params = LiveSessionParams {
6880
title: title.clone(),
6981
source_window: source_window.map(|s| s.chars().take(100).collect()),

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,33 @@ pub async fn finish_session(state: State<'_, AppState>, id: i64) -> Result<bool,
6464
Ok(ok)
6565
}
6666

67+
/// 会话改名(REQ-282,v0.19.6):详情头 ✎ 行内改名入口。
68+
///
69+
/// @ai-context: 改名落 title_kind='manual'——此后首句/AI 自动升级永不覆写
70+
/// (用户意志优先);空标题拒绝(与 create_session 兜底口径不同——
71+
/// 改名为显式动作,不静默回落)。
72+
#[tauri::command]
73+
pub fn update_session_title(state: State<'_, AppState>, id: i64, title: String) -> Result<(), String> {
74+
if id <= 0 {
75+
return Err("无效的会话 id".to_string());
76+
}
77+
let trimmed = title.trim();
78+
if trimmed.is_empty() {
79+
return Err("标题不能为空".to_string());
80+
}
81+
let title: String = trimmed.chars().take(TITLE_MAX_CHARS).collect();
82+
let ok = state
83+
.db
84+
.update_session_title(id, &title)
85+
.map_err(|e| e.to_string())?;
86+
if !ok {
87+
return Err("会话不存在".to_string());
88+
}
89+
// REQ-278:会话域变更广播(改名后列表/详情刷新)
90+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Sessions);
91+
Ok(())
92+
}
93+
6794
/// 列出会话(关键词可选;默认第 1 页 50 条,新→旧;v0.7.1 起携带转化状态标记)。
6895
///
6996
/// @ai-context: display_no 为单次返回列表内的 rank 语义(按时间序 1..=len)——

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,14 @@ CREATE TABLE IF NOT EXISTS contracts (
317317
// v0.11.7(图文会话,ADR-020):旧库迁移——sessions 表补 kind 列
318318
// (NULL=视频类会话零回归;'photo'=图文截屏会话;列表徽标/残留清扫依赖此列)
319319
ensure_column(conn, "sessions", "kind", "ALTER TABLE sessions ADD COLUMN kind TEXT")?;
320+
// REQ-282(v0.19.6):会话标题来源标记——'source'=来源名(可被首句/未来
321+
// AI 建议自动升级覆写);'manual'=用户改名(此后自动升级永不覆写)
322+
ensure_column(
323+
conn,
324+
"sessions",
325+
"title_kind",
326+
"ALTER TABLE sessions ADD COLUMN title_kind TEXT NOT NULL DEFAULT 'source'",
327+
)?;
320328
// v0.5.0 M4(REQ-048):旧库迁移——ocr_blocks 表补 region_kind 列
321329
ensure_column(
322330
conn,

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,52 @@ impl Db {
131131
})
132132
}
133133

134+
// ── REQ-282(v0.19.6):标题内容化 A 层 ──
135+
136+
/// 近 N 天非失败会话的标题(同源去重候选;录制/已结束都算,失败排除)。
137+
pub fn recent_session_titles(&self, days: i64) -> Result<Vec<String>> {
138+
let since = unix_seconds() - days.saturating_mul(86_400);
139+
self.with_conn(|conn| {
140+
let mut stmt = conn.prepare(
141+
"SELECT title FROM sessions WHERE status <> ?1 AND started_at >= ?2
142+
ORDER BY started_at DESC, id DESC",
143+
)?;
144+
let rows = stmt.query_map(params![SESSION_STATUS_FAILED, since], |row| {
145+
row.get::<_, String>(0)
146+
})?;
147+
rows.collect::<rusqlite::Result<Vec<_>>>().map_err(Into::into)
148+
})
149+
}
150+
151+
/// 人工改名(title_kind=manual——此后首句/AI 自动升级永不覆写)。
152+
pub fn update_session_title(&self, id: i64, title: &str) -> Result<bool> {
153+
self.with_conn(|conn| {
154+
let affected = conn.execute(
155+
"UPDATE sessions SET title = ?1, title_kind = 'manual' WHERE id = ?2",
156+
params![title, id],
157+
)?;
158+
Ok(affected > 0)
159+
})
160+
}
161+
162+
/// 首句自动升级(REQ-282):取首个可用转写句(8–40 字)为标题——
163+
/// 仅当 title_kind='source' 且标题确有变化时写(manual 永不被覆写)。
164+
pub fn auto_title_upgrade(&self, session_id: i64) -> Result<bool> {
165+
let segments = self.list_segments(session_id)?;
166+
let Some(candidate) =
167+
crate::title_rules::first_line_title(segments.iter().map(|seg| seg.text.as_str()))
168+
else {
169+
return Ok(false);
170+
};
171+
self.with_conn(|conn| {
172+
let affected = conn.execute(
173+
"UPDATE sessions SET title = ?1 WHERE id = ?2 AND title_kind = 'source' AND title <> ?1",
174+
params![candidate, session_id],
175+
)?;
176+
Ok(affected > 0)
177+
})
178+
}
179+
134180
/// 追加一条转写段(实时落库)。
135181
pub fn add_segment(&self, new: &NewSessionSegment) -> Result<SessionSegment> {
136182
self.with_conn(|conn| {

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ pub fn run_video_import<F: Fn(&ImportProgress)>(
9898
.map(|s| s.to_string_lossy().into_owned())
9999
.filter(|s| !s.trim().is_empty())
100100
.unwrap_or_else(|| "视频导入会话".to_string());
101+
// REQ-282(v0.19.6):同源去重——同名视频重复导入不产生双胞胎标题
102+
// (「xxx」→「xxx #2」);候选查询失败降级原名(零阻断)。
103+
let existing = db.recent_session_titles(90).unwrap_or_default();
104+
let title = crate::title_rules::dedupe_title(&existing, &title);
101105
let session = db.create_session(&NewSession {
102106
title: title.chars().take(100).collect(),
103107
source_window: None,
@@ -126,6 +130,9 @@ pub fn run_video_import<F: Fn(&ImportProgress)>(
126130
result.inspect_err(|_| {
127131
let _ = db.mark_session_failed(session_id);
128132
})?;
133+
// REQ-282(v0.19.6):首句升级——转写就绪后用首个可用句替换文件名标题
134+
// (仅 kind=source 生效;失败静默——不阻断导入主流程)。
135+
let _ = db.auto_title_upgrade(session_id);
129136
Ok(session_id)
130137
}
131138

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! 熵减桌面应用入口(Tauri 装配层)。
1+
//! 熵减桌面应用入口(Tauri 装配层)。
22
//!
33
//! @ai-context: 本文件只做模块声明与应用装配(插件注册 / 状态初始化 / command 注册),
44
//! 不含业务逻辑;业务自底向上分布:types → concat/db → asr/ocr → engine → commands。
@@ -403,6 +403,7 @@ mod commands_speaker;
403403
mod session_events;
404404
mod symbol_normalize;
405405
mod table_reconstruct;
406+
mod title_rules;
406407
mod types;
407408
mod ui_junk;
408409
mod vad_adaptive;
@@ -657,6 +658,8 @@ pub fn run() {
657658
commands_session::list_sessions,
658659
commands_session::get_session_detail,
659660
commands_session::delete_session,
661+
// REQ-282(v0.19.6):会话改名(title_kind=manual 停止自动覆写)
662+
commands_session::update_session_title,
660663
commands_session::add_session_segment,
661664
commands_session::add_session_ocr_block,
662665
// 会话 → 笔记(v0.7.6 审查硬拆:管线在 commands_session_note.rs,

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,25 @@ pub(crate) fn spawn_fusion(
8484
thread_tracker.end(session_id);
8585
match result {
8686
Ok(()) => {
87+
// REQ-282(v0.19.6):融合完成即首句升级——融合段为最终
88+
// 时间轴,此时取首个可用句最稳;仅 kind=source 生效,失败
89+
// 静默(标题保持来源名,不阻断 fused 事件);升级成功广播
90+
// 会话域变更(列表/详情即时可见新标题)。
91+
match fusion_db.auto_title_upgrade(session_id) {
92+
Ok(true) => crate::notify::emit_changed(
93+
&fusion_app,
94+
crate::notify::DataDomain::Sessions,
95+
),
96+
Ok(false) => {}
97+
Err(e) => eprintln!("[LiveSession] 会话标题首句升级失败: {}", e),
98+
}
8799
let _ = fusion_app.emit("session:fused", session_id);
88100
}
89101
Err(e) => {
90102
// 融合失败保留原段,前端提示(详情页仍可读原始轴)
103+
if let Err(t) = fusion_db.auto_title_upgrade(session_id) {
104+
eprintln!("[LiveSession] 会话标题首句升级失败(融合失败路径): {}", t);
105+
}
91106
let _ = fusion_app.emit(
92107
"session:fusion-failed",
93108
format!("融合失败(原始段已保留): {}", e),

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

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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

Comments
 (0)