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