|
11 | 11 |
|
12 | 12 | use serde::{Deserialize, Serialize}; |
13 | 13 |
|
| 14 | +/// 检测投票域(原 229–402 行)——纯逻辑投票与阈值,见 video_profile_detect.rs 模块头。 |
| 15 | +#[path = "video_profile_detect.rs"] |
| 16 | +mod detect; |
| 17 | + |
| 18 | +/// 检测域再导出:既有 `crate::video_profile::{…}` 导入路径保持不变。 |
| 19 | +/// ⚠️ 路径必须是 `detect::`(子模块路径)而非 `crate::video_profile_detect::`—— |
| 20 | +/// 本模块由 `#[path]` 在**父模块内**声明(不动 lib.rs 的 `mod` 块),不是 crate 根子模块。 |
| 21 | +pub use detect::{vote_detect, DetectResult, ObservedSignals, ProfileCandidate}; |
| 22 | + |
14 | 23 | /// 十二类档案标识(全栈统一业务术语;v0.7.1 起含 Unknown 共十三值)。 |
15 | 24 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
16 | 25 | #[serde(rename_all = "kebab-case")] |
@@ -226,112 +235,6 @@ pub fn profile_by_kind(kind: ProfileKind) -> VideoProfile { |
226 | 235 | profiles.remove(idx) |
227 | 236 | } |
228 | 237 |
|
229 | | -/// 观测到的检测信号(全部已有/低成本:A5 标题已入库、帧 diff/VAD/时长均为管线现状)。 |
230 | | -#[derive(Debug, Clone, Default, PartialEq)] |
231 | | -pub struct ObservedSignals { |
232 | | - /// 窗口标题(None=未知) |
233 | | - pub title: Option<String>, |
234 | | - /// URL/播放器标题(None=未知) |
235 | | - pub url: Option<String>, |
236 | | - /// 画面切换频率(次/分;None=未知) |
237 | | - pub frame_switch_rate: Option<f32>, |
238 | | - /// 字幕有无(det 结果统计;None=未知) |
239 | | - pub has_subtitle: Option<bool>, |
240 | | - /// 会话时长(分钟;None=未知) |
241 | | - pub duration_min: Option<u32>, |
242 | | -} |
243 | | - |
244 | | -/// 检测候选(降序)。 |
245 | | -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
246 | | -pub struct ProfileCandidate { |
247 | | - pub kind: ProfileKind, |
248 | | - /// 归一化得分 0.0-1.0(最高分档案 = 1.0,其余按比例) |
249 | | - pub score: f32, |
250 | | -} |
251 | | - |
252 | | -/// 检测结果。 |
253 | | -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
254 | | -pub struct DetectResult { |
255 | | - /// 候选档案(降序;全部 0 分时为 Unknown 单候选——诚实未知,不猜默认) |
256 | | - pub candidates: Vec<ProfileCandidate>, |
257 | | - /// 是否需用户确认(信号冲突/得分不足——置信度低才问,高则静默生效可改) |
258 | | - pub needs_confirmation: bool, |
259 | | - /// 记忆偏好命中(同窗口标题上次确认过;直接生效无需确认) |
260 | | - pub memory_hit: Option<ProfileKind>, |
261 | | - /// v0.9.0(REQ-188):记忆命中时的四维形态(检测卡 v2 展示; |
262 | | - /// 旧 JSON/旧记忆缺省 None——经 memory_hit.to_form() 映射兜底,零回归) |
263 | | - #[serde(default)] |
264 | | - pub memory_form: Option<crate::video_profile_spec::ContentForm>, |
265 | | - /// v0.9.0(REQ-190):领域标签检测结果(平台分区/标题词/用户确认/术语频率 |
266 | | - /// 四来源;None=未检测——旧 JSON/旧前端零回归) |
267 | | - #[serde(default)] |
268 | | - pub domain: Option<crate::video_profile_domain::DomainDetection>, |
269 | | - /// v0.11.5(Task 5):记忆命中 + 检测高置信但**冲突**(记忆 kind ≠ 检测首位) |
270 | | - /// → 检测为准,记忆以标记方式带回(前端展示"记忆与检测冲突,已按检测"); |
271 | | - /// 检测低置信/同 kind 时不设(记忆正常生效);旧 JSON 缺省 None(零回归) |
272 | | - #[serde(default)] |
273 | | - pub memory_conflict: Option<ProfileKind>, |
274 | | - /// v0.13.6(REQ-221):平台分区映射表命中的形态(确定性检测信号——优先级 |
275 | | - /// 高于记忆/标题候选:影视/直播分区直接定叙事/直播形态);未命中 None。 |
276 | | - #[serde(default)] |
277 | | - pub platform_form: Option<crate::video_profile_spec::ContentForm>, |
278 | | -} |
279 | | - |
280 | | -/// 检测得分阈值:top 得分低于该值视为信号不足。 |
281 | | -const CONFIDENCE_THRESHOLD: f32 = 2.5; |
282 | | -/// 信号冲突阈值:top 与次名差距小于该值视为冲突(需用户裁决)。 |
283 | | -const CONFLICT_GAP: f32 = 1.0; |
284 | | - |
285 | | -/// 混合检测投票(纯函数):观测信号 × 五档案检测信号配置 → 候选排序。 |
286 | | -/// |
287 | | -/// @ai-context: 每档案得分 = 标题关键词命中数×2 + URL 命中×2 + 帧率区间命中×2 |
288 | | -/// + 字幕偏好命中 + 时长达标;归一化后输出候选。 |
289 | | -/// @ai-context: 记忆偏好由调用方(command 层)先查 ProfileMemory,命中直接生效; |
290 | | -/// 本函数只负责信号投票(决策矩阵可注入 fake 信号单测)。 |
291 | | -pub fn vote_detect(signals: &ObservedSignals) -> DetectResult { |
292 | | - // v0.7.2(REQ-152):标题先剥系列名再投票——同一系列的 P1/P5 标题不同导致 |
293 | | - // 投票漂移(真实短板);剥离后各集投票一致("零基础化妆 P3" 与 "P5" 同键)。 |
294 | | - // 未识别出系列(普通标题)→ 原样投票(零回归);仅换投票标题,不加分不扣分。 |
295 | | - let mut s = signals.clone(); |
296 | | - if let Some(info) = signals |
297 | | - .title |
298 | | - .as_deref() |
299 | | - .and_then(crate::series_detect::extract_series) |
300 | | - { |
301 | | - s.title = Some(info.series); |
302 | | - } |
303 | | - let profiles = builtin_profiles(); |
304 | | - let mut scored: Vec<(ProfileKind, f32)> = profiles |
305 | | - .iter() |
306 | | - .map(|p| (p.kind, score_profile(p, &s))) |
307 | | - .collect(); |
308 | | - let max = scored.iter().map(|(_, s)| *s).fold(0.0f32, f32::max); |
309 | | - if max <= 0.0 { |
310 | | - // 无任何信号命中:Unknown 单候选 + 需确认(诚实未知,不假装猜中网课; |
311 | | - // v0.7.1 用户需求:无法自动识别时选中"未知") |
312 | | - return DetectResult { |
313 | | - candidates: vec![ProfileCandidate { kind: ProfileKind::Unknown, score: 1.0 }], |
314 | | - needs_confirmation: true, |
315 | | - memory_hit: None, |
316 | | - memory_form: None, |
317 | | - domain: None, |
318 | | - memory_conflict: None, |
319 | | - platform_form: None, |
320 | | - }; |
321 | | - } |
322 | | - scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); |
323 | | - let candidates: Vec<ProfileCandidate> = scored |
324 | | - .iter() |
325 | | - .map(|(kind, score)| ProfileCandidate { kind: *kind, score: score / max }) |
326 | | - .collect(); |
327 | | - let needs_confirmation = scored[0].1 < CONFIDENCE_THRESHOLD |
328 | | - || scored |
329 | | - .get(1) |
330 | | - .map(|(_, s)| scored[0].1 - *s < CONFLICT_GAP) |
331 | | - .unwrap_or(false); |
332 | | - DetectResult { candidates, needs_confirmation, memory_hit: None, memory_form: None, domain: None, memory_conflict: None, platform_form: None } |
333 | | -} |
334 | | - |
335 | 238 | /// 四象限记忆后置判定(纯函数,v0.11.5 Task 5):检测优先 + 记忆兜底 + 冲突以检测为准。 |
336 | 239 | /// |
337 | 240 | /// @ai-context: 调用方(command 层)先跑 vote_detect 得到检测结果,再以记忆后置裁决: |
@@ -366,41 +269,6 @@ pub fn apply_profile_memory( |
366 | 269 | result |
367 | 270 | } |
368 | 271 |
|
369 | | -/// 单档案得分(纯函数,可注入 fake 信号单测)。 |
370 | | -fn score_profile(profile: &VideoProfile, signals: &ObservedSignals) -> f32 { |
371 | | - let mut score = 0.0f32; |
372 | | - let ds = &profile.detect_signals; |
373 | | - if let Some(title) = signals.title.as_deref() { |
374 | | - let hits = ds.title_keywords.iter().filter(|k| title.contains(k.as_str())).count(); |
375 | | - score += hits as f32 * 2.0; |
376 | | - // "第X章"模式(网课章节):title 含"第"且含"章" |
377 | | - if title.contains('第') && title.contains('章') && ds.title_keywords.contains(&"第".to_string()) { |
378 | | - score += 2.0; |
379 | | - } |
380 | | - } |
381 | | - if let Some(url) = signals.url.as_deref() { |
382 | | - let hits = ds.url_keywords.iter().filter(|k| url.contains(k.as_str())).count(); |
383 | | - score += hits as f32 * 2.0; |
384 | | - } |
385 | | - if let (Some(rate), Some((lo, hi))) = (signals.frame_switch_rate, ds.frame_switch_range) { |
386 | | - if rate >= lo && rate <= hi { |
387 | | - score += 2.0; |
388 | | - } |
389 | | - } |
390 | | - if let Some(has_sub) = signals.has_subtitle { |
391 | | - if has_sub && ds.prefers_subtitle { |
392 | | - score += 1.0; |
393 | | - } |
394 | | - // 无字幕时偏好字幕的档案不额外加分(保持对称:仅正向信号计分) |
395 | | - } |
396 | | - if let (Some(dur), Some(min)) = (signals.duration_min, ds.min_duration_min) { |
397 | | - if dur >= min { |
398 | | - score += 1.0; |
399 | | - } |
400 | | - } |
401 | | - score |
402 | | -} |
403 | | - |
404 | 272 | /// 记忆偏好条目:窗口标题关键词 → 用户确认过的档案。 |
405 | 273 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] |
406 | 274 | pub struct MemoryEntry { |
|
0 commit comments