@@ -71,9 +71,16 @@ SpeechMatchContext buildMatchContext({
7171
7272/// 检测 A:连续尾部匹配。
7373///
74- /// 原文末尾有 ≥1 个连续词被匹配,且该尾部子序列在原文中唯一出现。
74+ /// 原文末尾有 ≥[minConsecutive] 个连续词被匹配,且该尾部子序列在原文中唯一出现。
7575/// 触发条件说明:用户说出了原文结尾的独特片段,大概率已读完。
76- DetectionResult detectTailMatch (SpeechMatchContext ctx) {
76+ ///
77+ /// 默认参数与历史跟读行为一致([minConsecutive] = 1,阈值 1s)。
78+ /// 复述场景调用方应传 `minConsecutive: 3, triggerDuration: Duration(seconds: 3)` 收紧。
79+ DetectionResult detectTailMatch (
80+ SpeechMatchContext ctx, {
81+ int minConsecutive = 1 ,
82+ Duration triggerDuration = const Duration (seconds: 1 ),
83+ }) {
7784 if (! ctx.hasMatch) {
7885 return const DetectionResult (description: 'A:无匹配' );
7986 }
@@ -88,8 +95,13 @@ DetectionResult detectTailMatch(SpeechMatchContext ctx) {
8895 }
8996 }
9097
91- if (consecutiveTail < 1 ) {
92- return const DetectionResult (description: 'A:末尾未匹配' );
98+ if (consecutiveTail < minConsecutive) {
99+ if (consecutiveTail < 1 ) {
100+ return const DetectionResult (description: 'A:末尾未匹配' );
101+ }
102+ return DetectionResult (
103+ description: 'A:尾部连续${consecutiveTail }词<$minConsecutive ,未触发' ,
104+ );
93105 }
94106
95107 final uniqueStart = tokens.length - consecutiveTail;
@@ -100,36 +112,51 @@ DetectionResult detectTailMatch(SpeechMatchContext ctx) {
100112 }
101113
102114 return DetectionResult (
103- threshold: const Duration (seconds: 1 ),
104- description: 'A:尾部连续${consecutiveTail }词且唯一→1s' ,
115+ threshold: triggerDuration,
116+ description:
117+ 'A:尾部连续${consecutiveTail }词且唯一→${triggerDuration .inMilliseconds }ms' ,
105118 );
106119}
107120
108121/// 检测 B:全句匹配率。
109122///
110- /// 100% → 1s, ≥95% → 2s, ≥90% → 3s,低于 90% 不触发。
111- DetectionResult detectOverallMatchRate (SpeechMatchContext ctx) {
123+ /// 默认(跟读模式):100% → 1s, ≥95% → 2s, ≥90% → 3s,低于 90% 不触发。
124+ ///
125+ /// 收紧模式:通过 [strictPerfectOnly] = true 启用,仅 100% 匹配才触发,阈值 [perfectDuration] 。
126+ /// 复述场景用此模式,避免 90-99% 匹配触发短阈值早停。
127+ DetectionResult detectOverallMatchRate (
128+ SpeechMatchContext ctx, {
129+ bool strictPerfectOnly = false ,
130+ Duration perfectDuration = const Duration (seconds: 1 ),
131+ Duration nearPerfectDuration = const Duration (seconds: 2 ),
132+ Duration highMatchDuration = const Duration (seconds: 3 ),
133+ }) {
112134 if (! ctx.hasMatch) {
113135 return const DetectionResult (description: 'B:无匹配' );
114136 }
115137
116138 final pct = (ctx.matchRate * 100 ).toInt ();
117139 if (ctx.matchRate >= 1.0 ) {
118140 return DetectionResult (
119- threshold: const Duration (seconds: 1 ),
120- description: 'B:匹配率${pct }%→1s' ,
141+ threshold: perfectDuration,
142+ description: 'B:匹配率${pct }%→${perfectDuration .inMilliseconds }ms' ,
143+ );
144+ }
145+ if (strictPerfectOnly) {
146+ return DetectionResult (
147+ description: 'B:匹配率${pct }%<100%,严格模式不触发' ,
121148 );
122149 }
123150 if (ctx.matchRate >= 0.95 ) {
124151 return DetectionResult (
125- threshold: const Duration (seconds : 2 ) ,
126- description: 'B:匹配率${pct }%→2s ' ,
152+ threshold: nearPerfectDuration ,
153+ description: 'B:匹配率${pct }%→${ nearPerfectDuration . inMilliseconds }ms ' ,
127154 );
128155 }
129156 if (ctx.matchRate >= 0.90 ) {
130157 return DetectionResult (
131- threshold: const Duration (seconds : 3 ) ,
132- description: 'B:匹配率${pct }%→3s ' ,
158+ threshold: highMatchDuration ,
159+ description: 'B:匹配率${pct }%→${ highMatchDuration . inMilliseconds }ms ' ,
133160 );
134161 }
135162
@@ -172,6 +199,66 @@ DetectionResult detectTailHitCount(SpeechMatchContext ctx, {int tailSize = 5}) {
172199 );
173200}
174201
202+ /// 检测 E:近完成(全句匹配率高 + 末尾覆盖到位)。
203+ ///
204+ /// 复述场景专用:当用户已讲完原文 9 成以上内容,且原文末尾 5 词命中 ≥4 时,
205+ /// 判定为"基本已完成复述",高置信快速收尾。
206+ ///
207+ /// 设计动机:覆盖 B 规则(要求 100% 全句匹配)漏掉的"差 1-2 个词没命中"场景,
208+ /// 又比 A 规则(要求末尾连续匹配)宽容,允许 ASR 漏识别 1 个末尾词。
209+ ///
210+ /// 触发条件:
211+ /// - `matchRate >= minMatchRate` (默认 0.90)
212+ /// - 原文末尾 [tailSize] 词中至少 [minTailHits] 词被匹配(默认 5 词中 ≥4 词)
213+ ///
214+ /// 默认参数对应复述模式收紧值。函数本身保持参数化,方便其他场景调用。
215+ DetectionResult detectNearCompletion (
216+ SpeechMatchContext ctx, {
217+ double minMatchRate = 0.90 ,
218+ int tailSize = 5 ,
219+ int minTailHits = 4 ,
220+ Duration triggerDuration = const Duration (seconds: 1 ),
221+ }) {
222+ if (! ctx.hasMatch) {
223+ return const DetectionResult (description: 'E:无匹配' );
224+ }
225+
226+ final pct = (ctx.matchRate * 100 ).toInt ();
227+ if (ctx.matchRate < minMatchRate) {
228+ final minPct = (minMatchRate * 100 ).toInt ();
229+ return DetectionResult (
230+ description: 'E:匹配率${pct }%<$minPct %,未触发' ,
231+ );
232+ }
233+
234+ final tokens = ctx.referenceTokens;
235+ final effectiveTailSize = tokens.length < tailSize ? tokens.length : tailSize;
236+ final tailStart = tokens.length - effectiveTailSize;
237+ var tailMatchCount = 0 ;
238+ for (var i = tailStart; i < tokens.length; i++ ) {
239+ if (ctx.matchedRefIndexes.contains (i)) {
240+ tailMatchCount++ ;
241+ }
242+ }
243+
244+ // 短句兜底:末尾词数不足 [minTailHits] 时,要求全部命中。
245+ final requiredHits =
246+ effectiveTailSize < minTailHits ? effectiveTailSize : minTailHits;
247+ if (tailMatchCount < requiredHits) {
248+ return DetectionResult (
249+ description:
250+ 'E:末尾${effectiveTailSize }词命中$tailMatchCount <$requiredHits ,未触发' ,
251+ );
252+ }
253+
254+ return DetectionResult (
255+ threshold: triggerDuration,
256+ description:
257+ 'E:匹配率${pct }% + 末尾$tailMatchCount /${effectiveTailSize }'
258+ '→${triggerDuration .inMilliseconds }ms' ,
259+ );
260+ }
261+
175262/// 检测 D:剩余词数估算阈值。
176263///
177264/// 从 transcript 末尾取 1-[maxSubstringLength] 个词,枚举所有连续子串,
@@ -343,16 +430,20 @@ Duration computeDynamicFallback({
343430/// - ≤20s → 1.2
344431/// - >20s → 1.3(长段落需要更多回忆时间)
345432///
346- /// - [matchRate] :文本匹配率(0-1),低于 0.8 时不缩短兜底。
347- /// 传 null 表示无转录(ASR 关闭),此时仅凭有声比例计算。
433+ /// [matchRate] :文本匹配率(0-1),低于 0.8 时不缩短兜底。
434+ /// 传 null 表示无转录(ASR 关闭),此时仅凭有声比例计算。
435+ ///
436+ /// 收紧版(2026-05-18):上限 20s→30s,各档阈值翻倍,下限 1s→5s。
437+ /// 旧算法以注释保留在函数尾部,方便对比/回滚。
348438Duration computeRetellDynamicFallback ({
349439 required Duration voicedDuration,
350440 required Duration referenceDuration,
351441 double ? matchRate,
352442}) {
353- // 动态上限:min(20s, max(5s, referenceDuration))
354- final capMs = referenceDuration.inMilliseconds.clamp (5000 , 20000 );
355- final scale = capMs / 20000 ; // 缩放因子,各阈值等比缩放
443+ // 动态上限:clamp(referenceDuration, 5s, 30s)
444+ // [旧算法-2026-05-18] 旧上限:clamp(refDur, 5s, 20s)
445+ final capMs = referenceDuration.inMilliseconds.clamp (5000 , 30000 );
446+ final scale = capMs / 30000 ; // 缩放因子,各阈值按 cap/30s 等比缩放
356447
357448 if (referenceDuration <= Duration .zero) return Duration (milliseconds: capMs);
358449 if (matchRate != null && matchRate < 0.8 ) return Duration (milliseconds: capMs);
@@ -365,24 +456,34 @@ Duration computeRetellDynamicFallback({
365456 final adjustedMs = referenceDuration.inMilliseconds * speedFactor;
366457 final ratio = voicedDuration.inMilliseconds / adjustedMs;
367458
368- // 基准阈值(cap=20s 时)按比例缩放:3/6/10/15/20 × scale
459+ // 基准阈值(scale=1.0 即 ref≥30s 时):6/12/cap/cap/cap,下限 5s
369460 final int ms;
370461 if (ratio >= 0.95 ) {
371- ms = (3000 * scale).round ();
372- } else if (ratio >= 0.90 ) {
373462 ms = (6000 * scale).round ();
463+ } else if (ratio >= 0.90 ) {
464+ ms = (12000 * scale).round ();
374465 } else if (ratio >= 0.85 ) {
375- ms = ( 10000 * scale). round () ;
466+ ms = capMs ;
376467 } else if (ratio >= 0.80 ) {
377- ms = ( 15000 * scale). round () ;
468+ ms = capMs ;
378469 } else if (ratio >= 0.75 ) {
379470 ms = capMs;
380471 } else {
381472 return Duration (milliseconds: capMs);
382473 }
383474
384- // 最低 1s,避免过短误触
385- return Duration (milliseconds: ms < 1000 ? 1000 : ms);
475+ // 收紧后下限:5s
476+ // [旧算法-2026-05-18] 原下限:1s
477+ return Duration (milliseconds: ms < 5000 ? 5000 : ms);
478+
479+ // [旧算法-2026-05-18] 原档位(cap=20s, scale=capMs/20000):
480+ // ratio >= 0.95 → 3000 * scale
481+ // ratio >= 0.90 → 6000 * scale
482+ // ratio >= 0.85 → 10000 * scale
483+ // ratio >= 0.80 → 15000 * scale
484+ // ratio >= 0.75 → cap
485+ // 其他 → cap
486+ // 下限 1000ms
386487}
387488
388489// ========== 内部工具函数 ==========
0 commit comments