Skip to content

Commit 8464f99

Browse files
committed
fix(profile): 审查即修 14 项——记忆兜底/ESC 竞态/烘焙迁移等
1 parent 4dfa2af commit 8464f99

20 files changed

Lines changed: 258 additions & 102 deletions

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

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ fn stop_clipboard_monitor(state: &AppState) {
300300
///
301301
/// @param form - 内容形态(kebab-case;None=不覆写该维)
302302
/// @param tier - 画面档位(kebab-case;None=不覆写该维)
303-
/// @param domain - 领域(kebab-case;None=不覆写该维)
303+
/// @param domain - 领域(kebab-case;None=不覆写该维;**空串 = 清除领域覆写回自动**)
304304
/// @param fine - v0.13.6(REQ-220):细目 id 数组(可选;与 domain 同传——
305305
/// 逐项校验属于该粗领域;仅 fine 无 domain → 报错)
306306
/// @ai-context: 写入 profile_override 共享槽 → screen worker 下轮采样 tick 消费
@@ -313,10 +313,14 @@ pub fn update_live_profile(
313313
domain: Option<String>,
314314
fine: Option<Vec<String>>,
315315
) -> Result<bool, String> {
316-
// 至少一项
317-
if form.is_none() && tier.is_none() && domain.is_none() {
316+
// 至少一项(审查修复:fine 计入——仅传 fine 时给出"需与 domain 同传"而非误导性提示;
317+
// 空串 sentinel 同样是合法"有动作"——清除领域覆写)
318+
if form.is_none() && tier.is_none() && domain.is_none() && fine.is_none() {
318319
return Err("form/tier/domain 至少需一项".to_string());
319320
}
321+
// 领域自动回退:空串 = 清除领域覆写(协议级 sentinel——None=不覆写该维;
322+
// 审查 M1 修复:原「null=不覆写」无法表达"还原自动",领域重置触发"至少需一项"误报)
323+
let clear_domain = domain.as_deref() == Some("");
320324
let parsed_form = form
321325
.as_deref()
322326
.and_then(crate::video_profile_spec::ContentForm::parse);
@@ -326,7 +330,8 @@ pub fn update_live_profile(
326330
let parsed_domain = domain
327331
.as_deref()
328332
.and_then(crate::video_profile_domain::DomainKind::parse);
329-
// form/tier/domain 的非法值各自报错(不模糊——让前端知道哪维错了)
333+
// form/tier/domain 的非法值各自报错(不模糊——让前端知道哪维错了;
334+
// 空串为清除语义不算非法)
330335
if let Some(ref f) = form {
331336
if parsed_form.is_none() {
332337
return Err(format!("非法形态标识: {}", f));
@@ -338,12 +343,14 @@ pub fn update_live_profile(
338343
}
339344
}
340345
if let Some(ref d) = domain {
341-
if parsed_domain.is_none() {
346+
if parsed_domain.is_none() && !clear_domain {
342347
return Err(format!("非法领域标识: {}", d));
343348
}
344349
}
345-
// 细目校验:仅当 domain 同传且粗领域可解析——逐项 belong 校验(非法报错不静默)
350+
// 细目校验:仅当 domain 同传且粗领域可解析——逐项 belong 校验(非法报错不静默)。
351+
// 审查修复:Some([])(空数组)与 None 语义统一(空=无细目,不要求 domain)
346352
let parsed_fine: Vec<String> = match fine {
353+
Some(fs) if fs.is_empty() => Vec::new(),
347354
Some(fs) => {
348355
let d = parsed_domain.ok_or_else(|| "fine 需与 domain 同传".to_string())?;
349356
let mut out = Vec::with_capacity(fs.len());
@@ -360,8 +367,10 @@ pub fn update_live_profile(
360367
let po = crate::live_session::ProfileOverride {
361368
form: parsed_form,
362369
tier: parsed_tier,
363-
domain: parsed_domain,
364-
fine: parsed_fine,
370+
// 清除语义:空串 → domain=None + fine 清空(worker 侧 domain None+locked →
371+
// 解锁并清当前领域——A3 语义即"还原自动")
372+
domain: if clear_domain { None } else { parsed_domain },
373+
fine: if clear_domain { Vec::new() } else { parsed_fine },
365374
};
366375
state
367376
.live_session

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ pub fn detect_video_profile(
9393
has_subtitle,
9494
duration_min: None,
9595
});
96+
// v0.13.6(REQ-221):分区映射形态为最强检测信号——未命中则为 None(回落前端
97+
// 记忆/候选链);命中时即便记忆/标题候选冲突也以分区为准(平台裁决语义)
98+
result.platform_form = platform_form;
99+
// 领域赋值**先于**记忆兜底(审查 H1 修复):vote_detect 恒 domain=None,
100+
// 若兜底在赋值前执行会被本行无条件覆盖成死代码——检测 > 记忆(仅空才兜底)
101+
result.domain = domain;
96102
// 2) 记忆后置判定(四象限:高置信同 kind/低置信 → 记忆生效兜底;
97103
// 高置信冲突 → 检测为准 + memory_conflict 标记——前端展示冲突提示)
98104
if let Some(t) = title.as_deref() {
@@ -103,7 +109,7 @@ pub fn detect_video_profile(
103109
.unwrap_or_default();
104110
result = apply_profile_memory(result, &memory, t);
105111
// v0.13.6(REQ-222):领域记忆兜底——映射/检测为空时用户确认过的
106-
// 粗+细目直接生效(检测 > 记忆——仅检测为空才兜底;source=memory 可诊断)
112+
// 粗+细目直接生效(仅检测为空才兜底;source=memory 可诊断)
107113
if result.domain.is_none() {
108114
if let Some(tag) = memory.lookup_domain(t) {
109115
result.domain = Some(crate::video_profile_domain::DomainDetection {
@@ -119,10 +125,6 @@ pub fn detect_video_profile(
119125
}
120126
}
121127
}
122-
// v0.13.6(REQ-221):分区映射形态为最强检测信号——未命中则为 None(回落前端
123-
// 记忆/候选链);命中时即便记忆/标题候选冲突也以分区为准(平台裁决语义)
124-
result.platform_form = platform_form;
125-
result.domain = domain;
126128
result
127129
}
128130

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,9 +428,11 @@ pub fn run_screen_worker(
428428
if let Some(f) = po.form { current_form = Some(f); changed = true; }
429429
// v0.11.5 审查修复(A3):domain 为 None(用户未选领域)→
430430
// 重置锁定,重新启用自动检测(领域重评不再跳过覆盖);
431-
// v0.13.6:细目与领域同栅——领域未定时细目一并清空
431+
// v0.13.6(审查修复):领域一并清空——避免 emit 出
432+
// domain=旧/fine=[] 的不一致快照("领域自动"语义)
432433
if po.domain.is_none() && domain_user_locked {
433434
domain_user_locked = false;
435+
current_domain_kind = None;
434436
current_fine_ids.clear();
435437
}
436438
if let Some(d) = po.domain {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -621,7 +621,7 @@ impl ProfileMemory {
621621
}
622622
}
623623

624-
/// 单测独立文件(本文件 ~394 行已登记豁免——见 line-limit-exemptions.md;
624+
/// 单测独立文件(本文件 ~590 行已登记豁免——见 line-limit-exemptions.md;
625625
/// 审查 L5 修复:原"保持 ≤300 行"注释不实,改为豁免说明)。
626626
#[cfg(test)]
627627
#[path = "video_profile_tests.rs"]

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -133,23 +133,26 @@ pub fn builtin_profiles() -> Vec<VideoProfile> {
133133
disable_ocr: true,
134134
disable_asr: false,
135135
},
136-
// REQ-124(T1)直播:ASR 为主 + 画面低频(裁决:无 OCR/弹幕——
137-
// disable_ocr=true 跳过画面链;图像流存储层接线后直播画面走 stream)
136+
// REQ-124(T1)直播:v0.13.6(REQ-219,审查 M3 对齐)——独立形态语义
137+
// 定稿为「浅画面」:OCR 待命(disable_ocr=false + ocr_weight=0.1 +
138+
// 低档采样 full_every=30),不再短路画面链(旧裁决无 OCR 与"直播有画面
139+
// 只是价值低"的新四维上下文冲突,以新语义为准——旧会话零回归由
140+
// ProfileKind::Live → ContentForm::Live → resolve_profile 同矩阵保证)
138141
VideoProfile {
139142
kind: ProfileKind::Live,
140143
detect_signals: DetectSignals {
141-
title_keywords: vec!["直播", "开播", "live"].into_iter().map(String::from).collect(),
144+
title_keywords: vec!["直播", "开播", "live", "带货", "实况", "回放"].into_iter().map(String::from).collect(),
142145
url_keywords: Vec::new(),
143146
frame_switch_range: None,
144147
prefers_subtitle: false,
145148
min_duration_min: None,
146149
},
147-
sampling_budget: SamplingBudget { subtitle_every: 2, full_every: 999, silent_subtitle_every: 4, silent_full_every: 999 },
148-
signal_weights: SignalWeights { subtitle_priority: false, ocr_weight: 0.0, asr_weight: 1.0 },
149-
postprocess_rules: PostprocessRules { chapter_detect: true, step_cards: false, verbal_normalize: true, highlight: true, speaker_detect: false, glossary: false },
150+
sampling_budget: SamplingBudget { subtitle_every: 4, full_every: 30, silent_subtitle_every: 6, silent_full_every: 30 },
151+
signal_weights: SignalWeights { subtitle_priority: false, ocr_weight: 0.1, asr_weight: 1.0 },
152+
postprocess_rules: PostprocessRules { chapter_detect: false, step_cards: false, verbal_normalize: true, highlight: true, speaker_detect: false, glossary: false },
150153
artifact_template: ArtifactTemplate::Summary,
151154
storage_tier: StoreTier::TextFirst,
152-
disable_ocr: true,
155+
disable_ocr: false,
153156
disable_asr: false,
154157
},
155158
// REQ-124(T2)白板:时间轴图像流——画面就是主体(全帧高频 full_every=1),

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

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -121,26 +121,22 @@ pub struct DomainDetection {
121121
pub fn detect_domain(signals: &DomainSignals) -> DomainDetection {
122122
let fine_tags: Vec<String> = Vec::new();
123123
// ①a 平台分区映射表(v0.13.6 REQ-221:确定性映射——置信 1.0,细目预选;
124-
// 影视/直播分区 coarse=None——领域留给内容信号,不在此短路)
125-
if let Some(entry) =
126-
crate::video_profile_platform_map::lookup_zone_first(&signals.platform_tags)
127-
{
128-
if let Some(kind) = entry.coarse {
129-
let fine_ids = entry.fine.map(|f| vec![f.to_string()]).unwrap_or_default();
130-
let hit_tag = signals
131-
.platform_tags
132-
.iter()
133-
.find(|t| crate::video_profile_platform_map::lookup_zone(t).is_some())
134-
.cloned()
135-
.unwrap_or_default();
136-
return DomainDetection {
137-
kind: Some(kind),
138-
fine_ids,
139-
fine_tags: if hit_tag.is_empty() { Vec::new() } else { vec![hit_tag] },
140-
source: "platform-map".to_string(),
141-
confidence: 1.0,
142-
};
143-
}
124+
// 影视/直播分区 coarse=None——领域留给内容信号,不在此短路)。
125+
// 审查 M2 修复:取**首个带 coarse 的**分区条目(首个命中条目可能是
126+
// coarse=None 的知识科普/直播——若整体跳过会丢失后续已登记 coarse 分区)
127+
let zone_hit = signals.platform_tags.iter().find_map(|t| {
128+
crate::video_profile_platform_map::lookup_zone(t)
129+
.and_then(|e| e.coarse.map(|k| (e, k, t)))
130+
});
131+
if let Some((entry, kind, tag)) = zone_hit {
132+
let fine_ids = entry.fine.map(|f| vec![f.to_string()]).unwrap_or_default();
133+
return DomainDetection {
134+
kind: Some(kind),
135+
fine_ids,
136+
fine_tags: vec![tag.clone()],
137+
source: "platform-map".to_string(),
138+
confidence: 1.0,
139+
};
144140
}
145141
// ①b 平台分区标签原文(强信号:官方分类原文直接进细标签)
146142
for tag in &signals.platform_tags {

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,10 @@ pub fn seed_words(kind: DomainKind) -> Vec<String> {
130130
"设计", "绘画", "插画", "配色", "排版", "PS", "Photoshop", "海报", "logo", "素描", "创意", "视觉",
131131
],
132132
DomainKind::Music => &[
133-
"音乐", "乐理", "钢琴", "吉他", "和弦", "节拍", "谱", "演唱", "编曲", "旋律", "音符", "音阶",
133+
"音乐", "乐理", "钢琴", "吉他", "和弦", "节拍", "乐谱", "演唱", "编曲", "旋律", "音符", "音阶",
134134
],
135135
DomainKind::Handcraft => &[
136-
"手工", "DIY", "编织", "折纸", "陶艺", "木工", "缝纫", "黏土", "串珠", "烘焙",
136+
"手工", "DIY", "编织", "折纸", "陶艺", "木工", "缝纫", "黏土", "串珠",
137137
],
138138
DomainKind::Exam => &[
139139
"考试", "考证", "考研", "高考", "公务员", "真题", "刷题", "答题", "考点", "冲刺", "备考", "模拟题",
@@ -144,9 +144,9 @@ pub fn seed_words(kind: DomainKind) -> Vec<String> {
144144
DomainKind::Psychology => &[
145145
"心理", "成长", "情绪", "焦虑", "压力", "习惯", "认知", "冥想", "思维", "自律", "人生", "哲学",
146146
],
147-
// v0.13.6:美食烹饪(烘焙自手工迁入)
147+
// v0.13.6:美食烹饪(烘焙自手工迁入——Handcraft 词表已同步移除)
148148
DomainKind::Cooking => &[
149-
"美食", "菜谱", "做饭", "炒菜", "炖", "烤箱", "烘焙", "蛋糕", "面包", "配料", "调味", "料理",
149+
"美食", "菜谱", "做饭", "炒菜", "炖菜", "烤箱", "烘焙", "蛋糕", "面包", "配料", "调味", "料理",
150150
],
151151
// v0.13.6:摄影视频制作
152152
DomainKind::PhotoVideo => &[
@@ -158,7 +158,7 @@ pub fn seed_words(kind: DomainKind) -> Vec<String> {
158158
],
159159
// v0.13.6:写作阅读
160160
DomainKind::Writing => &[
161-
"写作", "小说", "文案", "投稿", "阅读", "读后感", "书评", "文笔", "叙事", "稿", "写作技巧",
161+
"写作", "小说", "文案", "投稿", "阅读", "读后感", "书评", "文笔", "叙事", "稿件", "写作技巧",
162162
],
163163
// v0.13.6:数码硬件
164164
DomainKind::TechGadgets => &[

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,9 @@ pub static FINE_TABLE: &[(DomainKind, &[FineTag])] = &[
117117
DomainKind::Handcraft,
118118
&[
119119
FineTag { id: "knitting", label: "编织", seeds: &["编织", "毛线", "钩针", "棒针", "图解"] },
120-
FineTag { id: "woodwork", label: "木工", seeds: &["木工", "榫卯", "刨", "锯", "上漆"] },
121-
FineTag { id: "pottery", label: "陶艺", seeds: &["陶艺", "拉坯", "釉", "窑", "黏土"] },
122-
FineTag { id: "origami", label: "折纸", seeds: &["折纸", "折", "纸艺", "图解"] },
120+
FineTag { id: "woodwork", label: "木工", seeds: &["木工", "榫卯", "木刨", "锯子", "上漆"] },
121+
FineTag { id: "pottery", label: "陶艺", seeds: &["陶艺", "拉坯", "上釉", "窑炉", "黏土"] },
122+
FineTag { id: "origami", label: "折纸", seeds: &["折纸", "纸艺", "图解", "千纸鹤"] },
123123
FineTag { id: "beading", label: "串珠", seeds: &["串珠", "珠子", "手链", "编绳"] },
124124
],
125125
),
@@ -153,7 +153,7 @@ pub static FINE_TABLE: &[(DomainKind, &[FineTag])] = &[
153153
(
154154
DomainKind::Cooking,
155155
&[
156-
FineTag { id: "home", label: "家常菜", seeds: &["家常", "快手", "下饭", "炒", "炖"] },
156+
FineTag { id: "home", label: "家常菜", seeds: &["家常", "快手", "下饭", "炒菜", "炖菜"] },
157157
FineTag { id: "baking", label: "烘焙", seeds: &["烘焙", "烤箱", "面包", "蛋糕", "发酵"] },
158158
FineTag { id: "western", label: "西餐", seeds: &["西餐", "牛排", "意面", "沙拉", "摆盘"] },
159159
FineTag { id: "drink", label: "饮品咖啡", seeds: &["咖啡", "手冲", "饮品", "奶茶", "拉花"] },

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,20 @@ fn fine_hotword_candidates_union_dedup_and_skip_invalid() {
6666
assert!(cands.iter().any(|c| c == "React") && cands.iter().any(|c| c == "Vue"));
6767
assert!(!cands.iter().any(|c| c == "no-such"));
6868
}
69+
70+
#[test]
71+
fn no_single_char_cjk_fine_seeds() {
72+
// 审查 M4 守卫:细目种子同样禁止单字 CJK("折"/"炒"/"刨"等无边界误命中)
73+
for (_, list) in fine_table() {
74+
for f in *list {
75+
for s in f.seeds {
76+
assert!(
77+
s.chars().count() >= 2 || s.is_ascii(),
78+
"细目 {} 单字 CJK 种子: {}(M4 审查)",
79+
f.id,
80+
s
81+
);
82+
}
83+
}
84+
}
85+
}

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

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::video_profile_domain_data as data;
99

1010
#[test]
1111
fn domain_parse_and_label_roundtrip() {
12-
// Arrange/Act/Assert:15 领域 kebab-case 解析 + 展示名非空
12+
// Arrange/Act/Assert:20 领域 kebab-case 解析 + 展示名非空
1313
for k in ALL_DOMAINS {
1414
assert_eq!(DomainKind::parse(k.as_str()), Some(k), "{:?} roundtrip", k);
1515
assert!(!k.label().is_empty(), "{:?} 展示名非空", k);
@@ -119,12 +119,46 @@ fn fine_tags_open_platform_raw_text() {
119119
};
120120
// Act:知识科普 无领域种子词直接命中 → 平台标签不进领域(诚实不猜)
121121
let d = detect_domain(&s);
122-
// Assert:无领域命中(科普不属于 15 领域之一)——空领域不阻塞;
122+
// Assert:无领域命中(科普不属于 20 领域之一)——空领域不阻塞;
123123
// 细标签不携带(平台标签仅当命中领域时作为证据)
124124
assert_eq!(d.kind, None);
125125
assert_eq!(d.source, "none");
126126
}
127127

128+
// ── v0.13.6 审查回归(M1 烘焙迁移 / M4 单字种子守卫)──
129+
130+
#[test]
131+
fn baking_migrated_to_cooking_not_handcraft() {
132+
// Arrange:烘焙类标题(审查 M1——Handcraft 词表已移除"烘焙",
133+
// 否则 ALL_DOMAINS 顺序下 Handcraft 先命中且 tie-break 不替换,恒归"手工")
134+
let s = DomainSignals {
135+
title: Some("烘焙基础教程".into()),
136+
platform_tags: Vec::new(),
137+
user_confirmed: None,
138+
term_freq: Vec::new(),
139+
asr_opening: None,
140+
};
141+
// Act/Assert:归美食烹饪(迁移意图:烘焙自手工迁入)
142+
let d = detect_domain(&s);
143+
assert_eq!(d.kind, Some(DomainKind::Cooking), "烘焙不得再归手工");
144+
}
145+
146+
#[test]
147+
fn no_single_char_cjk_seeds() {
148+
// 审查 M4 守卫:单字 CJK 种子 + contains 无边界匹配会跨领域误命中
149+
// ("家谱"→Music、"慢炖"→Cooking 等)——粗领域种子必须 ≥2 字(ASCII 缩写除外)
150+
for kind in ALL_DOMAINS {
151+
for w in crate::video_profile_domain_data::seed_words(kind) {
152+
assert!(
153+
w.chars().count() >= 2 || w.is_ascii(),
154+
"{:?} 单字 CJK 种子: {}(M4 审查)",
155+
kind,
156+
w
157+
);
158+
}
159+
}
160+
}
161+
128162
// ── 消费接线(hotwords/术语筛选/区域预期)──
129163

130164
#[test]

0 commit comments

Comments
 (0)