Skip to content

Commit 79ba797

Browse files
committed
refactor(rust): 拆 video_profile 步 2 — 记忆偏好域
1 parent 19dd641 commit 79ba797

3 files changed

Lines changed: 250 additions & 224 deletions

File tree

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

Lines changed: 10 additions & 221 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,16 @@ mod detect;
1818
/// 检测域再导出:既有 `crate::video_profile::{…}` 导入路径保持不变。
1919
/// ⚠️ 路径必须是 `detect::`(子模块路径)而非 `crate::video_profile_detect::`——
2020
/// 本模块由 `#[path]` 在**父模块内**声明(不动 lib.rs 的 `mod` 块),不是 crate 根子模块。
21-
pub use detect::{vote_detect, DetectResult, ObservedSignals, ProfileCandidate};
21+
/// 用 glob 而非逐个列名:既覆盖两域全部公共面,也避免「公共类型被显式再导出却
22+
/// 无 crate 内按名引用」触发 unused_imports(与 types.rs `pub use types_*::*` 同款)。
23+
pub use detect::*;
24+
25+
/// 记忆偏好域(原 404–622 行)——三条匹配通道 + JSON IO,见 video_profile_memory.rs 模块头。
26+
#[path = "video_profile_memory.rs"]
27+
mod memory;
28+
29+
/// 记忆域再导出:既有 `crate::video_profile::{…}` 导入路径保持不变。
30+
pub use memory::*;
2231

2332
/// 十二类档案标识(全栈统一业务术语;v0.7.1 起含 Unknown 共十三值)。
2433
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -269,226 +278,6 @@ pub fn apply_profile_memory(
269278
result
270279
}
271280

