|
| 1 | +//! 结构密度路由纯函数(v0.11.0 REQ-196;v4 附录B 组路由判据)。 |
| 2 | +//! |
| 3 | +//! @ai-context: 判据锚定「结构承载性」——结构密度高→自成一组(无论长短), |
| 4 | +//! 低→归主题组(按 DomainTag 领域粒度)。信号来自四维解耦既有 |
| 5 | +//! 资产(章节检测/术语表/OCR 密度/形态 unknown/画面档/领域), |
| 6 | +//! 加权投票复用 vote_detect/vote_tier 范式(每信号一票→计票→置信)。 |
| 7 | +//! @ai-context: 路由误判为 ★★★★ 死法(高结构碎片埋进主题组/低结构长视频空洞 |
| 8 | +//! 自成一组)——冲突/弱信号诚实 NeedConfirm,配合 REQ-198 可见可改; |
| 9 | +//! 零信号兜底自成一组(埋没比空洞更伤——独立组可后续移动)。 |
| 10 | +//! @ai-context: 纯逻辑无 IO;golden 用例先行(TDD),阈值常量具名可调。 |
| 11 | +
|
| 12 | +use crate::video_profile_domain::DomainKind; |
| 13 | + |
| 14 | +/// 章节密度阈值(章/小时):≥ 此值投高密度票。 |
| 15 | +const CHAPTER_DENSITY_HIGH: f32 = 2.0; |
| 16 | +/// 章节密度阈值(章/小时):< 此值投低密度票。 |
| 17 | +const CHAPTER_DENSITY_LOW: f32 = 0.5; |
| 18 | +/// 术语表条目阈值:≥ 此值投高密度票(词汇表成块=内容自带结构)。 |
| 19 | +const GLOSSARY_HIGH: usize = 5; |
| 20 | +/// OCR 文本密度阈值(0..1):≥ 此值投高密度票(画面承载文字结构)。 |
| 21 | +const OCR_DENSITY_HIGH: f32 = 0.5; |
| 22 | +/// 高密度/低密度判定的最小共振票数(单一信号不足以定路由——vote_tier 同哲学)。 |
| 23 | +const MIN_RESONANT_VOTES: u32 = 2; |
| 24 | + |
| 25 | +/// 组路由输入信号(全部由既有检测资产计算;None=未观测不投票,不猜)。 |
| 26 | +#[derive(Debug, Clone, Default, PartialEq)] |
| 27 | +pub struct GroupRouteSignals { |
| 28 | + /// series_detect 命中(系列内容——assignment 层直判课程组,此处留早退路径) |
| 29 | + pub has_series: bool, |
| 30 | + /// 章节密度(章/小时;None=未跑章节检测) |
| 31 | + pub chapter_density: Option<f32>, |
| 32 | + /// 术语表条目数(None=未跑术语提取) |
| 33 | + pub glossary_terms: Option<usize>, |
| 34 | + /// OCR 文本密度(0..1;None=无 OCR 数据) |
| 35 | + pub ocr_text_density: Option<f32>, |
| 36 | + /// 形态 unknown(四维解耦诚实未知——低结构信号) |
| 37 | + pub profile_unknown: bool, |
| 38 | + /// 画面档 rich/mid(video_tier_detect——高结构信号) |
| 39 | + pub tier_rich: bool, |
| 40 | + /// 领域命中(归主题组的前提;None=低结构时无处可归) |
| 41 | + pub domain_kind: Option<DomainKind>, |
| 42 | +} |
| 43 | + |
| 44 | +/// 路由动作(三态——诚实不乱判)。 |
| 45 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 46 | +pub enum RouteAction { |
| 47 | + /// 结构密度高 → 自成一组(独立组) |
| 48 | + OwnGroup, |
| 49 | + /// 结构密度低且领域命中 → 归主题组 |
| 50 | + TopicGroup, |
| 51 | + /// 信号冲突/低结构无领域 → 待确认(REQ-198 可见可改兜住误判) |
| 52 | + NeedConfirm, |
| 53 | +} |
| 54 | + |
| 55 | +/// 路由决策(动作 + 命中信号明细——组卡片路由理由展示数据源)。 |
| 56 | +#[derive(Debug, Clone, PartialEq)] |
| 57 | +pub struct GroupRouteDecision { |
| 58 | + pub action: RouteAction, |
| 59 | + /// 命中信号明细(中文短语;前端逐条展示) |
| 60 | + pub reasons: Vec<String>, |
| 61 | +} |
| 62 | + |
| 63 | +/// 结构密度路由(纯函数):计票 → 共振判定 → 三态输出。 |
| 64 | +/// |
| 65 | +/// @ai-context: 规则序(先强后弱,命中即返回): |
| 66 | +/// 1. 系列命中 → 自成一组(课程组语义在 assignment 层兑现); |
| 67 | +/// 2. 高密度 ≥2 票共振 → 自成一组; |
| 68 | +/// 3. 高低票并存(冲突)→ 待确认; |
| 69 | +/// 4. 低密度 ≥2 票共振 → 有领域归主题组,无领域待确认; |
| 70 | +/// 5. 弱/零信号 → 自成一组兜底(独立组可后续移动,不埋没)。 |
| 71 | +pub fn route_group(signals: &GroupRouteSignals) -> GroupRouteDecision { |
| 72 | + if signals.has_series { |
| 73 | + return GroupRouteDecision { |
| 74 | + action: RouteAction::OwnGroup, |
| 75 | + reasons: vec!["系列内容命中(合集/分P)".to_string()], |
| 76 | + }; |
| 77 | + } |
| 78 | + let mut high: u32 = 0; |
| 79 | + let mut low: u32 = 0; |
| 80 | + let mut reasons_high: Vec<String> = Vec::new(); |
| 81 | + let mut reasons_low: Vec<String> = Vec::new(); |
| 82 | + if let Some(density) = signals.chapter_density { |
| 83 | + if density >= CHAPTER_DENSITY_HIGH { |
| 84 | + high += 1; |
| 85 | + reasons_high.push(format!("章节密度高({:.1} 章/小时)", density)); |
| 86 | + } else if density < CHAPTER_DENSITY_LOW { |
| 87 | + low += 1; |
| 88 | + reasons_low.push("几乎无章节结构".to_string()); |
| 89 | + } |
| 90 | + } |
| 91 | + if let Some(terms) = signals.glossary_terms { |
| 92 | + if terms >= GLOSSARY_HIGH { |
| 93 | + high += 1; |
| 94 | + reasons_high.push(format!("术语表成块({} 条)", terms)); |
| 95 | + } else if terms == 0 { |
| 96 | + low += 1; |
| 97 | + reasons_low.push("无术语沉淀".to_string()); |
| 98 | + } |
| 99 | + } |
| 100 | + if signals.ocr_text_density.is_some_and(|d| d >= OCR_DENSITY_HIGH) { |
| 101 | + high += 1; |
| 102 | + reasons_high.push(format!( |
| 103 | + "画面文字密度高({:.0}%)", |
| 104 | + signals.ocr_text_density.unwrap_or(0.0) * 100.0 |
| 105 | + )); |
| 106 | + } |
| 107 | + if signals.tier_rich { |
| 108 | + high += 1; |
| 109 | + reasons_high.push("画面档位丰富(板书/PPT 级)".to_string()); |
| 110 | + } |
| 111 | + if signals.profile_unknown { |
| 112 | + low += 1; |
| 113 | + reasons_low.push("形态未识别".to_string()); |
| 114 | + } |
| 115 | + if high >= MIN_RESONANT_VOTES { |
| 116 | + return GroupRouteDecision { action: RouteAction::OwnGroup, reasons: reasons_high }; |
| 117 | + } |
| 118 | + if high >= 1 && low >= 1 { |
| 119 | + reasons_high.extend(reasons_low); |
| 120 | + return GroupRouteDecision { action: RouteAction::NeedConfirm, reasons: reasons_high }; |
| 121 | + } |
| 122 | + if low >= MIN_RESONANT_VOTES { |
| 123 | + return match signals.domain_kind { |
| 124 | + Some(kind) => GroupRouteDecision { |
| 125 | + action: RouteAction::TopicGroup, |
| 126 | + reasons: { |
| 127 | + reasons_low.push(format!("领域命中({})", kind.label())); |
| 128 | + reasons_low |
| 129 | + }, |
| 130 | + }, |
| 131 | + // 低结构但无领域——主题组无处可归,诚实待确认(不硬塞大类抽屉) |
| 132 | + None => GroupRouteDecision { action: RouteAction::NeedConfirm, reasons: reasons_low }, |
| 133 | + }; |
| 134 | + } |
| 135 | + // 弱/零信号兜底:自成一组(独立组可后续移动;埋没比空洞更伤) |
| 136 | + GroupRouteDecision { |
| 137 | + action: RouteAction::OwnGroup, |
| 138 | + reasons: vec!["信号不足,默认独立成组(可后续移动)".to_string()], |
| 139 | + } |
| 140 | +} |
| 141 | + |
| 142 | +#[cfg(test)] |
| 143 | +#[path = "group_route_tests.rs"] |
| 144 | +mod tests; |
0 commit comments