Skip to content

Commit c39a315

Browse files
committed
refactor(rust): 拆 video_profile 步 1 — 检测投票域
1 parent c1a6ca5 commit c39a315

3 files changed

Lines changed: 166 additions & 142 deletions

File tree

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

Lines changed: 9 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@
1111
1212
use serde::{Deserialize, Serialize};
1313

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+
1423
/// 十二类档案标识(全栈统一业务术语;v0.7.1 起含 Unknown 共十三值)。
1524
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1625
#[serde(rename_all = "kebab-case")]
@@ -226,112 +235,6 @@ pub fn profile_by_kind(kind: ProfileKind) -> VideoProfile {
226235
profiles.remove(idx)
227236
}
228237

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-
335238
/// 四象限记忆后置判定(纯函数,v0.11.5 Task 5):检测优先 + 记忆兜底 + 冲突以检测为准。
336239
///
337240
/// @ai-context: 调用方(command 层)先跑 vote_detect 得到检测结果,再以记忆后置裁决:
@@ -366,41 +269,6 @@ pub fn apply_profile_memory(
366269
result
367270
}
368271

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-
404272
/// 记忆偏好条目:窗口标题关键词 → 用户确认过的档案。
405273
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
406274
pub struct MemoryEntry {
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
//! 档案检测投票域(REQ-043 / v0.7.1 Unknown / v0.7.2 REQ-152 系列名剥离 / v0.13.6 REQ-221 平台形态)。
2+
//!
3+
//! @ai-context: 从 video_profile.rs 拆出(AGENTS.md §3 单文件 ≤300 行):本域只做
4+
//! 纯逻辑投票——观测信号 × 各档案检测信号配置 → 加权得分 → 降序候选。
5+
//! 得分权重:标题关键词命中×2("第X章"额外 +2,要求该档关键词表含"第")、
6+
//! URL 命中×2、帧率落区间 +2、字幕偏好命中 +1、时长达标 +1。
7+
//! @ai-context: 投票顺序即语义(禁止重排):先剥系列名(仅换投票标题,未识别则原样投)
8+
//! → 满分 ≤0 提前返回 Unknown 单候选(score 1.0 诚实未知)→ 稳定降序
9+
//! (平分保留数据表顺序)→ 2.5/1.0 双阈值判 needs_confirmation。
10+
//! @ai-context: 零副作用、零 IO、不依赖 windows/DB/引擎;档案参数表在
11+
//! video_profile_data.rs,本域只消费。签名/阈值逐字沿用原文件。
12+
//! @ai-context: ObservedSignals/ProfileCandidate/DetectResult 保留在本文件,由父模块
13+
//! `pub use detect::{…}` 再导出(33 处既有导入路径零改动);本域经
14+
//! `use super::*` 取用父模块的 ProfileKind/VideoProfile/builtin_profiles。
15+
16+
use super::*;
17+
18+
/// 观测到的检测信号(全部已有/低成本:A5 标题已入库、帧 diff/VAD/时长均为管线现状)。
19+
#[derive(Debug, Clone, Default, PartialEq)]
20+
pub struct ObservedSignals {
21+
/// 窗口标题(None=未知)
22+
pub title: Option<String>,
23+
/// URL/播放器标题(None=未知)
24+
pub url: Option<String>,
25+
/// 画面切换频率(次/分;None=未知)
26+
pub frame_switch_rate: Option<f32>,
27+
/// 字幕有无(det 结果统计;None=未知)
28+
pub has_subtitle: Option<bool>,
29+
/// 会话时长(分钟;None=未知)
30+
pub duration_min: Option<u32>,
31+
}
32+
33+
/// 检测候选(降序)。
34+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35+
pub struct ProfileCandidate {
36+
pub kind: ProfileKind,
37+
/// 归一化得分 0.0-1.0(最高分档案 = 1.0,其余按比例)
38+
pub score: f32,
39+
}
40+
41+
/// 检测结果。
42+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43+
pub struct DetectResult {
44+
/// 候选档案(降序;全部 0 分时为 Unknown 单候选——诚实未知,不猜默认)
45+
pub candidates: Vec<ProfileCandidate>,
46+
/// 是否需用户确认(信号冲突/得分不足——置信度低才问,高则静默生效可改)
47+
pub needs_confirmation: bool,
48+
/// 记忆偏好命中(同窗口标题上次确认过;直接生效无需确认)
49+
pub memory_hit: Option<ProfileKind>,
50+
/// v0.9.0(REQ-188):记忆命中时的四维形态(检测卡 v2 展示;
51+
/// 旧 JSON/旧记忆缺省 None——经 memory_hit.to_form() 映射兜底,零回归)
52+
#[serde(default)]
53+
pub memory_form: Option<crate::video_profile_spec::ContentForm>,
54+
/// v0.9.0(REQ-190):领域标签检测结果(平台分区/标题词/用户确认/术语频率
55+
/// 四来源;None=未检测——旧 JSON/旧前端零回归)
56+
#[serde(default)]
57+
pub domain: Option<crate::video_profile_domain::DomainDetection>,
58+
/// v0.11.5(Task 5):记忆命中 + 检测高置信但**冲突**(记忆 kind ≠ 检测首位)
59+
/// → 检测为准,记忆以标记方式带回(前端展示"记忆与检测冲突,已按检测");
60+
/// 检测低置信/同 kind 时不设(记忆正常生效);旧 JSON 缺省 None(零回归)
61+
#[serde(default)]
62+
pub memory_conflict: Option<ProfileKind>,
63+
/// v0.13.6(REQ-221):平台分区映射表命中的形态(确定性检测信号——优先级
64+
/// 高于记忆/标题候选:影视/直播分区直接定叙事/直播形态);未命中 None。
65+
#[serde(default)]
66+
pub platform_form: Option<crate::video_profile_spec::ContentForm>,
67+
}
68+
69+
/// 检测得分阈值:top 得分低于该值视为信号不足。
70+
const CONFIDENCE_THRESHOLD: f32 = 2.5;
71+
/// 信号冲突阈值:top 与次名差距小于该值视为冲突(需用户裁决)。
72+
const CONFLICT_GAP: f32 = 1.0;
73+
74+
/// 混合检测投票(纯函数):观测信号 × 五档案检测信号配置 → 候选排序。
75+
///
76+
/// @ai-context: 每档案得分 = 标题关键词命中数×2 + URL 命中×2 + 帧率区间命中×2
77+
/// + 字幕偏好命中 + 时长达标;归一化后输出候选。
78+
/// @ai-context: 记忆偏好由调用方(command 层)先查 ProfileMemory,命中直接生效;
79+
/// 本函数只负责信号投票(决策矩阵可注入 fake 信号单测)。
80+
pub fn vote_detect(signals: &ObservedSignals) -> DetectResult {
81+
// v0.7.2(REQ-152):标题先剥系列名再投票——同一系列的 P1/P5 标题不同导致
82+
// 投票漂移(真实短板);剥离后各集投票一致("零基础化妆 P3" 与 "P5" 同键)。
83+
// 未识别出系列(普通标题)→ 原样投票(零回归);仅换投票标题,不加分不扣分。
84+
let mut s = signals.clone();
85+
if let Some(info) = signals
86+
.title
87+
.as_deref()
88+
.and_then(crate::series_detect::extract_series)
89+
{
90+
s.title = Some(info.series);
91+
}
92+
let profiles = builtin_profiles();
93+
let mut scored: Vec<(ProfileKind, f32)> = profiles
94+
.iter()
95+
.map(|p| (p.kind, score_profile(p, &s)))
96+
.collect();
97+
let max = scored.iter().map(|(_, s)| *s).fold(0.0f32, f32::max);
98+
if max <= 0.0 {
99+
// 无任何信号命中:Unknown 单候选 + 需确认(诚实未知,不假装猜中网课;
100+
// v0.7.1 用户需求:无法自动识别时选中"未知")
101+
return DetectResult {
102+
candidates: vec![ProfileCandidate { kind: ProfileKind::Unknown, score: 1.0 }],
103+
needs_confirmation: true,
104+
memory_hit: None,
105+
memory_form: None,
106+
domain: None,
107+
memory_conflict: None,
108+
platform_form: None,
109+
};
110+
}
111+
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
112+
let candidates: Vec<ProfileCandidate> = scored
113+
.iter()
114+
.map(|(kind, score)| ProfileCandidate { kind: *kind, score: score / max })
115+
.collect();
116+
let needs_confirmation = scored[0].1 < CONFIDENCE_THRESHOLD
117+
|| scored
118+
.get(1)
119+
.map(|(_, s)| scored[0].1 - *s < CONFLICT_GAP)
120+
.unwrap_or(false);
121+
DetectResult { candidates, needs_confirmation, memory_hit: None, memory_form: None, domain: None, memory_conflict: None, platform_form: None }
122+
}
123+
124+
/// 单档案得分(纯函数,可注入 fake 信号单测)。
125+
fn score_profile(profile: &VideoProfile, signals: &ObservedSignals) -> f32 {
126+
let mut score = 0.0f32;
127+
let ds = &profile.detect_signals;
128+
if let Some(title) = signals.title.as_deref() {
129+
let hits = ds.title_keywords.iter().filter(|k| title.contains(k.as_str())).count();
130+
score += hits as f32 * 2.0;
131+
// "第X章"模式(网课章节):title 含"第"且含"章"
132+
if title.contains('第') && title.contains('章') && ds.title_keywords.contains(&"第".to_string()) {
133+
score += 2.0;
134+
}
135+
}
136+
if let Some(url) = signals.url.as_deref() {
137+
let hits = ds.url_keywords.iter().filter(|k| url.contains(k.as_str())).count();
138+
score += hits as f32 * 2.0;
139+
}
140+
if let (Some(rate), Some((lo, hi))) = (signals.frame_switch_rate, ds.frame_switch_range) {
141+
if rate >= lo && rate <= hi {
142+
score += 2.0;
143+
}
144+
}
145+
if let Some(has_sub) = signals.has_subtitle {
146+
if has_sub && ds.prefers_subtitle {
147+
score += 1.0;
148+
}
149+
// 无字幕时偏好字幕的档案不额外加分(保持对称:仅正向信号计分)
150+
}
151+
if let (Some(dur), Some(min)) = (signals.duration_min, ds.min_duration_min) {
152+
if dur >= min {
153+
score += 1.0;
154+
}
155+
}
156+
score
157+
}

‎scripts/line-limits.mjs‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ export const FROZEN_OVER_LIMIT = [
3535
'app/src-tauri/src/commands_goals.rs',
3636
'app/src-tauri/src/ai_refine_task.rs',
3737
'app/src-tauri/src/note_filter.rs',
38-
'app/src-tauri/src/video_profile.rs',
3938
];
4039

4140
const toPosix = (p) => p.split(sep).join('/');

0 commit comments

Comments
 (0)