Skip to content

Commit 9720e6f

Browse files
committed
feat(video): v0.9.0 视频档案四维解耦与叙事变体代码建设
M1 四维解耦数据模型(形态7类×画面4档×领域×语言)+ M2 画面价值三信号自动检测/升降档裁决 + M3 领域标签体系/热词预热 + M4 平台适配 + M5 检测卡v2与叙事模板变体;cargo test 1352 全绿
1 parent 9780002 commit 9720e6f

35 files changed

Lines changed: 3547 additions & 160 deletions

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@
55

66
## [Unreleased] - 2026-08-21
77

8+
### 视频档案框架 v2 四维解耦(任务 5:v0.9.0 M1~M5 代码建设,2026-08-21)
9+
10+
- **四维解耦数据模型(REQ-188)**:新增 `video_profile_spec.rs` + `video_profile_spec_data.rs`——形态 7 类(讲授/实操/解说/对话/题目/代码/音频)× 画面档 4 档(高/中/低/无)× 领域 × 语言 四维解耦;13→7 映射(whiteboard→讲授=高、podcast/live→音频=无档);参数矩阵(形态→产物模板+后处理、画面档→采样/OCR/存储);记忆库 kind 映射迁移(MemoryEntry.form 字段 + remember_form/lookup_form,旧 JSON 零回归);新 command(video_profile_for_spec / video_profile_spec_by_kind / remember_video_profile_form)
11+
- **画面价值自动检测(REQ-189)**:新增 `video_tier_detect.rs`——三信号加权投票(帧切换率/OCR 文字面积/区域构成)+ 升降档裁决(升档静默/降档确认)+ 重评窗口聚合(150s 定档);screen worker 集成观测器 + DualRateScheduler::retune 动态调档 + live:tier-changed / live:tier-downgrade-request 事件 + confirm_tier_downgrade command
12+
- **领域标签体系(REQ-190)**:新增 `video_profile_domain.rs` + `video_profile_domain_data.rs`——粗 15 领域 × 种子词表 + 细标签开放 + 四来源检测(平台>用户>标题>术语);hotwords 预热(preheat_domain_hotwords 注入 VocabManager)、术语筛选、区域预期(数学→公式区)接线
13+
- **平台信号适配(REQ-191)**:新增 `platform_adapter.rs`——平台推断(标题后缀/URL/本地文件)+ bilibili 分区标签提取(会话 33 实证 `知识科普|经济管理`)+ local 路径语义 + OCR 标签通用化(不依赖平台枚举);无平台信号零回归
14+
- **检测卡 v2(REQ-192)**:ProfileDetector 重写为三维一体交互(形态/画面/领域各自可调下拉,修改即记忆);未知维"识别中"不阻塞;形态低置信必问;档案卡无开始按钮(归采集控制区)
15+
- **叙事结构模板变体(REQ-193)**:新增 `narrative_detect.rs`——故事化特征投票(角色+转折词)→ 摘要模板"叙事线+要点提取"变体(会话 33 小马故事归属);直接教学零回归
16+
- 验收:cargo test 1352 全绿 + clippy 零警告;M6 真机验收(会话 33 复测)待执行
17+
818
### 重构与基建(任务 4 完成)
919

1020
- **分支重构**:远程分支重建为 `dev`(日常开发主线)/ `main`(发布线,semantic-release 自动发布)/ `old`(旧代码存档,dev+main 合并,只读);删除 `rebuild`;GitHub 默认分支切换为 dev

