Skip to content

Commit 15f306e

Browse files
committed
fix(v0.14): 审查即修(词间空格/无效bbox/worker退出/悬空边/副作用)
1 parent 584a1dc commit 15f306e

7 files changed

Lines changed: 96 additions & 15 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ impl Db {
8888
/// 删除笔记;返回是否实际删除。
8989
pub fn delete_note(&self, id: i64) -> Result<bool> {
9090
self.with_conn(|conn| {
91+
// v0.14 C3 审查(L5 悬空边):笔记删除级联清理 knowledge_links 的
92+
// target 引用——图谱/反查不出现指向已删除笔记的悬空边。先清引用
93+
// 后删笔记:失败半态 = 反查少一条(可重挂),优于悬空边;单连接
94+
// Mutex 串行执行无并发窗口(项目无事务先例,保持 with_conn 风格)
95+
conn.execute(
96+
"DELETE FROM knowledge_links WHERE target_type = 'note' AND target_id = ?1",
97+
params![id],
98+
)?;
9199
let affected = conn.execute("DELETE FROM notes WHERE id = ?1", params![id])?;
92100
Ok(affected > 0)
93101
})

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,10 +250,12 @@ pub(crate) fn ocr_worker_loop(
250250
};
251251
(result, reply)
252252
}
253-
// 外层 match 已分流 RecognizeLines(返回类型不同)——逻辑不可达
253+
// 外层 match 已分流 RecognizeLines(返回类型不同)——逻辑不可达;
254+
// 防御(审查 L2):即使可达也不得以裸 return 退出主循环(会
255+
// 静默终止整个 OCR worker,后续请求全部失败)——跳过继续
254256
OcrRequest::RecognizeLines { reply, .. } => {
255257
let _ = reply.send(Err(AppError::Ocr("行级重识别请求分流失效".to_string())));
256-
return;
258+
continue;
257259
}
258260
};
259261
if result.is_err() {

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,12 @@ pub fn is_suspect_line(text: &str, score: f32) -> bool {
3535
}
3636

3737
/// 行图裁剪(纯函数):bbox + padding → 裁剪图(越界 clamp)。
38+
///
39+
/// @ai-context: 空矩形防御(审查 M2 双保险)——非法 bbox(零宽高)不裁剪返回
40+
/// 原图;调用方 rec_pipeline_on_blocks 已先过滤无效 bbox(无效
41+
/// 不送识别),本检查兜底防未来调用方遗漏(原图送识别会以整屏
42+
/// 结果静默替换碎片,污染落库数据)。
3843
pub fn crop_line(image: &RgbImage, bbox: &TextBox) -> RgbImage {
39-
// 空矩形防御:非法 bbox(零宽高)不裁剪(返回原图——调用方按 1:1 索引忽略)
4044
if bbox.w <= 0.0 || bbox.h <= 0.0 {
4145
return image.clone();
4246
}
@@ -128,7 +132,11 @@ pub fn rec_pipeline_on_blocks(
128132
let suspects: Vec<usize> = blocks
129133
.iter()
130134
.enumerate()
131-
.filter(|(_, b)| b.bbox.is_some() && is_suspect_line(&b.text, b.score))
135+
.filter(|(_, b)| {
136+
// 无效 bbox(零宽高)不裁剪不识别(审查 M2)——crop_line 对无效
137+
// bbox 返回原图,若送识别会以整屏图结果静默替换碎片文本(落库污染)
138+
b.bbox.is_some_and(|bb| bb.w > 0.0 && bb.h > 0.0) && is_suspect_line(&b.text, b.score)
139+
})
132140
.map(|(i, _)| i)
133141
.collect();
134142
if suspects.is_empty() {

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,22 @@ fn pipeline_all_healthy_no_rec_call() {
183183
// Assert:0 替换(无识别请求——crops 空)
184184
assert_eq!(replaced, 0);
185185
}
186+
187+
#[test]
188+
fn invalid_bbox_never_sent_to_recognition() {
189+
// Arrange:零宽 bbox 疑碎块 + 识别器返回整屏内容(审查 M2——原实现会把
190+
// 整屏图送识别并静默替换碎片文本,污染落库)
191+
let mut blocks = vec![blk("碎", 0.9, Some((10.0, 10.0, 0.0, 20.0)))];
192+
let rec = FakeRec {
193+
results: vec![LineRecResult { text: "整屏内容".to_string(), score: 0.9 }],
194+
fail: false,
195+
};
196+
let img = RgbImage::from_pixel(100, 50, image::Rgb([0, 0, 0]));
197+
198+
// Act
199+
let replaced = rec_pipeline_on_blocks(&rec, &img, &mut blocks, 0.5);
200+
201+
// Assert:无效 bbox 不裁剪不识别——块文本原样保留
202+
assert_eq!(replaced, 0);
203+
assert_eq!(blocks[0].text, "碎", "无效 bbox 块不得被整屏结果替换");
204+
}

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,17 +177,23 @@ fn to_line_input(b: &SessionOcrBlock) -> crate::line_merge::LineInput {
177177
}
178178

179179
/// 两块合并(① 产物):文本直拼(中文行内拼接)+ 得分均值 + bbox 并集。
180+
///
181+
/// @ai-context: ASCII 词间空格保持(审查 M1)——英文/数字 OCR 块 trim 后直拼会
182+
/// 单词粘连("Hello " + "World" → "HelloWorld",空格信息已丢失
183+
/// 不可恢复);中文无词间空格不受影响。
180184
fn merge_two(a: &SessionOcrBlock, b: &SessionOcrBlock) -> SessionOcrBlock {
181185
let (ba, bb) = (a.bbox.unwrap(), b.bbox.unwrap());
182186
let x = ba.x.min(bb.x);
183187
let y = ba.y.min(bb.y);
184188
let x2 = (ba.x + ba.w).max(bb.x + bb.w);
185189
let y2 = (ba.y + ba.h).max(bb.y + bb.h);
190+
let (a_t, b_t) = (a.text.trim(), b.text.trim());
191+
let gap = if ascii_gap(a_t, b_t) { " " } else { "" };
186192
SessionOcrBlock {
187193
id: a.id,
188194
session_id: a.session_id,
189195
timestamp_ms: a.timestamp_ms,
190-
text: format!("{}{}", a.text.trim(), b.text.trim()),
196+
text: format!("{}{}{}", a_t, gap, b_t),
191197
score: (a.score + b.score) / 2.0,
192198
region: a.region.clone(),
193199
region_kind: a.region_kind.clone(),
@@ -196,6 +202,13 @@ fn merge_two(a: &SessionOcrBlock, b: &SessionOcrBlock) -> SessionOcrBlock {
196202
}
197203
}
198204

205+
/// ASCII 词间空格判定(审查 M1):a 尾字符与 b 首字符均为 ASCII 字母/数字 →
206+
/// 需补空格(英文/数字行合并场景;中文首尾字符非 ASCII 不触发)。
207+
fn ascii_gap(a: &str, b: &str) -> bool {
208+
a.chars().last().is_some_and(|c| c.is_ascii_alphanumeric())
209+
&& b.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
210+
}
211+
199212
/// 块 → 净化行(trim 文本/bbox)。
200213
fn to_purified(b: &SessionOcrBlock) -> PurifiedLine {
201214
PurifiedLine {

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,3 +242,31 @@ fn bbox_shift_keeps_both_frames() {
242242
// Assert:两屏都输出(位置位移 → 新屏,不误并)
243243
assert_eq!(result.ocr_body, vec!["标题内容".to_string(), "标题内容更多".to_string()]);
244244
}
245+
246+
// ── 审查 M1 回归:ASCII 词间空格保持 ────────────────────────────
247+
248+
#[test]
249+
fn merge_two_keeps_ascii_word_gap() {
250+
// Arrange:英文行合并("Hello " + "World")——trim 后直拼会单词粘连
251+
let a = blk_boxed(1_000, "Hello ", 0.9, 0.0, 0.0, 50.0, 20.0);
252+
let b = blk_boxed(1_000, "World", 0.9, 50.0, 0.0, 50.0, 20.0);
253+
254+
// Act
255+
let merged = super::merge_two(&a, &b);
256+
257+
// Assert:补词间空格(空格信息在 trim 前已存在,恢复语义)
258+
assert_eq!(merged.text, "Hello World");
259+
}
260+
261+
#[test]
262+
fn merge_two_chinese_joins_without_gap() {
263+
// Arrange:中文行合并——无词间空格语义,直拼不受影响(零回归)
264+
let a = blk_boxed(1_000, "红色代表", 0.9, 0.0, 0.0, 80.0, 20.0);
265+
let b = blk_boxed(1_000, "热情", 0.9, 80.0, 0.0, 40.0, 20.0);
266+
267+
// Act
268+
let merged = super::merge_two(&a, &b);
269+
270+
// Assert:中文直拼无空格
271+
assert_eq!(merged.text, "红色代表热情");
272+
}

‎app/src/components/GroupSidebar.tsx‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -188,22 +188,25 @@ export default function GroupSidebar({
188188

189189
/** 访问组:写最近使用(LRU)+ 过滤切换(toggle=组行单击互斥;recent=直达) */
190190
const handleGroupSelect = (g: NoteGroup, toggle: boolean) => {
191-
setRecentIds((cur) => {
192-
const next = pushRecentGroup(cur, g.id);
193-
writeRecentGroupIds(window.localStorage, next);
194-
return next;
195-
});
191+
setRecentIds((cur) => pushRecentGroup(cur, g.id));
196192
onGroupFilterChange(toggle ? (groupFilter === g.id ? null : g.id) : g.id);
197193
};
198194

199195
const toggleFolded = (kind: string) => {
200-
setFolded((cur) => {
201-
const next = { ...cur, [kind]: !cur[kind] };
202-
writeFolded(window.localStorage, kind, next[kind]);
203-
return next;
204-
});
196+
setFolded((cur) => ({ ...cur, [kind]: !cur[kind] }));
205197
};
206198

199+
// v0.14 C1 审查(L6):setState updater 保持纯函数——localStorage 副作用移出,
200+
// 经 useEffect 在 state 落定后同步(StrictMode 双调用/并发渲染下行为确定);
201+
// 挂载后首写与初始化值相同,幂等无副作用
202+
useEffect(() => {
203+
writeRecentGroupIds(window.localStorage, recentIds);
204+
}, [recentIds]);
205+
useEffect(() => {
206+
// folded 键数固定(GROUP_SECTIONS),全量写量级忽略
207+
Object.entries(folded).forEach(([kind, value]) => writeFolded(window.localStorage, kind, value));
208+
}, [folded]);
209+
207210
/** 拖拽归组(组行 drop;noteId 来自 NoteListView 行 dragStart) */
208211
const handleGroupDrop = (g: NoteGroup, e: React.DragEvent) => {
209212
e.preventDefault();

0 commit comments

Comments
 (0)