Skip to content

Commit fc8e862

Browse files
committed
feat(asr): REQ-268 会话离线精修后端(第二遍+草稿表+命令)
1 parent 152cf56 commit fc8e862

10 files changed

Lines changed: 1163 additions & 1 deletion

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,8 @@ pub fn setup_app_state(app: &mut tauri::App) -> Result<(), String> {
288288
overlay_image_path: std::sync::Arc::new(std::sync::Mutex::new(None)),
289289
// v0.12.3:浮窗 UI 状态(locked/topmost——Rust 侧单一来源,见 commands_window.rs)
290290
float_ui: std::sync::Arc::new(std::sync::Mutex::new(crate::commands_window::FloatUi::default())),
291+
// v0.20.2(REQ-268):会话第二遍离线精修运行注册表(默认空)
292+
second_pass_jobs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
291293
});
292294
// v0.12.3(P2-10):浮窗预创建常驻(隐藏)——打开秒显、点击期零建窗风险;
293295
// 失败幂等回落为打开时懒创建(open_capture_float 内部兜底)。

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

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
//! ASR 会话全量离线精修(第二遍)——纯逻辑层(v0.20.2 / REQ-268)。
2+
//!
3+
//! @ai-context: 实时链路只做端点句 SenseVoice 重打分(3s 有界,streaming_asr),
4+
//! 导入才是全窗两遍——本模块把"导入同级离线质量"带给实时会话:
5+
//! 会话结束后用 S4 落盘音频(data_dir/session-audio/{id}.wav,
6+
//! live_session_loop finalize 出的 16k PCM16)重跑全窗 SenseVoice,
7+
//! 逐窗与现网段轴比对产出「替换草稿」,采纳/回退由命令层落
8+
//! session_refine_drafts(原料 session_segments 永不变——可逆契约)。
9+
//! @ai-context: 分窗复用导入链路同款窗口(30s 窗 + 2s 重叠,import::plan_...)
10+
//! ——与 import 同解码口径,窗边句由相邻窗文本重叠自然冗余;
11+
//! 对比门限 SKIP_SIMILAR_ABOVE:现网已接近离线质量(相似 ≥0.85)
12+
//! 的窗不产草稿,避免无意义噪音(预览只看到有改善的窗)。
13+
//! @ai-context: 本模块纯逻辑无 IO:窗规划/基线拼接/相似度/覆盖合并全部可单测
14+
//! (asr_pass2_tests.rs);解码编排与命令面在 commands_asr_pass2.rs。
15+
//! @ai-context: dead_code 豁免——命令面接线提交后移除(先纯逻辑后系统层)。
16+
17+
#![allow(dead_code)]
18+
19+
/// 通用时间轴文本(纯函数输入,与 DB 行解耦)。
20+
#[derive(Debug, Clone, PartialEq)]
21+
pub struct TimedText {
22+
pub start_ms: u64,
23+
pub end_ms: u64,
24+
pub text: String,
25+
}
26+
27+
/// 单窗第二遍替换草稿(纯逻辑产物,落库见 db_session_refine::NewRefineDraft)。
28+
#[derive(Debug, Clone, PartialEq)]
29+
pub struct Pass2Proposal {
30+
pub start_ms: u64,
31+
pub end_ms: u64,
32+
/// 现网基线文本(窗覆盖段按时间序拼接;空=该窗原链路无内容)。
33+
pub base_text: String,
34+
/// 第二遍全窗转写文本(SenseVoice,trim 后)。
35+
pub refined_text: String,
36+
/// 基线与精修文本的内容相似度(0..1;1=逐字一致)。
37+
pub similarity: f32,
38+
}
39+
40+
/// 跳过产草稿的相似度门限(≥门限视为"已接近离线质量",无改善价值)。
41+
pub const SKIP_SIMILAR_ABOVE: f32 = 0.85;
42+
/// 覆盖主导率:原段被采纳窗覆盖 ≥ 该比例时在有效轴上被取代(防窗边重复;
43+
/// 部分覆盖(< 比例)保留原段——宁可窗边轻微重复,不可丢内容)。
44+
pub const DROP_COVERAGE_RATIO: f32 = 0.6;
45+
46+
/// 第二遍分窗(与导入链路同窗 30s + 重叠 2s——同解码口径)。
47+
pub fn plan_windows(duration_ms: u64) -> Vec<(u64, u64)> {
48+
crate::import::plan_chunks_with_overlap(
49+
duration_ms,
50+
crate::import::CHUNK_WINDOW_MS,
51+
crate::import::CHUNK_OVERLAP_MS,
52+
)
53+
}
54+
55+
/// 与窗口有重叠的段(按时间轴升序;用于基线拼接与覆盖判定)。
56+
fn covered<'a>(segs: &'a [TimedText], win: (u64, u64)) -> Vec<&'a TimedText> {
57+
let (ws, we) = win;
58+
let mut v: Vec<&TimedText> = segs
59+
.iter()
60+
.filter(|s| s.start_ms < we && s.end_ms > ws)
61+
.collect();
62+
v.sort_by_key(|s| (s.start_ms, s.end_ms));
63+
v
64+
}
65+
66+
/// 窗口基线文本:覆盖段文本按时间序单空格拼接(净化交给下游 note 链)。
67+
pub fn window_base_text(segs: &[TimedText], win: (u64, u64)) -> String {
68+
covered(segs, win)
69+
.into_iter()
70+
.map(|s| s.text.trim())
71+
.filter(|t| !t.is_empty())
72+
.collect::<Vec<_>>()
73+
.join(" ")
74+
}
75+
76+
/// 去标点空白的内容文本(与 asr_rescore 去标点口径同源——CER/画像同口径)。
77+
fn content_chars(s: &str) -> Vec<char> {
78+
crate::asr_rescore::strip_punct(s)
79+
}
80+
81+
/// 内容相似度(纯函数):去标点空白后 Levenshtein 归一化——1 - 编辑距离/较长串。
82+
///
83+
/// @ai-context: 现网 vs 离线两遍同源词面(同模型族)——微小标点/断句差异
84+
/// 不应算"改善";标点不进距离,内容字编辑成本为 1。
85+
pub fn normalized_similarity(a: &str, b: &str) -> f32 {
86+
let ca: String = content_chars(a).into_iter().collect();
87+
let cb: String = content_chars(b).into_iter().collect();
88+
let max = ca.chars().count().max(cb.chars().count());
89+
if max == 0 {
90+
return 1.0;
91+
}
92+
let dist = crate::asr_rescore::levenshtein(&ca, &cb);
93+
1.0 - dist as f32 / max as f32
94+
}
95+
96+
/// 判定单窗是否产草稿(纯函数):精修文本非空;基线文本非空时需
97+
/// 相似度低于门限(有实质差异才值得人工裁决)。
98+
pub fn propose_window(
99+
win: (u64, u64),
100+
refined_raw: &str,
101+
segs: &[TimedText],
102+
) -> Option<Pass2Proposal> {
103+
let refined = refined_raw.trim().to_string();
104+
if refined.is_empty() {
105+
return None;
106+
}
107+
let base = window_base_text(segs, win);
108+
if base.is_empty() {
109+
// 窗覆盖段全空但仍产出转写 = 原链路丢内容(漏识)——值得提议恢复
110+
return Some(Pass2Proposal {
111+
start_ms: win.0,
112+
end_ms: win.1,
113+
base_text: String::new(),
114+
refined_text: refined,
115+
similarity: 0.0,
116+
});
117+
}
118+
let sim = normalized_similarity(&base, &refined);
119+
if sim >= SKIP_SIMILAR_ABOVE {
120+
return None;
121+
}
122+
Some(Pass2Proposal {
123+
start_ms: win.0,
124+
end_ms: win.1,
125+
base_text: base,
126+
refined_text: refined,
127+
similarity: sim,
128+
})
129+
}
130+
131+
/// 合并相互重叠的采纳块(窗间 2s 重叠——同句跨窗重复文本经语义合并去重;
132+
/// 合并失败(无文本衔接证据)退化为空格拼接,不丢内容)。
133+
fn merge_overlapping(adopted: &[TimedText]) -> Vec<TimedText> {
134+
let mut sorted: Vec<TimedText> = adopted.to_vec();
135+
sorted.sort_by_key(|a| (a.start_ms, a.end_ms));
136+
let mut out: Vec<TimedText> = Vec::new();
137+
for cur in sorted {
138+
match out.last_mut() {
139+
Some(prev) if cur.start_ms < prev.end_ms => {
140+
let joined = crate::asr_merge::merge_segments_with_spacing(
141+
&prev.text,
142+
&cur.text,
143+
0,
144+
)
145+
.unwrap_or_else(|| format!("{} {}", prev.text, cur.text));
146+
prev.end_ms = prev.end_ms.max(cur.end_ms);
147+
prev.text = joined;
148+
}
149+
_ => out.push(cur),
150+
}
151+
}
152+
out
153+
}
154+
155+
/// 有效时间轴:原段表 + 已采纳替换窗合成(原料表不动——可逆契约)。
156+
///
157+
/// @ai-context: 采用"覆盖主导"取代语义:原段被任一采纳块覆盖 ≥ DROP_COVERAGE_RATIO
158+
/// 即在有效轴上让位(文本已在精修窗内重述);部分覆盖的边段保留
159+
/// 原文,避免窗边词句丢失。返回按时间轴升序的合成文本。
160+
pub fn effective_segments(segs: &[TimedText], adopted: &[TimedText]) -> Vec<TimedText> {
161+
if adopted.is_empty() {
162+
return segs.to_vec();
163+
}
164+
let blocks = merge_overlapping(adopted);
165+
let mut out: Vec<TimedText> = Vec::new();
166+
for b in &blocks {
167+
out.push(b.clone());
168+
}
169+
for s in segs {
170+
let len = (s.end_ms.saturating_sub(s.start_ms)) as f32;
171+
let covered_len: u64 = blocks
172+
.iter()
173+
.map(|b| {
174+
let ov = b.end_ms.min(s.end_ms).saturating_sub(b.start_ms.max(s.start_ms));
175+
ov
176+
})
177+
.sum();
178+
if len > 0.0 && covered_len as f32 / len >= DROP_COVERAGE_RATIO {
179+
continue;
180+
}
181+
out.push(s.clone());
182+
}
183+
out.sort_by_key(|t| (t.start_ms, t.end_ms));
184+
out
185+
}
186+
187+
/// 已采纳块(窗口级)→ 段表副本合成(原料 session_segments 不动——可逆契约)。
188+
///
189+
/// @ai-context: 与 effective_segments 同覆盖语义(≥ DROP_COVERAGE_RATIO 让位),
190+
/// 但保留 SessionSegment 行结构(id 供 note_filter 去重/时间锚点):
191+
/// 让位原段按其主导块归属;合成行 id = 其覆盖原段最小 id
192+
/// (时间锚点仍指向窗内首段),无覆盖原段(纯插入)→ 负 id 占位
193+
/// (与真实正 id 不冲突;note_filter 仅要求段内唯一)。
194+
pub fn overlay_segments(
195+
segs: &[crate::types::SessionSegment],
196+
adopted: &[(u64, u64, String)],
197+
) -> Vec<crate::types::SessionSegment> {
198+
use crate::types::SessionSegment;
199+
if adopted.is_empty() {
200+
return segs.to_vec();
201+
}
202+
let adopted_timed: Vec<TimedText> = adopted
203+
.iter()
204+
.map(|(s, e, t)| TimedText { start_ms: *s, end_ms: *e, text: t.clone() })
205+
.collect();
206+
let blocks = merge_overlapping(&adopted_timed);
207+
// 第一遍:判定每块主导覆盖的原段(让位集合),随后按块分配合成行 id
208+
let mut dropped_by_block: Vec<Vec<i64>> = vec![Vec::new(); blocks.len()];
209+
let mut drop_flags: Vec<bool> = vec![false; segs.len()];
210+
for (bi, b) in blocks.iter().enumerate() {
211+
for (si, s) in segs.iter().enumerate() {
212+
let len = s.end_ms.saturating_sub(s.start_ms) as f32;
213+
let ov = b.end_ms.min(s.end_ms).saturating_sub(b.start_ms.max(s.start_ms)) as f32;
214+
if len > 0.0 && ov / len >= DROP_COVERAGE_RATIO {
215+
dropped_by_block[bi].push(s.id);
216+
drop_flags[si] = true;
217+
}
218+
}
219+
}
220+
let mut out: Vec<SessionSegment> = Vec::new();
221+
for (bi, b) in blocks.iter().enumerate() {
222+
let id = dropped_by_block[bi].iter().copied().min().unwrap_or_else(|| -(bi as i64 + 1));
223+
out.push(SessionSegment {
224+
id,
225+
session_id: segs.first().map(|s| s.session_id).unwrap_or(0),
226+
start_ms: b.start_ms,
227+
end_ms: b.end_ms,
228+
text: b.text.clone(),
229+
// 来源标记与 db_session_refine::SOURCE_ASR_PASS2 同值(避免纯层依赖
230+
// DB 模块;双处同串——db 侧为唯一权威常量)
231+
source: "asr_pass2".to_string(),
232+
confidence: None,
233+
volume: None,
234+
speech_rate: None,
235+
pause_ms: None,
236+
speaker: None,
237+
});
238+
}
239+
for (si, s) in segs.iter().enumerate() {
240+
if !drop_flags[si] {
241+
out.push(s.clone());
242+
}
243+
}
244+
out.sort_by_key(|x| (x.start_ms, x.id));
245+
out
246+
}
247+
248+
#[cfg(test)]
249+
#[path = "asr_pass2_tests.rs"]
250+
mod tests;

0 commit comments

Comments
 (0)