‎app/src-tauri/Cargo.toml‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,33 @@ default-run = "entropydecrease"
1717
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
1818
name = "app_lib"
1919
crate-type = ["staticlib", "cdylib", "rlib"]
20+
# 隐式 lib test 禁用(2026-08-21 构建修复):统一由下方显式 [[test]] app_lib_tests
21+
# 承载——其链接经 build.rs 注入 comctl32 v6 manifest(隐式目标无法注入,
22+
# 加载 system32 的 comctl32 5.82 报 0xc0000139)
23+
test = false
24+
25+
# 显式测试目标(2026-08-21 构建修复):build.rs 的 comctl32 v6 manifest 注入
26+
# 经 cargo:rustc-link-arg-tests 只作用于测试目标;bin test=false——隐式 bin test
27+
# 无测试(0 用例)且链接 tauri-build 的 resource.lib(已含 MANIFEST 资源),
28+
# 重复注入会 CVT1100 duplicate resource。
29+
[[test]]
30+
name = "app_lib_tests"
31+
path = "src/lib.rs"
32+
33+
[[bin]]
34+
name = "entropydecrease"
35+
path = "src/main.rs"
36+
test = false
37+
38+
[[bin]]
39+
name = "asr_forensic"
40+
path = "src/bin/asr_forensic.rs"
41+
test = false
42+
43+
[[bin]]
44+
name = "cer_bench"
45+
path = "src/bin/cer_bench.rs"
46+
test = false
2047

2148
[features]
2249
# ADR-009(v0.4.0 M1):OCR CUDA EP 编译开关(默认开)——ORT 1.28 官方 gpu_cuda12

‎app/src-tauri/build.rs‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,55 @@
1010
fn main() {
1111
tauri_build::build();
1212
copy_onnxruntime_dll();
13+
declare_comctl32_v6_manifest();
1314
}
1415

