Skip to content

Commit 653e8b3

Browse files
committed
fix(kb): 审查即修 Rust 批——采纳守卫前置/总线补端/回填分批/语义降级加固(v0.19.4/5)
1 parent 90c11fa commit 653e8b3

18 files changed

Lines changed: 198 additions & 90 deletions

‎app/src-tauri/Cargo.toml‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,6 @@ sherpa-onnx = { version = "1.13", default-features = false, features = ["shared"
7272
# .cargo/config.toml 注入);仅取 ndarray 输入通道,禁 download-binaries
7373
# (crate 本地缓存 + config 零网络下载;模型文件另走 model_registry 分发)
7474
ort = { version = "2.0.0-rc.13", default-features = false, features = ["ndarray"] }
75-
# ort ndarray 通道需要直接依赖同版本 ndarray(构建张量输入)
76-
ndarray = "0.16"
7775
# v0.12.0 构建修复:sherpa-onnx 由 static(MT 静态库,内嵌 onnxruntime 符号)切 shared
7876
# (DLL 动态链接)——否则与 oar-ocr 的 ort-sys(MD 动态链接 onnxruntime.dll)链接时
7977
# 发生 LNK2038 RuntimeLibrary 冲突 + LNK2005 onnxruntime 符号重复;shared 包

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -433,8 +433,10 @@ pub async fn update_note(
433433
// (v0.10.0 状态一致性规则:版本快照只在显式保存/AI 采纳时建立)
434434
if create_version == Some(false) {
435435
let ok = state.db.update_note(id, &title, &content).map_err(|e| e.to_string())?;
436-
// REQ-278:笔记域变更广播(自动保存同样触发——防抖在订阅端)
437-
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Notes);
436+
// REQ-278:确有变化才广播(空保存/同内容失焦不制造假刷新)
437+
if ok {
438+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Notes);
439+
}
438440
return Ok(ok);
439441
}
440442
// v0.10.1 F3:内容去重——与最新版本相同则跳过 versioned_save
@@ -476,8 +478,10 @@ pub async fn delete_note(state: State<'_, AppState>, id: i64) -> Result<bool, St
476478
eprintln!("[notes] 清理笔记图片目录失败({img_dir:?}): {e}");
477479
}
478480
}
479-
// REQ-278:确实删除才广播 notes 域
481+
// REQ-278:确实删除才广播 notes 域;连带清 knowledge_links(note 引用)——
482+
// Knowledge 页图谱/引用同样需即时刷新(低-1 审查补端)
480483
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Notes);
484+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Knowledge);
481485
}
482486
Ok(deleted)
483487
}

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,12 @@ pub fn ai_enrich_apply(
187187
) -> Result<Note, String> {
188188
// 存在性校验(versioned_save 内部也会校验——提前失败给明确错误)
189189
get_note(state.inner(), note_id)?;
190+
// 审查修复(2026-09-04):采纳守卫前置于任何写库(重复采纳防重复版本+usage)
191+
if let Some(tid) = task_id {
192+
if state.db.is_ai_task_adopted(tid) {
193+
return Err("该任务结果已采纳落库——请勿重复采纳(可到笔记页查看)".to_string());
194+
}
195+
}
190196
// 审查修复(2026-08-21):落库成本用模型感知单价(与预估同口径)
191197
let cost = crate::ai_cost::usage_cost_for_model(
192198
result.base_markdown.chars().count(),
@@ -222,13 +228,9 @@ pub fn ai_enrich_apply(
222228
},
223229
)
224230
.map_err(|e| e.to_string())?;
225-
// F2 任务中心:标记采纳 + 成本回填(task_id 可选;防重启后重复采纳)。
226-
// 审查修复(2026-08-21):服务端前置校验已采纳状态(前端禁用 + 服务端
227-
// 兜底双保险——防异常调用重复建笔记)。
231+
// F2 任务中心:标记采纳 + 成本回填(task_id 可选;防重启后重复采纳——
232+
// 采纳守卫已前置于写库前)
228233
if let Some(tid) = task_id {
229-
if state.db.is_ai_task_adopted(tid) {
230-
return Err("该任务结果已采纳落库——请勿重复采纳(可到笔记页查看)".to_string());
231-
}
232234
let _ = state.db.mark_ai_task_adopted(tid);
233235
let _ = state.db.update_ai_task_cost(tid, cost);
234236
}

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,12 @@ pub fn ai_note_refine_apply(
152152
if cur.content != result.base_markdown {
153153
return Err("笔记内容已在你精修后被修改——请重新精修(当前版本未被覆盖,可先行查看)".to_string());
154154
}
155+
// 审查修复(2026-09-04):采纳守卫前置于任何写库(重复采纳防重复版本+usage)
156+
if let Some(tid) = task_id {
157+
if state.db.is_ai_task_adopted(tid) {
158+
return Err("该任务结果已采纳落库——请勿重复采纳(可到笔记页查看)".to_string());
159+
}
160+
}
155161
let cost = crate::ai_cost::usage_cost_for_model(
156162
result.base_markdown.chars().count(),
157163
result.refined_markdown.chars().count(),
@@ -182,9 +188,6 @@ pub fn ai_note_refine_apply(
182188
)
183189
.map_err(|e| e.to_string())?;
184190
if let Some(tid) = task_id {
185-
if state.db.is_ai_task_adopted(tid) {
186-
return Err("该任务结果已采纳落库——请勿重复采纳(可到笔记页查看)".to_string());
187-
}
188191
let _ = state.db.mark_ai_task_adopted(tid);
189192
let _ = state.db.update_ai_task_cost(tid, cost);
190193
}

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,14 @@ pub fn ai_refine_apply(
363363
.ok_or_else(|| format!("会话不存在: {}", session_id))?;
364364
let fallback = format!("{}(AI 精修)", session.title);
365365
let title = crate::commands::normalize_title(result.title.clone(), &fallback);
366+
// 审查修复(2026-09-04):采纳守卫**前置**于任何写库——F2 注释自称"服务端
367+
// 前置校验"但实为后置(先建基线笔记/写版本/记 usage 再拒,重复采纳会留
368+
// 孤儿半态且 Err 早退不播 Notes);双点/陈旧面板/多入口并发兜底依赖此处。
369+
if let Some(tid) = task_id {
370+
if state.db.is_ai_task_adopted(tid) {
371+
return Err("该任务结果已采纳落库——请勿重复采纳(可到笔记页查看)".to_string());
372+
}
373+
}
366374
// ① 规则基线建笔记(版本链首快照原料——可回溯精修前内容)
367375
let new = NewNote {
368376
title: title.clone(),
@@ -417,13 +425,8 @@ pub fn ai_refine_apply(
417425
)
418426
.map_err(|e| e.to_string())?;
419427
// F2 任务中心:标记采纳 + 成本回填(task_id 可选——旧前端调用不传则跳过;
420-
// 防重启后从任务中心重复采纳产生重复笔记)。
421-
// 审查修复(2026-08-21):服务端前置校验已采纳状态——防异常/重复调用
422-
// 绕过前端 UI 直接重复建笔记(前端禁用 + 服务端兜底双保险)。
428+
// 防重启后从任务中心重复采纳产生重复笔记——采纳守卫已前置于写库前)
423429
if let Some(tid) = task_id {
424-
if state.db.is_ai_task_adopted(tid) {
425-
return Err("该任务结果已采纳落库——请勿重复采纳(可到笔记页查看)".to_string());
426-
}
427430
let _ = state.db.mark_ai_task_adopted(tid);
428431
let _ = state.db.update_ai_task_cost(tid, cost);
429432
}

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,10 +220,14 @@ pub fn promote_fragment_to_note(
220220
return Err(format!("笔记组不存在: {}", gid));
221221
}
222222
}
223-
state
223+
let note = state
224224
.db
225225
.promote_fragment_to_note(&state.data_dir, fragment_id, &title, group_id)
226-
.map_err(|e| e.to_string())
226+
.map_err(|e| e.to_string())?;
227+
// REQ-278 审查补端:碎片升笔记 = 笔记新增 + 组内容/计数变化(碎片移出)
228+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Notes);
229+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::NoteGroups);
230+
Ok(note)
227231
}
228232

229233
/// 碎片图片 → 本地绝对路径(前端 convertFileSrc 消费;REQ-201 缩略图)。

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

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,10 @@ const EMBEDDING_MODEL_REL: &str = "embedding/bge-small-zh-v1.5";
7171
/// 下载 kb embedding 模型(bge-small-zh-v1.5 int8 ONNX + BERT 词表)。
7272
///
7373
/// @ai-context: 模型文件不入库、不打包(约 25MB 按需下载——REQ-262 口径);
74-
/// 源:ONNX 量化模型走 Xenova(社区导出),vocab.txt 走 BAAI
75-
/// 官方仓库;下载写 .part 后原子改名(中断残留可重下覆盖)。
76-
/// 下载完成 ≠ 就绪——需 kb_embedding_load 换槽 + 「学习库」段
77-
/// 重建索引回填向量(命令返回值给前端引导文案)。
74+
/// 审查修复(2026-09-04):双文件均已就位 → 幂等跳过(重启不重复
75+
/// 下 25MB);单文件下载失败清理 .part;落位前按 Content-Length
76+
/// 校验长度(防截断半文件被加载器吞并报费解错误)。
77+
/// 下载完成 ≠ 就绪——需 kb_embedding_load 换槽 + 重建索引回填。
7878
#[tauri::command]
7979
pub async fn kb_embedding_download(state: State<'_, AppState>) -> Result<String, String> {
8080
let dir = state.model_dir.join(EMBEDDING_MODEL_REL);
@@ -90,23 +90,47 @@ pub async fn kb_embedding_download(state: State<'_, AppState>) -> Result<String,
9090
];
9191
tauri::async_runtime::spawn_blocking(move || -> Result<String, String> {
9292
std::fs::create_dir_all(&dir).map_err(|e| format!("创建模型目录失败: {e}"))?;
93-
let mut downloaded = Vec::new();
94-
for (name, url) in files {
93+
let missing: Vec<&str> = files
94+
.iter()
95+
.filter(|(name, _)| !dir.join(name).is_file())
96+
.map(|(name, _)| *name)
97+
.collect();
98+
if missing.is_empty() {
99+
return Ok(format!(
100+
"模型文件已就绪(幂等跳过下载):{}——点击「▶ 加载引擎」后全量重建即可",
101+
dir.to_string_lossy()
102+
));
103+
}
104+
let mut downloaded: Vec<String> = Vec::new();
105+
for (name, url) in files.iter().filter(|(name, _)| missing.contains(name)) {
95106
let part = dir.join(format!("{name}.part"));
96107
let target = dir.join(name);
97-
let resp = ureq::get(url)
98-
.timeout(std::time::Duration::from_secs(600))
99-
.call()
100-
.map_err(|e| format!("下载 {name} 失败: {e}"))?;
101-
let mut reader = resp.into_reader();
102-
let mut out = std::fs::File::create(&part).map_err(|e| format!("写临时文件失败: {e}"))?;
103-
std::io::copy(&mut reader, &mut out).map_err(|e| format!("写 {name} 失败: {e}"))?;
104-
drop(out);
105-
std::fs::rename(&part, &target).map_err(|e| format!("落位 {name} 失败: {e}"))?;
106-
downloaded.push(name.to_string());
108+
let result = (|| -> Result<(), String> {
109+
let resp = ureq::get(url)
110+
.timeout(std::time::Duration::from_secs(600))
111+
.call()
112+
.map_err(|e| format!("下载 {name} 失败: {e}"))?;
113+
let expected = resp
114+
.header("content-length")
115+
.and_then(|v| v.parse::<u64>().ok());
116+
let mut reader = resp.into_reader();
117+
let mut out = std::fs::File::create(&part).map_err(|e| format!("写临时文件失败: {e}"))?;
118+
let copied = std::io::copy(&mut reader, &mut out).map_err(|e| format!("写 {name} 失败: {e}"))?;
119+
drop(out);
120+
if expected.is_some_and(|len| copied != len) {
121+
return Err(format!("{name} 下载不完整({copied}/{expected:?} 字节)——已清理,请重试"));
122+
}
123+
std::fs::rename(&part, &target).map_err(|e| format!("落位 {name} 失败: {e}"))?;
124+
Ok(())
125+
})();
126+
if let Err(e) = result {
127+
let _ = std::fs::remove_file(&part); // 失败清理半文件(重试不留残)
128+
return Err(e);
129+
}
130+
downloaded.push((*name).to_string());
107131
}
108132
Ok(format!(
109-
"已下载:{}({})——请在「学习库」段点击加载引擎并重建索引以回填向量",
133+
"已下载:{}({})——请点击「▶ 加载引擎」并「🔄 全量重建」以回填向量",
110134
downloaded.join("、"),
111135
dir.to_string_lossy()
112136
))
@@ -150,14 +174,19 @@ pub fn kb_embedding_status(state: State<'_, AppState>) -> Result<EmbeddingStatus
150174

151175
/// 加载(或重载)本地 ONNX embedding 引擎。
152176
///
153-
/// @ai-context: 模型文件由下载命令/分发先落位(models/embedding/bge-small-zh-
154-
/// v1.5/{model_quantized.onnx,vocab.txt});本命令只做加载与换槽:
155-
/// 成功 → 槽位切 onnx(后续 reindex 按新引擎重嵌);失败 → 槽位
156-
/// 保持原样并如实报错(不静默降级——状态命令仍显示旧态)。
177+
/// @ai-context: 模型文件由下载命令/分发先落位;本命令只做加载与换槽:成功 →
178+
/// 槽位切 onnx(后续 reindex 按新引擎重嵌);失败 → 槽位保持原样
179+
/// 并如实报错。审查修复(2026-09-04):模型解析(~25MB onnx)搬
180+
/// 到 spawn_blocking——不在主线程/async 运行时上做秒级 CPU 工作。
157181
#[tauri::command]
158-
pub fn kb_embedding_load(state: State<'_, AppState>) -> Result<EmbeddingStatusView, String> {
182+
pub async fn kb_embedding_load(state: State<'_, AppState>) -> Result<EmbeddingStatusView, String> {
159183
let dir = state.model_dir.join(EMBEDDING_MODEL_REL);
160-
let engine = crate::kb_embed_onnx::OnnxEmbedding::try_load(&dir)?;
184+
let dir_for_load = dir.clone();
185+
let engine = tauri::async_runtime::spawn_blocking(move || {
186+
crate::kb_embed_onnx::OnnxEmbedding::try_load(&dir_for_load)
187+
})
188+
.await
189+
.map_err(|e| format!("引擎加载任务调度失败: {e}"))??;
161190
let mut slot = state
162191
.embedding_slot
163192
.lock()

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,10 @@ pub fn add_knowledge_concept(
6464
boundary: Option<String>,
6565
relation: Option<String>,
6666
) -> Result<KnowledgeConcept, String> {
67-
add_knowledge_concept_inner(&state.db, system_id, name, essence, boundary, relation)
67+
let c = add_knowledge_concept_inner(&state.db, system_id, name, essence, boundary, relation)?;
68+
// REQ-278 审查补端:概念新增 → 广播 knowledge 域(体系页/检索跨页刷新)
69+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Knowledge);
70+
Ok(c)
6871
}
6972

7073
/// 更新概念可选字段(name 归一化 + 查重;status 白名单),返回更新后实体。
@@ -81,7 +84,10 @@ pub fn update_knowledge_concept(
8184
relation: Option<String>,
8285
status: Option<String>,
8386
) -> Result<KnowledgeConcept, String> {
84-
update_knowledge_concept_inner(&state.db, id, name, essence, boundary, relation, status)
87+
let c = update_knowledge_concept_inner(&state.db, id, name, essence, boundary, relation, status)?;
88+
// REQ-278 审查补端:概念更新 → 广播 knowledge 域
89+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Knowledge);
90+
Ok(c)
8591
}
8692

8793
/// 列出概念(体系/状态过滤可选)。
@@ -110,7 +116,10 @@ pub fn add_knowledge_model(
110116
valid_when: Option<String>,
111117
invalid_when: Option<String>,
112118
) -> Result<KnowledgeModel, String> {
113-
add_knowledge_model_inner(&state.db, system_id, name, disciplines, claim, valid_when, invalid_when)
119+
let m = add_knowledge_model_inner(&state.db, system_id, name, disciplines, claim, valid_when, invalid_when)?;
120+
// REQ-278 审查补端:模型新增 → 广播 knowledge 域
121+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Knowledge);
122+
Ok(m)
114123
}
115124

116125
/// 更新模型可选字段(disciplines 同 add 校验),返回更新后实体。
@@ -130,7 +139,10 @@ pub fn update_knowledge_model(
130139
invalid_when: Option<String>,
131140
status: Option<String>,
132141
) -> Result<KnowledgeModel, String> {
133-
update_knowledge_model_inner(&state.db, id, name, disciplines, claim, valid_when, invalid_when, status)
142+
let m = update_knowledge_model_inner(&state.db, id, name, disciplines, claim, valid_when, invalid_when, status)?;
143+
// REQ-278 审查补端:模型更新 → 广播 knowledge 域
144+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Knowledge);
145+
Ok(m)
134146
}
135147

136148
/// 列出体系内模型。

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ pub fn note_versions_rollback(
5050
.db
5151
.rollback_to(note_id, target_version_id)
5252
.map_err(|e| e.to_string())?;
53+
// REQ-278 审查补端:回滚 = 笔记内容变更(版本链新枝)→ 广播 notes 域
54+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Notes);
5355
state
5456
.db
5557
.get_note(note_id)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ pub fn start_photo_session(state: State<'_, AppState>, title: Option<String>) ->
8585
})?;
8686
*guard = Some(session.id);
8787
*state.photo_store.lock().map_err(|_| "图文图片库锁中毒".to_string())? = Some(store);
88+
// REQ-278 审查补端:图文会话创建(旁路 create_session)→ 广播 sessions 域
89+
crate::notify::emit_changed(&state.app, crate::notify::DataDomain::Sessions);
8890
Ok(session.id)
8991
}
9092

0 commit comments

Comments
 (0)