Skip to content

Commit b043970

Browse files
committed
feat(profile): 视频档案分类细化——形态 10/领域两层细目/检测链/领域记忆
1 parent a21af26 commit b043970

23 files changed

Lines changed: 1441 additions & 194 deletions

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,8 @@ fn stop_clipboard_monitor(state: &AppState) {
301301
/// @param form - 内容形态(kebab-case;None=不覆写该维)
302302
/// @param tier - 画面档位(kebab-case;None=不覆写该维)
303303
/// @param domain - 领域(kebab-case;None=不覆写该维)
304+
/// @param fine - v0.13.6(REQ-220):细目 id 数组(可选;与 domain 同传——
305+
/// 逐项校验属于该粗领域;仅 fine 无 domain → 报错)
304306
/// @ai-context: 写入 profile_override 共享槽 → screen worker 下轮采样 tick 消费
305307
/// 并 emit live:profile-updated 事件回推前端。
306308
#[tauri::command]
@@ -309,6 +311,7 @@ pub fn update_live_profile(
309311
form: Option<String>,
310312
tier: Option<String>,
311313
domain: Option<String>,
314+
fine: Option<Vec<String>>,
312315
) -> Result<bool, String> {
313316
// 至少一项
314317
if form.is_none() && tier.is_none() && domain.is_none() {
@@ -339,10 +342,26 @@ pub fn update_live_profile(
339342
return Err(format!("非法领域标识: {}", d));
340343
}
341344
}
345+
// 细目校验:仅当 domain 同传且粗领域可解析——逐项 belong 校验(非法报错不静默)
346+
let parsed_fine: Vec<String> = match fine {
347+
Some(fs) => {
348+
let d = parsed_domain.ok_or_else(|| "fine 需与 domain 同传".to_string())?;
349+
let mut out = Vec::with_capacity(fs.len());
350+
for f in &fs {
351+
if crate::video_profile_domain_fine::parse_fine(d, f).is_none() {
352+
return Err(format!("非法细目标识: {}(不属于领域 {}", f, d.as_str()));
353+
}
354+
out.push(f.clone());
355+
}
356+
out
357+
}
358+
None => Vec::new(),
359+
};
342360
let po = crate::live_session::ProfileOverride {
343361
form: parsed_form,
344362
tier: parsed_tier,
345363
domain: parsed_domain,
364+
fine: parsed_fine,
346365
};
347366
state
348367
.live_session

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

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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]
154180
pub 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 - 窗口标题(记忆匹配键,完整标题入库)

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,9 +324,15 @@ mod video_profile_spec;
324324
mod video_profile_spec_data;
325325
// v0.9.0 M2(REQ-189):画面价值档位检测(三信号投票 + 重评窗口 + 升降档裁决)
326326
mod video_tier_detect;
327-
// v0.9.0 M3(REQ-190):内容领域标签体系(粗 15 领域 + 细标签 + 四来源检测)
327+
// v0.9.0 M3(REQ-190):内容领域标签体系(粗 20 领域 + 细标签 + 四来源检测;
328+
// v0.13.6:粗 15→20 + curated 细目两层——REQ-220)
328329
mod video_profile_domain;
329330
mod video_profile_domain_data;
331+
mod video_profile_domain_fine;
332+
mod video_profile_domain_fine_data;
333+
// v0.13.6(REQ-221):平台分区映射表(B站 分区 → 形态/粗领域/细目)
334+
mod video_profile_platform_map;
335+
mod video_profile_platform_map_data;
330336
// v0.9.0 M4(REQ-191):平台信号适配(bilibili/local 轻量适配 + OCR 标签通用化)
331337
mod platform_adapter;
332338
// v0.9.0 M5(REQ-193):叙事结构检测(故事线/结构化条目/直接教学 模板变体)
@@ -564,6 +570,9 @@ pub fn run() {
564570
// v0.9.0 M3(REQ-190):领域标签检测 + hotwords 预热
565571
commands_video::detect_video_domain,
566572
commands_video::preheat_domain_hotwords,
573+
// v0.13.6(REQ-220/222):细目选项表 + 领域记忆(coarse+细目多选)
574+
commands_video::list_domain_fine,
575+
commands_video::remember_video_profile_domain,
567576
// v0.12.0 M6(采集体验债):采集浮窗打开/关闭
568577
commands_window::open_capture_float,
569578
commands_window::close_capture_float,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ pub struct ProfileOverride {
172172
pub form: Option<crate::video_profile_spec::ContentForm>,
173173
pub tier: Option<crate::video_profile_spec::VisualTier>,
174174
pub domain: Option<crate::video_profile_domain::DomainKind>,
175+
/// v0.13.6(REQ-220):细目 id 多选(与 domain 同传;空=仅粗领域)
176+
pub fine: Vec<String>,
175177
}
176178

177179
/// 会话装配后半段(引擎就绪后):音频捕获 → 屏幕 worker → 主循环 → 后台融合。

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ pub fn run_screen_worker(
128128
// v0.11.5(Task 6):已生效形态/领域状态(内存跟踪;None=未定)
129129
let mut current_form: Option<crate::video_profile_spec::ContentForm> = None;
130130
let mut current_domain_kind: Option<crate::video_profile_domain::DomainKind> = None;
131+
// v0.13.6(REQ-220):已生效细目 id(与 domain 同栅——domain 未定时为空)
132+
let mut current_fine_ids: Vec<String> = Vec::new();
131133
// v0.11.5(终审 I-1):领域用户手动覆写标记——用户裁决 > 自动检测,
132134
// 覆写后自动重评不再覆盖该维度(用户手动改过的不自动推翻)
133135
let mut domain_user_locked = false;
@@ -425,27 +427,33 @@ pub fn run_screen_worker(
425427
}
426428
if let Some(f) = po.form { current_form = Some(f); changed = true; }
427429
// v0.11.5 审查修复(A3):domain 为 None(用户未选领域)→
428-
// 重置锁定,重新启用自动检测(领域重评不再跳过覆盖)
430+
// 重置锁定,重新启用自动检测(领域重评不再跳过覆盖);
431+
// v0.13.6:细目与领域同栅——领域未定时细目一并清空
429432
if po.domain.is_none() && domain_user_locked {
430433
domain_user_locked = false;
434+
current_fine_ids.clear();
431435
}
432436
if let Some(d) = po.domain {
433437
current_domain_kind = Some(d);
434438
// 用户手动覆写 → 锁定该维度(重评不覆盖)
435439
domain_user_locked = true;
436440
changed = true;
441+
// v0.13.6(REQ-220):细目随领域覆写(空=仅粗领域,合法)
442+
current_fine_ids = po.fine.clone();
437443
}
438444
if changed {
439445
let snapshot = crate::live_session::ProfileOverride {
440446
form: current_form,
441447
tier: tier_applied_tier,
442448
domain: current_domain_kind,
449+
fine: current_fine_ids.clone(),
443450
};
444451
if let Ok(mut ag) = applied_profile.lock() { *ag = Some(snapshot); }
445452
let _ = app.emit("live:profile-updated", serde_json::json!({
446453
"form": current_form.map(|f| f.as_str()),
447454
"tier": tier_applied_tier.map(|t| t.as_str()),
448455
"domain": current_domain_kind.map(|d| d.as_str()),
456+
"fine": current_fine_ids,
449457
}));
450458
} else {
451459
// v0.11.5 审查修复(A2):override 取到全空值(command 层
@@ -500,16 +508,20 @@ pub fn run_screen_worker(
500508
&& detected.confidence >= 0.6
501509
{
502510
current_domain_kind = detected.kind;
511+
// v0.13.6:自动重评命中的细目随之生效(curated 预选;空则仅粗领域)
512+
current_fine_ids = detected.fine_ids;
503513
let snapshot = crate::live_session::ProfileOverride {
504514
form: current_form,
505515
tier: tier_applied_tier,
506516
domain: current_domain_kind,
517+
fine: current_fine_ids.clone(),
507518
};
508519
if let Ok(mut ag) = applied_profile.lock() { *ag = Some(snapshot); }
509520
let _ = app.emit("live:profile-updated", serde_json::json!({
510521
"form": current_form.map(|f| f.as_str()),
511522
"tier": tier_applied_tier.map(|t| t.as_str()),
512523
"domain": current_domain_kind.map(|d| d.as_str()),
524+
"fine": current_fine_ids,
513525
}));
514526
}
515527
}

0 commit comments

Comments
 (0)