16+
/// 声明 comctl32 v6 SxS 依赖(2026-08-21 真机定位修复)。
17+
///
18+
/// @ai-context: Windows 25H2 的 system32\comctl32.dll 为 5.82(无 TaskDialogIndirect
19+
/// 导出),v6 需 manifest 声明后从 WinSxS 按需加载;tauri(windows
20+
/// crate)链接了 TaskDialogIndirect,而 rustc 的 MSVC 链接默认不生成
21+
/// manifest——lib 测试 exe 全量链接时加载 5.82 报
22+
/// 0xc0000139 STATUS_ENTRYPOINT_NOT_FOUND(bin 因链接剪裁不含该符号
23+
/// 不受影响)。作用于 bin/test/cdylib 全部链接目标(含 cargo test)。
24+
/// @ai-context: 不用 /MANIFESTDEPENDENCY 内联声明——其值含空格,经 rustc 透传后
25+
/// link.exe 按空格拆参(LNK1181);改 /MANIFESTINPUT 指向 manifest
26+
/// 文件,路径写入 TEMP 根(避免 "work space" 类空格路径同样拆参失败),
27+
/// TEMP 含空格时降级跳过(仅影响 TaskDialog 调用场景,不阻断构建)。
28+
/// @ai-context: 仅对 test 目标注入(rustc-link-arg-tests)——应用/bin 已有
29+
/// tauri-build 的 resource.lib manifest(含 v6 声明),重复注入
30+
/// 会 CVT1100 duplicate resource;lib test/bin test 无 manifest
31+
/// 才需要(cargo test 全量链接必现 0xc0000139)。
32+
#[cfg(all(target_os = "windows", target_env = "msvc"))]
33+
fn declare_comctl32_v6_manifest() {
34+
let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
35+
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
36+
<dependency>
37+
<dependentAssembly>
38+
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="amd64" publicKeyToken="6595b64144ccf1df" language="*"/>
39+
</dependentAssembly>
40+
</dependency>
41+
</assembly>
42+
"#;
43+
let Ok(dir) = std::env::var("TEMP") else {
44+
return;
45+
};
46+
if dir.contains(' ') {
47+
println!("cargo:warning=TEMP 路径含空格,跳过 comctl32 v6 manifest 注入(TaskDialog 调用场景可能加载 5.82 失败)");
48+
return;
49+
}
50+
let manifest = std::path::Path::new(&dir).join("entropy-comctl32-v6.manifest");
51+
if let Err(e) = std::fs::write(&manifest, xml) {
52+
println!("cargo:warning=写入 comctl32 v6 manifest 失败: {e}");
53+
return;
54+
}
55+
println!("cargo:rustc-link-arg-tests=/MANIFEST:EMBED");
56+
println!("cargo:rustc-link-arg-tests=/MANIFESTINPUT:{}", manifest.display());
57+
}
58+
59+
#[cfg(not(all(target_os = "windows", target_env = "msvc")))]
60+
fn declare_comctl32_v6_manifest() {} // 非 Windows/MSVC 无 SxS manifest 机制
61+
1562
/// 复制 onnxruntime.dll 到 target 输出目录(dev/release 均生效)。
1663
fn copy_onnxruntime_dll() {
1764
println!("cargo:rerun-if-env-changed=ORT_LIB_LOCATION");

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

Lines changed: 56 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,42 @@
11
//! ai_client.rs 单测(AAA 模式;payload/解析纯函数——网络路径不单测)。
22
3+
use std::sync::Mutex;
4+
35
use crate::ai_client::{
46
build_chat_payload, chat_completions_url, extract_content, parse_json_object, AiClient,
57
AiClientConfig, AiClientError,
68
};
79
use crate::ai_settings::AiSettings;
810

11+
/// env 操作互斥(防并行测试互相覆盖 SILICONFLOW_*——与 ai_cost_tests 同模式)。
12+
static ENV_LOCK: Mutex<()> = Mutex::new(());
13+
14+
/// 在锁内执行 env 敏感操作(清除后执行闭包;保存/还原宿主环境)。
15+
fn with_env_locked(f: impl FnOnce()) {
16+
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
17+
let keys = [
18+
"SILICONFLOW_API_KEY",
19+
"SILICONFLOW_BASE_URL",
20+
"SILICONFLOW_MODEL",
21+
"SILICONFLOW_TIMEOUT_SECS",
22+
"SILICONFLOW_RETRIES",
23+
"SILICONFLOW_MAX_TOKENS",
24+
];
25+
let saved: Vec<(String, Option<String>)> =
26+
keys.iter().map(|k| (k.to_string(), std::env::var(k).ok())).collect();
27+
// 清除所有相关 env(防宿主机泄漏干扰测试——闭包内 env 操作纯净)
28+
for k in &keys {
29+
std::env::remove_var(k);
30+
}
31+
f();
32+
for (k, v) in saved {
33+
match v {
34+
Some(val) => std::env::set_var(k, val),
35+
None => std::env::remove_var(k),
36+
}
37+
}
38+
}
39+
940
#[test]
1041
fn chat_url_preserves_v1_segment() {
1142
// 审查回归(2026-08-21):/v1 不得被 trim——默认端点 + 自定义端点均保留
@@ -82,50 +113,35 @@ fn parse_json_object_invalid_is_parse_error() {
82113

83114
#[test]
84115
fn from_settings_resolves_defaults() {
85-
// Arrange:清除相关环境变量(防宿主机泄漏——本机 User 级已设 DeepSeek
86-
// 配置;沿用 ai_text_filter_tests 的 save/remove/restore 模式,末尾还原)
87-
let keys = [
88-
"SILICONFLOW_API_KEY",
89-
"SILICONFLOW_BASE_URL",
90-
"SILICONFLOW_MODEL",
91-
"SILICONFLOW_TIMEOUT_SECS",
92-
"SILICONFLOW_RETRIES",
93-
"SILICONFLOW_MAX_TOKENS",
94-
];
95-
let saved: Vec<(String, Option<String>)> =
96-
keys.iter().map(|k| (k.to_string(), std::env::var(k).ok())).collect();
97-
for k in &keys {
98-
std::env::remove_var(k);
99-
}
100-
// Act:无 env/凭据 → 设置值;密钥为空
101-
let s = AiSettings::default();
102-
let c = AiClient::from_settings(&s, None).config;
103-
// Assert:2026-08-21 真机排查后默认值——长生成超时 300s + 输出上限 20000 token
104-
assert_eq!(c.base_url, s.base_url);
105-
assert_eq!(c.model, s.model);
106-
assert!(c.api_key.is_empty());
107-
assert_eq!(c.timeout_secs, 300);
108-
assert_eq!(c.max_retries, 2);
109-
assert_eq!(c.max_tokens, 20000);
110-
// env 覆盖 max_tokens(顺序执行,无并行竞争)
111-
std::env::set_var("SILICONFLOW_MAX_TOKENS", "4096");
112-
let c2 = AiClient::from_settings(&AiSettings::default(), None).config;
113-
assert_eq!(c2.max_tokens, 4096);
114-
// 清理(还原宿主环境,防污染其他测试)
115-
for (k, v) in saved {
116-
match v {
117-
Some(val) => std::env::set_var(k, val),
118-
None => std::env::remove_var(k),
119-
}
120-
}
116+
// 测试隔离(与 ai_cost_tests 同模式):锁内清除/还原宿主环境变量
117+
with_env_locked(|| {
118+
// Act:无 env/凭据 → 设置值;密钥为空
119+
let s = AiSettings::default();
120+
let c = AiClient::from_settings(&s, None).config;
121+
// Assert:2026-08-21 真机排查后默认值——长生成超时 300s + 输出上限 20000 token
122+
assert_eq!(c.base_url, s.base_url);
123+
assert_eq!(c.model, s.model);
124+
assert!(c.api_key.is_empty());
125+
assert_eq!(c.timeout_secs, 300);
126+
assert_eq!(c.max_retries, 2);
127+
assert_eq!(c.max_tokens, 20000);
128+
// env 覆盖 max_tokens(锁内顺序执行,无并行竞争)
129+
std::env::set_var("SILICONFLOW_MAX_TOKENS", "4096");
130+
let c2 = AiClient::from_settings(&AiSettings::default(), None).config;
131+
assert_eq!(c2.max_tokens, 4096);
132+
});
121133
}
122134

123135
#[test]
124136
fn from_settings_uses_stored_key_when_no_env() {
125-
// 凭据库密钥生效(无 env 时)
126-
let s = AiSettings::default();
127-
let c = AiClient::from_settings(&s, Some("sk-stored".to_string())).config;
128-
assert_eq!(c.api_key, "sk-stored");
137+
// 测试隔离(与 ai_cost_tests 同模式):锁内清除宿主泄漏的 SILICONFLOW_API_KEY
138+
// (本机 User 级已设真实密钥)——无 env 才验证凭据库密钥
139+
with_env_locked(|| {
140+
// 凭据库密钥生效(无 env 时)
141+
let s = AiSettings::default();
142+
let c = AiClient::from_settings(&s, Some("sk-stored".to_string())).config;
143+
assert_eq!(c.api_key, "sk-stored");
144+
});
129145
}
130146

131147
#[test]

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,29 @@ fn hands_on_blocks(
172172
}
173173

174174
/// 摘要文(口播):C2 重点 → Claim 排序 + Quote 金句 + 关键词索引
175+
///
176+
/// @ai-context: v0.9.0 M5(REQ-193):叙事变体——故事化科普(会话 33 类)
177+
/// 产出「叙事线 + 要点提取」(叙事段保序 + 编号要点);
178+
/// 直接教学/无故事化特征 → 现有路径零回归。
175179
fn talking_head_blocks(detail: &SessionDetail, analysis: &SessionAnalysis) -> Vec<ArtifactBlock> {
180+
// 叙事检测(故事化特征:角色 + 转折词;命中 → 叙事线+要点变体)
181+
let narrative = crate::narrative_detect::detect_narrative(
182+
&crate::narrative_detect::NarrativeSignals {
183+
segments: detail
184+
.segments
185+
.iter()
186+
.map(|s| s.text.clone())
187+
.collect(),
188+
ocr_texts: detail
189+
.ocr_blocks
190+
.iter()
191+
.map(|b| b.text.clone())
192+
.collect(),
193+
},
194+
);
195+
if narrative.style == crate::narrative_detect::NarrativeStyle::Storytelling {
196+
return storytelling_blocks(detail, &narrative);
197+
}
176198
let mut blocks = Vec::new();
177199
let mut order = 0u32;
178200
// 重点候选 → Claim
@@ -213,6 +235,60 @@ fn talking_head_blocks(detail: &SessionDetail, analysis: &SessionAnalysis) -> Ve
213235
blocks
214236
}
215237

238+
/// 故事化叙事变体(REQ-193):叙事线(保序叙事段)+ 要点提取(编号要点)。
239+
///
240+
/// @ai-context: 会话 33 实证形态——叙事段作为正文段落保留故事主线;
241+
/// 要点作为 Highlight 块结构化呈现("1、公积金贷款利息低");
242+
/// 无要点时兜底叙事段直出(不丢故事)。
243+
fn storytelling_blocks(
244+
detail: &SessionDetail,
245+
narrative: &crate::narrative_detect::NarrativeDetection,
246+
) -> Vec<ArtifactBlock> {
247+
let mut blocks = Vec::new();
248+
let mut order = 0u32;
249+
// 叙事线:含故事化特征的段保序(引用转写段 id——可回看可跳转)
250+
for seg in &detail.segments {
251+
if narrative.narrative_line.iter().any(|n| n == &seg.text) {
252+
blocks.push(ArtifactBlock {
253+
kind: ArtifactKind::Paragraph,
254+
refs: BlockRefs { segment_id: Some(seg.id), ocr_block_id: None, frame_ms: Some(seg.start_ms) },
255+
payload: BlockPayload::Text(seg.text.clone()),
256+
order,
257+
source: BlockSource::Local,
258+
id: 0,
259+
});
260+
order += 1;
261+
}
262+
}
263+
// 要点提取:编号要点段 → Highlight(结构化呈现)
264+
for point in &narrative.key_points {
265+
blocks.push(ArtifactBlock {
266+
kind: ArtifactKind::Highlight,
267+
refs: BlockRefs { segment_id: None, ocr_block_id: None, frame_ms: None },
268+
payload: BlockPayload::Text(point.clone()),
269+
order,
270+
source: BlockSource::Local,
271+
id: 0,
272+
});
273+
order += 1;
274+
}
275+
// 兜底:无叙事线(故事化但特征段缺失——罕见)→ 全部段直出不丢内容
276+
if blocks.is_empty() {
277+
for seg in &detail.segments {
278+
blocks.push(ArtifactBlock {
279+
kind: ArtifactKind::Paragraph,
280+
refs: BlockRefs { segment_id: Some(seg.id), ocr_block_id: None, frame_ms: Some(seg.start_ms) },
281+
payload: BlockPayload::Text(seg.text.clone()),
282+
order,
283+
source: BlockSource::Local,
284+
id: 0,
285+
});
286+
order += 1;
287+
}
288+
}
289+
blocks
290+
}
291+
216292
/// 对话纪要(访谈):QAPair 结构 + Highlight(A3 讲者标注为可选字段)
217293
fn interview_blocks(detail: &SessionDetail, analysis: &SessionAnalysis) -> Vec<ArtifactBlock> {
218294
let mut blocks = Vec::new();

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,3 +366,57 @@ fn follow_along_template_produces_step_cards_from_boundaries() {
366366
// 口令边界 → 标签=口令原文
367367
assert!(steps.iter().any(|s| matches!(&s.payload, BlockPayload::Step { label: Some(l), .. } if l == "第一组")));
368368
}
369+
370+
// ── v0.9.0 M5(REQ-193):叙事结构模板变体 ──
371+
372+
/// 会话 33 类故事化科普会话(小马买房公积金知识)。
373+
fn storytelling_detail() -> SessionDetail {
374+
detail(
375+
vec![
376+
("小马工作几年存了一点钱", 0, 3000),
377+
("有一天,小马想买房了", 3000, 6000),
378+
("后来小马了解到公积金贷款利息低", 6000, 10000),
379+
("1、公积金贷款利息低", 10000, 13000),
380+
("2、其他情况可以取出", 13000, 16000),
381+
("于是小马决定用公积金贷款", 16000, 20000),
382+
],
383+
vec![("要点:公积金贷款", 7000)],
384+
)
385+
}
386+
387+
#[test]
388+
fn storytelling_科普_produces_narrative_line_and_points() {
389+
// Arrange:故事化科普会话(会话 33 归属——口播/解说档案)
390+
let d = storytelling_detail();
391+
// Act:口播档案构建产物(叙事变体路径)
392+
let artifact = build_artifact(ProfileKind::TalkingHead, &d, &[]);
393+
// Assert:叙事线段落(含角色段保序)+ 要点 Highlight
394+
let paras: Vec<&ArtifactBlock> = artifact.blocks.iter().filter(|b| b.kind == ArtifactKind::Paragraph).collect();
395+
let points: Vec<&ArtifactBlock> = artifact.blocks.iter().filter(|b| b.kind == ArtifactKind::Highlight).collect();
396+
assert!(!paras.is_empty(), "故事化科普应产出叙事线段落");
397+
assert!(paras.iter().any(|b| matches!(&b.payload, BlockPayload::Text(t) if t.contains("小马"))), "叙事线含角色段");
398+
assert!(!points.is_empty(), "故事化科普应产出结构化要点");
399+
assert!(points.iter().any(|b| matches!(&b.payload, BlockPayload::Text(t) if t.contains("1、公积金贷款利息低"))), "要点提取命中编号段");
400+
}
401+
402+
#[test]
403+
fn direct_teaching_模板_零回归() {
404+
// Arrange:直接教学会话(无故事化特征——现有路径)
405+
let d = detail(
406+
vec![
407+
("微积分的核心概念是极限", 0, 5000),
408+
("极限的定义如下", 6000, 10000),
409+
("连续函数满足三个条件", 11000, 15000),
410+
],
411+
vec![("板书:极限定义", 1000)],
412+
);
413+
// Act:口播档案构建(直接教学路径——现有摘要模板)
414+
let artifact = build_artifact(ProfileKind::TalkingHead, &d, &[]);
415+
// Assert:零回归——无叙事线段落(走 Claim/Quote 现有路径)
416+
assert!(!artifact.blocks.iter().any(|b| b.kind == ArtifactKind::Paragraph), "直接教学不产叙事线段落");
417+
// 兜底 Quote 或 Claim 至少存在(现有摘要语义)
418+
assert!(
419+
artifact.blocks.iter().any(|b| matches!(b.kind, ArtifactKind::Quote | ArtifactKind::Claim)),
420+
"直接教学走现有摘要路径"
421+
);
422+
}

‎app/src-tauri/src/capture/frame_diff.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,19 @@ impl DualRateScheduler {
135135
self
136136
}
137137

138+
/// v0.9.0 M2(REQ-189):会话中动态调档——画面价值重评升档时静默生效
139+
/// (更积极的采样无损失);降档由用户确认后经本方法应用。
140+
///
141+
/// @ai-context: 保留已计 tick 数(升档不需重置节奏——调高频率后下一 tick
142+
/// 自然进入新档位);降档保留 tick 可避免同 tick 重复触发。
143+
pub fn retune(&mut self, budget: crate::video_profile::SamplingBudget) {
144+
self.subtitle_every = budget.subtitle_every.max(1);
145+
self.full_every = budget.full_every.max(1);
146+
self.silent_subtitle_every = budget.silent_subtitle_every.max(1);
147+
self.silent_full_every = budget.silent_full_every.max(1);
148+
self.degraded_full_every = self.full_every * 2;
149+
}
150+
138151
/// 推进一个 tick 并返回本次采样区域(speech_active=语音活跃度;degraded=高负载降级)。
139152
///
140153
/// @ai-context: 字幕区与全帧各自独立计数(审查修复):重叠 tick 优先字幕区

0 commit comments

Comments
 (0)