Skip to content

Commit bdd6338

Browse files
committed
fix(rag): v0.19 审查即修——焦点陈旧/失败引用保留/流标志泄漏/limit 契约/引号路由
1 parent 402da10 commit bdd6338

21 files changed

Lines changed: 342 additions & 95 deletions

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,8 @@ pub fn truncate_retrieval(text: &str, budget_chars: usize) -> String {
6565

6666
/// 多片段排序截断(按给定相关性得分离低;总量 ≤ budget_chars——O(N log N) 排序
6767
/// 后单遍累加,**输出上界与输入规模无关**:预算恒定)。
68-
/// 登记豁免 dead_code:多片段检索打包为 L3 FTS 接线预留(当前最小面上文为
69-
/// 单体串截断 truncate_retrieval——本函数 TDD 就绪,随 REQ-024 检索增强启用)。
70-
#[allow(dead_code)]
68+
/// @ai-context: v0.19.1(REQ-260)学习库问答上下文打包消费本函数(kb_prompt::kb_build_context)——
69+
/// 原"预留 dead_code"豁免注释与 #[allow] 已随转正移除(审查 L6 清理)。
7170
pub fn pack_fragments(fragments: &[(String, f64)], budget_chars: usize) -> (String, bool) {
7271
let mut sorted: Vec<&(String, f64)> = fragments.iter().collect();
7372
sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,14 @@ pub async fn chat_regenerate(
200200
let model = client.config.model.clone();
201201
// 单活跃流注册早于删除动作(审查修复:与 chat_send 同纪律)
202202
let flag = try_begin_stream(&state, session_id)?;
203-
let msgs = state.db.list_chat_messages(session_id).map_err(|e| e.to_string())?;
203+
// 审查 M2(v0.19.1 同修既有泄漏):flag 已注册——list 失败早退必须 end_stream
204+
let msgs = match state.db.list_chat_messages(session_id) {
205+
Ok(m) => m,
206+
Err(e) => {
207+
end_stream(&state, session_id);
208+
return Err(e.to_string());
209+
}
210+
};
204211
if let Some(last_assistant) = msgs.iter().rev().find(|m| m.role == "assistant") {
205212
if let Err(e) = state.db.delete_chat_message(session_id, last_assistant.id) {
206213
end_stream(&state, session_id);

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

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ pub(crate) fn kb_chat_send(
6464
end_stream(state, session_id);
6565
return Err(e.to_string());
6666
}
67+
// 交互即"最近更新"(gate-off 路径无 model 回写不再 bump——显式 touch;
68+
// 审查 L5:此前仅生成成功路径经 set_chat_session_model 顺带刷新排序)
69+
let _ = state.db.touch_chat_session(session_id);
6770
kb_run(state, session_id, content, channel, flag, &settings)
6871
}
6972

@@ -75,7 +78,15 @@ pub(crate) fn kb_chat_regenerate(
7578
) -> Result<(), String> {
7679
let settings = settings_snapshot(state)?;
7780
let flag = try_begin_stream(state, session_id)?;
78-
let msgs = state.db.list_chat_messages(session_id).map_err(|e| e.to_string())?;
81+
let msgs = match state.db.list_chat_messages(session_id) {
82+
Ok(m) => m,
83+
// 审查 M2:flag 已注册——任何早退必须 end_stream,否则会话永久
84+
// "进行中"直至重启
85+
Err(e) => {
86+
end_stream(state, session_id);
87+
return Err(e.to_string());
88+
}
89+
};
7990
// 先定位并删除旧回答(failed/aborted 占位一并重试——语义同纯聊 regenerate)
8091
let old_assistant = msgs.iter().rev().find(|m| m.role == "assistant");
8192
if let Some(last) = old_assistant {
@@ -90,7 +101,10 @@ pub(crate) fn kb_chat_regenerate(
90101
.find(|m| m.role == "user")
91102
.map(|m| m.content.trim().to_string());
92103
match question {
93-
Some(q) if !q.is_empty() => kb_run(state, session_id, &q, channel, flag, &settings),
104+
Some(q) if !q.is_empty() => {
105+
let _ = state.db.touch_chat_session(session_id);
106+
kb_run(state, session_id, &q, channel, flag, &settings)
107+
}
94108
_ => {
95109
end_stream(state, session_id);
96110
Err("没有可重新生成的提问(历史已空)".to_string())
@@ -171,15 +185,23 @@ fn kb_run(
171185
let _ = state.db.set_chat_session_model(session_id, None, &model);
172186
}
173187
let meta_answer = kb_meta_json("answer", &hits);
188+
// 失败分支共用的 hits-only meta 快照(线程内命中引用保留——审查 H1:
189+
// 生成中失败(断网/401/429)也必须挂 meta,前端 failed 气泡据此渲染引用)
190+
let meta_hits = kb_meta_json("hits-only", &hits);
174191
let st = state.clone();
175192
let q = question.to_string();
176193
let tier = settings.kb_qa_tier.clone();
177194
tauri::async_runtime::spawn_blocking(move || {
178195
// ④ 片段全文取回 → 预算打包(pack_fragments 转正——硬顶 + 诚实截断)
179-
let text_map = st
180-
.db
181-
.kb_chunk_texts(&hits.iter().map(|h| h.chunk_id).collect::<Vec<_>>())
182-
.unwrap_or_default();
196+
let chunk_ids: Vec<i64> = hits.iter().map(|h| h.chunk_id).collect();
197+
let text_map = match st.db.kb_chunk_texts(&chunk_ids) {
198+
Ok(m) => m,
199+
Err(e) => {
200+
// 审查 L4:取回失败不得静默——记录并按空文本降级(命中引用仍在)
201+
eprintln!("[kb-chat] 命中全文取回失败(按空文本降级): {}", e);
202+
std::collections::HashMap::new()
203+
}
204+
};
183205
let entries: Vec<(KbHit, String)> = hits
184206
.iter()
185207
.cloned()
@@ -228,13 +250,28 @@ fn kb_run(
228250
Err(e) => {
229251
let msg = e.to_string();
230252
let _ = channel.send(ChatStreamEvent::from(&e));
231-
let _ = st.db.insert_chat_message(
253+
// 失败回退命中列表(hits-only meta 照挂——引用不丢;历史过滤
254+
// 按 failed 排除——审查 H1 修复:生成中失败与生成前失败同契约)
255+
match st.db.insert_chat_message(
232256
session_id,
233257
"assistant",
234258
&truncate_chars(&msg, 500),
235259
"failed",
236-
);
237-
// 失败回退命中列表(meta 照挂——引用不丢;历史过滤按 failed 排除)
260+
) {
261+
Ok(id) => {
262+
let _ = st.db.finish_chat_message(
263+
id,
264+
&truncate_chars(&msg, 500),
265+
"failed",
266+
None,
267+
Some(&model),
268+
);
269+
if let Some(meta) = &meta_hits {
270+
let _ = st.db.set_chat_message_meta(session_id, id, Some(meta));
271+
}
272+
}
273+
Err(e2) => eprintln!("[kb-chat] 失败消息落库失败: {}", e2),
274+
}
238275
}
239276
}
240277
end_stream(&st, session_id);

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ pub struct ChatMessage {
4646
/// v0.19.1(REQ-260):消息级元数据 JSON(学习库问答=命中/引用清单;
4747
/// NULL=无元数据——既有消息零变化)
4848
pub meta_json: Option<String>,
49-
/// done | aborted | failed(failed 内容为空——错误详情走流事件)
49+
/// done | aborted | failed(failed 内容=错误占位文本(截断);错误详情另走
50+
/// 流事件——审查注释修正:早期实现曾为空,现与 failed 占位语义一致)
5051
pub status: String,
5152
pub created_at: i64,
5253
}
@@ -168,6 +169,17 @@ impl Db {
168169
Ok(())
169170
}
170171

172+
/// 轻量交互触点(v0.19.1 审查 L5 修复):会话"最近更新"排序 bump——
173+
/// 学习库问答 gate-off/失败路径无 model 回写,须显式 touch 保持排序如实。
174+
pub fn touch_chat_session(&self, id: i64) -> Result<()> {
175+
let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
176+
conn.execute(
177+
"UPDATE chat_sessions SET updated_at=?1 WHERE id=?2",
178+
params![crate::db_sessions_rows::unix_seconds(), id],
179+
)?;
180+
Ok(())
181+
}
182+
171183
/// 插入消息(返回 id)。
172184
pub fn insert_chat_message(
173185
&self,

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

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,22 @@ pub struct KbQueryPlan {
4040

4141
/// 查询 → 候选规划(纯函数;空/全停用词 → 全空三件套——调用方按"无命中"
4242
/// 处理,诚实明说不猜)。
43+
///
44+
/// @ai-context: 分类前剥离成对引号("…" 与 “…”/‘…’)——trigram 只索引字母
45+
/// 数字连续段,带引号的 "配色" 若按原文 4 字符判长会进 FTS 而
46+
/// 内部 token 仅 2 字符致静默零命中(审查 M1:先剥引号再按内容
47+
/// 判档——剥后 <3 字转 LIKE 子串,与不带引号行为一致)。
4348
pub fn plan_query(query: &str) -> KbQueryPlan {
4449
let mut fts_terms: Vec<String> = Vec::new();
4550
let mut like_terms: Vec<String> = Vec::new();
4651
let mut highlight: Vec<String> = Vec::new();
4752
for token in query.split_whitespace() {
48-
if contains_cjk(token) {
49-
for seg in split_cjk_stops(token) {
53+
let stripped = strip_outer_quotes(token);
54+
if stripped.is_empty() {
55+
continue;
56+
}
57+
if contains_cjk(stripped) {
58+
for seg in split_cjk_stops(stripped) {
5059
if seg.chars().count() == 2 {
5160
push_unique(&mut like_terms, &seg);
5261
push_unique(&mut highlight, &seg);
@@ -58,12 +67,12 @@ pub fn plan_query(query: &str) -> KbQueryPlan {
5867
}
5968
// 1 字残段丢弃(trigram 与 LIKE 均无意义——诚实舍弃)
6069
}
61-
} else if token.chars().count() >= 3 {
62-
push_unique(&mut fts_terms, token);
63-
push_unique(&mut highlight, token);
64-
} else if token.chars().count() == 2 {
65-
push_unique(&mut like_terms, token);
66-
push_unique(&mut highlight, token);
70+
} else if stripped.chars().count() >= 3 {
71+
push_unique(&mut fts_terms, stripped);
72+
push_unique(&mut highlight, stripped);
73+
} else if stripped.chars().count() == 2 {
74+
push_unique(&mut like_terms, stripped);
75+
push_unique(&mut highlight, stripped);
6776
}
6877
}
6978
let fts = (!fts_terms.is_empty()).then(|| {
@@ -76,6 +85,27 @@ pub fn plan_query(query: &str) -> KbQueryPlan {
7685
KbQueryPlan { fts, like_terms, highlight_terms: highlight }
7786
}
7887

88+
/// 剥离最外层成对引号(ASCII 直引号 + 中文全角引号;不成对/内嵌 → 原样)。
89+
fn strip_outer_quotes(token: &str) -> &str {
90+
let b = token.as_bytes();
91+
if b.len() < 2 {
92+
return token;
93+
}
94+
let first = token.chars().next().expect("len≥1");
95+
let last = token.chars().last().expect("len≥1");
96+
let paired = matches!(
97+
(first, last),
98+
('"', '"') | ('\'', '\'') | ('\u{201C}', '\u{201D}') | ('\u{2018}', '\u{2019}')
99+
);
100+
if paired {
101+
let inner = &token[first.len_utf8()..token.len() - last.len_utf8()];
102+
// 剥一层即可(内嵌引号属词内符号——由 fts 短语/LIKE 字面按原样处理)
103+
inner
104+
} else {
105+
token
106+
}
107+
}
108+
79109
/// 段落含 CJK 判定(CJK 统一表意区含扩展——覆盖日常用语)。
80110
fn contains_cjk(s: &str) -> bool {
81111
s.chars().any(|c| matches!(c as u32, 0x4E00..=0x9FFF | 0x3400..=0x4DBF))
@@ -105,16 +135,44 @@ fn split_cjk_stops(run: &str) -> Vec<String> {
105135
/// 段 → 整段 + 3~6 字滑动窗口短语(>6 字时展开:防"学色彩搭配"式前缀粘连
106136
/// 致整段短语落空——整段 verbatim 命中 + 滑窗 OR,bm25 自然把真关键词
107137
/// 所在块排前)。
138+
///
139+
/// @ai-context: 膨胀上界(审查 M3:聊天消息 ≤16000 字符时按 n 全展开会生成
140+
/// ~4n 短语 + 去重 O(n²),秒级卡顿):>32 字段不再发整段(verbatim
141+
/// 命中概率趋零);滑窗只在**首 24 / 尾 24** 两区取 k=3..6,且总量
142+
/// 硬顶 96——任何输入下表达式有界(毫秒级),超长问句语义集中在
143+
/// 头尾关键词,尾区保尾词召回)。
144+
const WINDOW_WHOLE_MAX_CHARS: usize = 32;
145+
const WINDOW_EDGE_CHARS: usize = 24;
146+
const WINDOW_TERMS_CAP: usize = 96;
147+
108148
fn cjk_windows(seg: &str) -> Vec<String> {
109149
let chars: Vec<char> = seg.chars().collect();
110150
let n = chars.len();
111151
if n <= 6 {
112152
return vec![chars.iter().collect()];
113153
}
114-
let mut wins = vec![chars.iter().collect()];
115-
for k in 3..=6 {
116-
for start in 0..=n - k {
117-
wins.push(chars[start..start + k].iter().collect());
154+
let mut wins: Vec<String> = Vec::new();
155+
if n <= WINDOW_WHOLE_MAX_CHARS {
156+
wins.push(chars.iter().collect());
157+
}
158+
// 首/尾两区的滑动窗(n ≤ 48 时头尾重叠——第二区置空即可全覆盖)
159+
let edge2 = WINDOW_EDGE_CHARS * 2;
160+
let regions: [(usize, usize); 2] = if n > edge2 {
161+
[(0, WINDOW_EDGE_CHARS), (n - WINDOW_EDGE_CHARS, WINDOW_EDGE_CHARS)]
162+
} else {
163+
[(0, n), (0, 0)]
164+
};
165+
for (base, len) in regions {
166+
if len == 0 {
167+
continue; // 第二区占位空(len=0 无窗)
168+
}
169+
for k in 3..=6 {
170+
for start in 0..=len.saturating_sub(k) {
171+
if wins.len() >= WINDOW_TERMS_CAP {
172+
return wins;
173+
}
174+
wins.push(chars[base + start..base + start + k].iter().collect());
175+
}
118176
}
119177
}
120178
wins

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

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,11 +76,35 @@ fn stopword_only_query_is_honestly_empty() {
7676
}
7777

7878
#[test]
79-
fn quote_chars_are_escaped_in_fts() {
80-
// Arrange/Act(防 FTS 语法注入——引号按 FTS5 双引号转义)
79+
fn quotes_are_stripped_before_engine_routing() {
80+
// 审查 M1:带引号 token 先剥引号再判档——"say \"hi\" there":say/there 进
81+
// fts;"hi"(剥后 2 字)转 LIKE,与不带引号行为一致(否则 trigram 零 token
82+
// 短语静默漏检)
8183
let p = plan_query("say \"hi\" there");
82-
// Assert(say/"hi"(4 字)/there 全进 fts;`"hi"` → `"""hi"""`)
83-
assert_eq!(p.fts.as_deref(), Some(r#""say" OR """hi""" OR "there""#));
84+
assert_eq!(p.fts.as_deref(), Some("\"say\" OR \"there\""));
85+
assert_eq!(p.like_terms, vec!["hi"]);
86+
assert!(p.highlight_terms.contains(&"hi".to_string()));
87+
}
88+
89+
#[test]
90+
fn fullwidth_quoted_two_char_cjk_routes_to_like() {
91+
// 审查 M1:全角引号 “配色” 剥后 2 字 → LIKE 通道(原实现按原文 4 字符
92+
// 进 FTS,实际 trigram 无法索引 2 字——静默零命中)
93+
let p = plan_query("\u{201C}配色\u{201D}");
94+
assert_eq!(p.fts, None);
95+
assert_eq!(p.like_terms, vec!["配色"]);
96+
}
97+
98+
#[test]
99+
fn long_segment_window_expansion_is_bounded() {
100+
// 审查 M3:>6 字段滑窗只取首/尾 24 字区且总量硬顶——任意长问句表达式有界
101+
let seg: String = "甲乙丙丁".repeat(40); // 160 字符
102+
let p = plan_query(&seg);
103+
let fts = p.fts.expect("fts 应有候选");
104+
let terms = fts.matches(" OR ").count() + 1;
105+
assert!(terms <= 96, "窗口膨胀必须有界(审查 M3): {} 词", terms);
106+
assert!(!fts.contains(&format!("\"{}\"", seg)), "超长段不整段 verbatim");
107+
assert!(fts.contains("\"甲乙丙\""), "头区滑窗保留: {}", fts);
84108
}
85109

86110
// ---------- RRF 融合 ----------

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
use rusqlite::{Connection, OptionalExtension};
1717

1818
use crate::db::unix_seconds;
19-
use crate::kb_chunk::{chunk_fragment, chunk_note};/// 派生索引格式版本(索引语义变更/embedding 接入时 +1——与 kb_meta 不符即
19+
use crate::kb_chunk::{chunk_fragment, chunk_note};
20+
21+
/// 派生索引格式版本(索引语义变更/embedding 接入时 +1——与 kb_meta 不符即
2022
/// 触发前端"索引待重建"角标;reindex_all 落当前值)。
2123
pub const KB_INDEX_VERSION: i64 = 1;
2224

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

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,13 @@ impl Db {
7171
Ok((note_ids, frag_ids))
7272
})?;
7373
let total = (note_ids.len() + frag_ids.len()) as u64;
74-
// ② 启动即清零失败计数 + 落格式版本(本次重建口径的诚实报告)
74+
// ② 启动:清零失败计数(本次重建口径)+ 整清两影子表——落实头注
75+
// "删除全部 kb_* → 全量重切重写";整表清同时物理消灭级联/失败路径
76+
// 遗留的 kb_fts 孤儿行(审查 M2:孤儿清扫原实现只能从现存 chunk 出发,
77+
// fts 引用已失 chunk 的行永不可见不可清——整清后无需孤儿清扫)
7578
self.with_conn(|conn| {
7679
reset_index_errors(conn);
77-
meta_set(conn, META_INDEX_VERSION, &KB_INDEX_VERSION.to_string())?;
80+
conn.execute_batch("DELETE FROM kb_fts; DELETE FROM kb_chunks;")?;
7881
Ok(())
7982
})?;
8083
let mut done = 0u64;
@@ -129,30 +132,12 @@ impl Db {
129132
done += 1;
130133
progress(done, total);
131134
}
132-
// ③ 孤儿清扫(源行已失的残留块——FK 级联只覆盖常规路径;极端写
133-
// 入口/历史遗留的孤儿在此物理清除——stats 脏源归零的必要条件)
134-
self.with_conn(|conn| {
135-
conn.execute(
136-
"DELETE FROM kb_fts WHERE chunk_id IN (SELECT c.id FROM kb_chunks c
137-
LEFT JOIN notes n ON n.id = c.note_id
138-
LEFT JOIN fragments f ON f.id = c.fragment_id
139-
WHERE (c.source_kind='note' AND n.id IS NULL)
140-
OR (c.source_kind='fragment' AND f.id IS NULL))",
141-
[],
142-
)?;
143-
conn.execute(
144-
"DELETE FROM kb_chunks WHERE id IN (SELECT c.id FROM kb_chunks c
145-
LEFT JOIN notes n ON n.id = c.note_id
146-
LEFT JOIN fragments f ON f.id = c.fragment_id
147-
WHERE (c.source_kind='note' AND n.id IS NULL)
148-
OR (c.source_kind='fragment' AND f.id IS NULL))",
149-
[],
150-
)?;
151-
Ok(())
152-
})?;
153-
// ④ 完成标记(失败也已落库计数——UI 角标如实;成功则 last_error 已清)
135+
// ④ 完成标记:格式版本与完成时间**同点落值**(审查 L3:版本先行会在
136+
// 中途终止时留下"版本已最新+零错误+旧完成时间"的自相矛盾状态——
137+
// 移到收尾,中断即保持旧版本 → 角标"待重建"如实)
154138
let reindex_at = crate::kb_index::now_seconds();
155139
let _ = self.with_conn(|conn| {
140+
meta_set(conn, META_INDEX_VERSION, &KB_INDEX_VERSION.to_string())?;
156141
meta_set(conn, META_REINDEX_ALL_AT, &reindex_at.to_string())?;
157142
Ok(())
158143
});

0 commit comments

Comments
 (0)