@@ -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