@@ -67,6 +67,10 @@ pub fn detect_video_profile(
6767 }
6868 _ => { }
6969 }
70+ // v0.13.6(REQ-221):分区映射表 → 形态候选(强信号:影视/直播分区直接定
71+ // 形态,防标题词误判讲授/解说;未命中 None——回落记忆/标题链)
72+ let platform_form = crate :: video_profile_platform_map:: lookup_zone_first ( & platform_hints)
73+ . and_then ( |e| e. form ) ;
7074 let domain = crate :: video_profile_domain:: detect_domain (
7175 & crate :: video_profile_domain:: DomainSignals {
7276 title : title. clone ( ) ,
@@ -79,7 +83,8 @@ pub fn detect_video_profile(
7983 asr_opening : None ,
8084 } ,
8185 ) ;
82- let domain = ( domain. kind . is_some ( ) || !domain. fine_tags . is_empty ( ) ) . then_some ( domain) ;
86+ let domain = ( domain. kind . is_some ( ) || !domain. fine_tags . is_empty ( ) || !domain. fine_ids . is_empty ( ) )
87+ . then_some ( domain) ;
8388 // 1) 信号投票(检测优先——v0.11.5 Task 5:不再被记忆命中短路,先跑检测)
8489 let mut result = vote_detect ( & ObservedSignals {
8590 title : title. clone ( ) ,
@@ -97,7 +102,26 @@ pub fn detect_video_profile(
97102 . map ( |m| m. clone ( ) )
98103 . unwrap_or_default ( ) ;
99104 result = apply_profile_memory ( result, & memory, t) ;
105+ // v0.13.6(REQ-222):领域记忆兜底——映射/检测为空时用户确认过的
106+ // 粗+细目直接生效(检测 > 记忆——仅检测为空才兜底;source=memory 可诊断)
107+ if result. domain . is_none ( ) {
108+ if let Some ( tag) = memory. lookup_domain ( t) {
109+ result. domain = Some ( crate :: video_profile_domain:: DomainDetection {
110+ kind : tag
111+ . coarse
112+ . as_deref ( )
113+ . and_then ( crate :: video_profile_domain:: DomainKind :: parse) ,
114+ fine_tags : Vec :: new ( ) ,
115+ fine_ids : tag. fine ,
116+ source : "memory" . to_string ( ) ,
117+ confidence : 1.0 ,
118+ } ) ;
119+ }
120+ }
100121 }
122+ // v0.13.6(REQ-221):分区映射形态为最强检测信号——未命中则为 None(回落前端
123+ // 记忆/候选链);命中时即便记忆/标题候选冲突也以分区为准(平台裁决语义)
124+ result. platform_form = platform_form;
101125 result. domain = domain;
102126 result
103127}
@@ -150,16 +174,28 @@ pub fn detect_video_domain(
150174/// ASR 术语命中率↑;幂等去重)。
151175///
152176/// @param kind - 领域标识(kebab-case;非法值明确报错)
177+ /// @param fine - v0.13.6(REQ-220):细目 id 数组(可选)——候选集 = 粗种子 ∪ 细目种子
178+ /// (前端/后端术语不同,细分预热命中率↑);缺省 None 向后兼容
153179#[ tauri:: command]
154180pub fn preheat_domain_hotwords (
155181 state : State < ' _ , AppState > ,
156182 kind : String ,
183+ fine : Option < Vec < String > > ,
157184) -> Result < usize , String > {
158185 let kind = crate :: video_profile_domain:: DomainKind :: parse (
159186 & kind. chars ( ) . take ( 30 ) . collect :: < String > ( ) ,
160187 )
161188 . ok_or_else ( || "非法领域标识" . to_string ( ) ) ?;
162- let candidates = crate :: video_profile_domain:: hotword_candidates ( kind) ;
189+ let mut candidates = crate :: video_profile_domain:: hotword_candidates ( kind) ;
190+ // 细目候选(并集去重;非法 id 静默跳过——诚实降级为仅粗通道)
191+ for c in crate :: video_profile_domain_fine:: fine_hotword_candidates (
192+ kind,
193+ & fine. unwrap_or_default ( ) ,
194+ ) {
195+ if !candidates. contains ( & c) {
196+ candidates. push ( c) ;
197+ }
198+ }
163199 let mut vocab = state
164200 . vocab
165201 . lock ( )
@@ -171,6 +207,69 @@ pub fn preheat_domain_hotwords(
171207 Ok ( added)
172208}
173209
210+ /// v0.13.6(REQ-222):记录用户确认的领域(coarse+细目多选)——同标题/系列下次直接生效。
211+ ///
212+ /// @param title - 窗口标题(记忆匹配键;series 键剥离与形态记忆同口径)
213+ /// @param coarse - 粗领域标识(kebab-case;非法值明确报错)
214+ /// @param fine - 细目 id 数组(可选;逐项校验属于该粗领域——非法报错不静默)
215+ #[ tauri:: command]
216+ pub fn remember_video_profile_domain (
217+ state : State < ' _ , AppState > ,
218+ title : String ,
219+ coarse : String ,
220+ fine : Option < Vec < String > > ,
221+ ) -> Result < ( ) , String > {
222+ let title = title. trim ( ) . to_string ( ) ;
223+ if title. is_empty ( ) {
224+ return Err ( "标题为空,无法记忆" . to_string ( ) ) ;
225+ }
226+ let kind = crate :: video_profile_domain:: DomainKind :: parse (
227+ & coarse. chars ( ) . take ( 30 ) . collect :: < String > ( ) ,
228+ )
229+ . ok_or_else ( || "非法领域标识" . to_string ( ) ) ?;
230+ let fine = fine. unwrap_or_default ( ) ;
231+ for id in & fine {
232+ if crate :: video_profile_domain_fine:: parse_fine ( kind, id) . is_none ( ) {
233+ return Err ( format ! ( "非法细目标识: {}(不属于领域 {}" , id, kind. as_str( ) ) ) ;
234+ }
235+ }
236+ let tag = crate :: video_profile_spec:: DomainTag {
237+ coarse : Some ( kind. as_str ( ) . to_string ( ) ) ,
238+ fine,
239+ } ;
240+ let path = state. profile_memory_path . clone ( ) ;
241+ let memory = state. profile_memory . clone ( ) ;
242+ // 锁内 read-modify-write(与形态记忆同模式,防 TOCTOU 文件竞争)
243+ {
244+ let mut guard = memory
245+ . lock ( )
246+ . map_err ( |e| format ! ( "档案记忆锁中毒: {}" , e) ) ?;
247+ guard. remember_domain ( & title, & tag) ;
248+ guard
249+ . save ( & path)
250+ . map_err ( |e| format ! ( "保存档案记忆失败: {}" , e) ) ?;
251+ }
252+ Ok ( ( ) )
253+ }
254+
255+ /// v0.13.6(REQ-220):细目选项表(单一数据源——前端 chips 下拉从 Rust 拉取,
256+ /// 与 VideoProfile 检测卡同源;未登记粗领域 → 空列表诚实)。
257+ #[ tauri:: command]
258+ pub fn list_domain_fine ( ) -> Vec < ( String , Vec < crate :: video_profile_domain_fine:: FineTagDto > ) > {
259+ crate :: video_profile_domain_fine:: fine_table ( )
260+ . iter ( )
261+ . map ( |( k, list) | (
262+ k. as_str ( ) . to_string ( ) ,
263+ list. iter ( )
264+ . map ( |f| crate :: video_profile_domain_fine:: FineTagDto {
265+ id : f. id . to_string ( ) ,
266+ label : f. label . to_string ( ) ,
267+ } )
268+ . collect ( ) ,
269+ ) )
270+ . collect ( )
271+ }
272+
174273/// 记录用户确认/修改:窗口标题 → 档案(记忆偏好,下次同标题直接生效)。
175274///
176275/// @param title - 窗口标题(记忆匹配键,完整标题入库)
0 commit comments