272-
/// 记忆偏好条目:窗口标题关键词 → 用户确认过的档案。
273-
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274-
pub struct MemoryEntry {
275-
pub keyword: String,
276-
pub kind: ProfileKind,
277-
/// v0.7.2(REQ-152):系列键标记——键是剥离序号后的系列名(true),
278-
/// 同系列各集共享记忆;旧 JSON 缺省 false(零回归)。
279-
#[serde(default)]
280-
pub is_series: bool,
281-
/// v0.9.0(REQ-188):四维形态记忆(旧 13 类 kind 的映射结果;
282-
/// 旧 JSON 缺省 None → 读取时经 kind.to_form() 映射,零迁移风险)。
283-
#[serde(default)]
284-
pub form: Option<crate::video_profile_spec::ContentForm>,
285-
}
286-
287-
/// v0.13.6(REQ-222):领域记忆条目——coarse+细目 id(用户确认即记忆)。
288-
///
289-
/// @ai-context: 与 MemoryEntry(kind/form 通道)**分离**:领域记忆不参与 lookup()
290-
/// 的 kind 匹配——防 domain-only 条目把 legacy kind 结果染成 Unknown;
291-
/// 检索走 lookup_domain(同最长关键词/series 键规则)。
292-
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
293-
pub struct DomainMemoryEntry {
294-
pub keyword: String,
295-
/// series 键标记(与 MemoryEntry.is_series 同口径)
296-
#[serde(default)]
297-
pub is_series: bool,
298-
/// 用户确认的领域(粗 + 细目 id 多选);空 fine=仅粗领域(合法——不阻塞)
299-
#[serde(default)]
300-
pub domain: crate::video_profile_spec::DomainTag,
301-
}
302-
303-
/// 记忆偏好库(JSON 持久化;同 vocab 模式:路径可注入,测试用 tempfile)。
304-
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
305-
pub struct ProfileMemory {
306-
pub entries: Vec<MemoryEntry>,
307-
/// v0.13.6(REQ-222):领域记忆独立通道(旧 JSON 缺省空——零迁移)
308-
#[serde(default)]
309-
pub domain_entries: Vec<DomainMemoryEntry>,
310-
}
311-
312-
impl ProfileMemory {
313-
/// 从磁盘加载;文件不存在/损坏 → 空库(防御:不阻断启动)。
314-
pub fn load(path: &std::path::Path) -> Self {
315-
let Ok(raw) = std::fs::read_to_string(path) else { return Self::default() };
316-
serde_json::from_str(&raw).unwrap_or_default()
317-
}
318-
319-
/// 原子写(先 .tmp 再 rename,防写一半损坏记忆库)。
320-
pub fn save(&self, path: &std::path::Path) -> crate::error::Result<()> {
321-
let raw = serde_json::to_string_pretty(self)
322-
.map_err(|e| crate::error::AppError::Io(format!("序列化档案记忆失败: {}", e)))?;
323-
let tmp = path.with_extension("json.tmp");
324-
std::fs::write(&tmp, raw)?;
325-
std::fs::rename(&tmp, path)?;
326-
Ok(())
327-
}
328-
329-
/// 按窗口标题查询记忆偏好:标题包含某条 keyword 即命中(同窗口标题下次直接生效)。
330-
///
331-
/// @ai-context: 最长关键词优先("网课-数学" 应命中更长更具体的条目)。
332-
/// @ai-context: v0.7.2(REQ-152):先试**系列键**——标题可识别系列(P/集/EP 等)
333-
/// 时用系列名匹配,P1 确认过的档案 P5 直接生效(修复标题序号
334-
/// 变化导致的记忆失配);系列未命中回退完整标题 contains(现状)。
335-
pub fn lookup(&self, title: &str) -> Option<ProfileKind> {
336-
if let Some(info) = crate::series_detect::extract_series(title) {
337-
if let Some(kind) = self.lookup_best(&info.series) {
338-
return Some(kind);
339-
}
340-
}
341-
self.lookup_best(title)
342-
}
343-
344-
/// 最长关键词优先匹配(纯函数)。
345-
fn lookup_best(&self, key: &str) -> Option<ProfileKind> {
346-
let mut best: Option<(usize, ProfileKind)> = None;
347-
for e in &self.entries {
348-
if key.contains(&e.keyword) && !e.keyword.is_empty() {
349-
let len = e.keyword.chars().count();
350-
if best.as_ref().is_none_or(|(bl, _)| len > *bl) {
351-
best = Some((len, e.kind));
352-
}
353-
}
354-
}
355-
best.map(|(_, kind)| kind)
356-
}
357-
358-
/// 记录用户确认(关键词已存在则覆盖档案;新增追加)。
359-
///
360-
/// @ai-context: v0.7.2(REQ-152):标题可识别系列 → 存**系列键**(is_series=true,
361-
/// 同系列各集共享);否则存完整标题(现状行为零回归)。
362-
pub fn remember(&mut self, keyword: &str, kind: ProfileKind) {
363-
let form = kind.to_form();
364-
self.remember_with_form(keyword, kind, form);
365-
}
366-
367-
/// 记录用户确认(四维形态优先版,REQ-188):kind 存代表旧类(消费端兼容),
368-
/// form 存新形态(检测卡 v2 下次直接生效——同标题/同系列)。
369-
///
370-
/// @ai-context: form 为 None(Unknown 等无可映射形态)时仅记 kind——
371-
/// 形态维度诚实未知,不猜默认;读取时 lookup_form 返回 None。
372-
pub fn remember_form(&mut self, keyword: &str, form: crate::video_profile_spec::ContentForm) {
373-
let kind = crate::video_profile_spec_data::legacy_kind_for_form(form);
374-
self.remember_with_form(keyword, kind, Some(form));
375-
}
376-
377-
/// 记录内部实现(series 键剥离 + 条目覆盖/追加 + form 同步)。
378-
fn remember_with_form(
379-
&mut self,
380-
keyword: &str,
381-
kind: ProfileKind,
382-
form: Option<crate::video_profile_spec::ContentForm>,
383-
) {
384-
let (key, is_series) = Self::memory_key(keyword);
385-
if key.is_empty() {
386-
return;
387-
}
388-
if let Some(e) = self.entries.iter_mut().find(|e| e.keyword == key) {
389-
e.kind = kind;
390-
e.is_series = is_series;
391-
e.form = form;
392-
} else {
393-
self.entries.push(MemoryEntry { keyword: key, kind, is_series, form });
394-
}
395-
}
396-
397-
/// 记忆键归一化(series 剥离;空键 → None 语义由调用方拒绝)。
398-
///
399-
/// @ai-context: MemoryEntry/DomainMemoryEntry 共用——键规则单一来源防漂移。
400-
fn memory_key(keyword: &str) -> (String, bool) {
401-
let keyword = keyword.trim().to_string();
402-
if keyword.is_empty() {
403-
return (keyword, false);
404-
}
405-
match crate::series_detect::extract_series(&keyword) {
406-
Some(info) => (info.series, true),
407-
None => (keyword, false),
408-
}
409-
}
410-
411-
/// 记录用户确认的领域(coarse+细目多选;REQ-222)——同标题/系列下次直接生效。
412-
///
413-
/// @ai-context: 独立通道(DomainMemoryEntry)不污染 kind/form 记忆;
414-
/// 细目为空(仅粗领域)同样合法;空键拒绝(不记悬挂条目)。
415-
pub fn remember_domain(&mut self, keyword: &str, domain: &crate::video_profile_spec::DomainTag) {
416-
let (key, is_series) = Self::memory_key(keyword);
417-
if key.is_empty() {
418-
return;
419-
}
420-
if let Some(e) = self.domain_entries.iter_mut().find(|e| e.keyword == key) {
421-
e.is_series = is_series;
422-
e.domain = domain.clone();
423-
} else {
424-
self.domain_entries.push(DomainMemoryEntry {
425-
keyword: key,
426-
is_series,
427-
domain: domain.clone(),
428-
});
429-
}
430-
}
431-
432-
/// 按标题查询领域记忆(REQ-222):series 键优先,完整标题兜底;最长关键词优先。
433-
pub fn lookup_domain(&self, title: &str) -> Option<crate::video_profile_spec::DomainTag> {
434-
if let Some(info) = crate::series_detect::extract_series(title) {
435-
if let Some(tag) = self.lookup_domain_best(&info.series) {
436-
return Some(tag);
437-
}
438-
}
439-
self.lookup_domain_best(title)
440-
}
441-
442-
/// 最长关键词优先匹配(纯函数;领域记忆专用通道)。
443-
fn lookup_domain_best(&self, key: &str) -> Option<crate::video_profile_spec::DomainTag> {
444-
let mut best: Option<(usize, crate::video_profile_spec::DomainTag)> = None;
445-
for e in &self.domain_entries {
446-
if key.contains(&e.keyword) && !e.keyword.is_empty() {
447-
let len = e.keyword.chars().count();
448-
if best.as_ref().is_none_or(|(bl, _)| len > *bl) {
449-
best = Some((len, e.domain.clone()));
450-
}
451-
}
452-
}
453-
best.map(|(_, tag)| tag)
454-
}
455-
456-
/// 按标题查询四维形态记忆(REQ-188):form 优先,旧条目经 kind 映射兜底。
457-
///
458-
/// @ai-context: 消费端 v2(检测卡/会话落库)用本方法;旧 lookup 保留为
459-
/// 13 类兼容(零回归)。序列键剥离逻辑与 lookup 同口径。
460-
pub fn lookup_form(&self, title: &str) -> Option<crate::video_profile_spec::ContentForm> {
461-
if let Some(info) = crate::series_detect::extract_series(title) {
462-
if let Some(form) = self.lookup_form_best(&info.series) {
463-
return Some(form);
464-
}
465-
}
466-
self.lookup_form_best(title)
467-
}
468-
469-
/// 最长关键词优先匹配(纯函数;form 优先、kind 映射兜底)。
470-
fn lookup_form_best(
471-
&self,
472-
key: &str,
473-
) -> Option<crate::video_profile_spec::ContentForm> {
474-
let mut best: Option<(usize, crate::video_profile_spec::ContentForm)> = None;
475-
for e in &self.entries {
476-
if key.contains(&e.keyword) && !e.keyword.is_empty() {
477-
let len = e.keyword.chars().count();
478-
let form = e
479-
.form
480-
.or_else(|| e.kind.to_form());
481-
if let Some(f) = form {
482-
if best.as_ref().is_none_or(|(bl, _)| len > *bl) {
483-
best = Some((len, f));
484-
}
485-
}
486-
}
487-
}
488-
best.map(|(_, form)| form)
489-
}
490-
}
491-
492281
/// 单测独立文件(本文件 ~590 行已登记豁免——见 line-limit-exemptions.md;
493282
/// 审查 L5 修复:原"保持 ≤300 行"注释不实,改为豁免说明)。
494283
#[cfg(test)]

0 commit comments

Comments
 (0)