Skip to content

Commit cf41a46

Browse files
committed
feat(rag): v0.19.3 检索建议——发现路径建议制落库(REQ-261)
1 parent effa77c commit cf41a46

11 files changed

Lines changed: 722 additions & 3 deletions
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//! 检索建议命令层(REQ-261,v0.19.3;设计 §八/治理)。
2+
//!
3+
//! @ai-context: 发现路径入口——feature flag kb_discovery(默认关)在此把关
4+
//! (后端不信前端隐藏);命中本地检索为派生只读(恒可用引擎),
5+
//! 但**建议候选输出**受开关约束(ADR-029:建议制·默认关)。
6+
//! @ai-context: 确认落库不由本模块提供写路径——前端勾选后复用既有
7+
//! link_knowledge_target(白名单零迁移、幂等),本命令零双写。
8+
9+
use tauri::State;
10+
11+
use crate::commands::AppState;
12+
use crate::kb_discovery::DiscoveryResult;
13+
14+
/// 概念相关素材建议 + 跨体系相似提示(单次拉取;开关关 → 明确报错引导设置)。
15+
#[tauri::command]
16+
pub fn kb_discovery_suggest(
17+
state: State<'_, AppState>,
18+
concept_id: i64,
19+
) -> Result<DiscoveryResult, String> {
20+
if concept_id <= 0 {
21+
return Err("无效的概念 id".to_string());
22+
}
23+
let flags = state
24+
.feature_flags
25+
.lock()
26+
.map_err(|e| format!("功能开关锁中毒: {}", e))?;
27+
if !flags.kb_discovery {
28+
return Err("相关素材建议未开启(设置 → 学习库 → 相关素材建议开关;默认关——本地检索本身不受影响)".to_string());
29+
}
30+
drop(flags);
31+
state
32+
.db
33+
.kb_discovery_suggest(concept_id)
34+
.map_err(|e| e.to_string())?
35+
.ok_or_else(|| "概念不存在(可能已删除或不在当前体系)".to_string())
36+
}

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@ pub struct FeatureFlags {
1515
/// feed 碎片快速捕获(v0.11.1 默认关——Phase 2 验证前不默认启用;
1616
/// v0.12.2 转正后默认开——快速记录收件箱成为笔记页正式动线)
1717
pub feed_capture: bool,
18+
/// v0.19.3(REQ-261):检索建议(发现路径)——概念「相关素材建议」候选
19+
/// 与跨体系相似概念提示;默认关(ADR-029:建议制·默认关延续;本地命中
20+
/// 片段检索本身恒可用,不受本开关约束)
21+
pub kb_discovery: bool,
1822
}
1923

2024
impl Default for FeatureFlags {
2125
fn default() -> Self {
22-
Self { feed_capture: true }
26+
Self { feed_capture: true, kb_discovery: false }
2327
}
2428
}
2529

@@ -48,6 +52,7 @@ impl FeatureFlags {
4852
pub fn get(&self, name: &str) -> Option<bool> {
4953
match name {
5054
"feed_capture" => Some(self.feed_capture),
55+
"kb_discovery" => Some(self.kb_discovery),
5156
_ => None,
5257
}
5358
}
@@ -59,6 +64,10 @@ impl FeatureFlags {
5964
self.feed_capture = value;
6065
true
6166
}
67+
"kb_discovery" => {
68+
self.kb_discovery = value;
69+
true
70+
}
6271
_ => false,
6372
}
6473
}

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,15 @@ fn save_and_reload_persists() {
4444
let dir = std::env::temp_dir().join("ff_save_test");
4545
let _ = std::fs::create_dir_all(&dir);
4646
let path = dir.join("flags.json");
47-
let flags = FeatureFlags { feed_capture: true };
47+
let flags = FeatureFlags { feed_capture: true, kb_discovery: true };
4848
// Act
4949
flags.save(&path).expect("save");
5050
let loaded = FeatureFlags::load(&path);
5151
// Assert
5252
assert!(loaded.feed_capture);
53+
assert!(loaded.kb_discovery, "kb_discovery 开关 roundtrip 保真");
54+
// 旧 JSON 零迁移:缺失新字段回退默认关(保守默认纪律)
55+
let _ = std::fs::write(&path, r#"{"feedCapture":true}"#);
56+
let legacy = FeatureFlags::load(&path);
57+
assert!(!legacy.kb_discovery, "旧配置缺失字段 = 默认关");
5358
}

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

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
//! 检索建议·发现路径数据层(REQ-261,v0.19.3;设计 §八)。
2+
//!
3+
//! @ai-context: 读路径 B——概念「相关素材建议」:以概念名+essence 为查询做
4+
//! 全库混合检索 top-K(≤10),**排除已链接 target**(该概念
5+
//! knowledge_links 已引用的 note/fragment 不重复建议);跨体系
6+
//! 相似概念为提示型(只展示"概念 X 与体系 Y 的 Z 高度相似",
7+
//! 不自动合并——合并命令不存在且语义归人,YAGNI)。
8+
//! @ai-context: 铁律:建议不落库、零双写——确认后仅由命令层经既有
9+
//! link_knowledge_target 引用通道落库(target_type 白名单
10+
//! note/fragment 已有,零迁移);feature flag kb_discovery
11+
//! (默认关)在命令层把关(后端不信前端隐藏)。本层只做只读
12+
//! 编排 + 纯函数。
13+
14+
use std::collections::HashSet;
15+
16+
use rusqlite::OptionalExtension;
17+
use serde::Serialize;
18+
19+
use crate::db::Db;
20+
use crate::error::Result;
21+
use crate::kb_search::KbHit;
22+
23+
/// 证据候选上限(设计 top-K ≤10)。
24+
pub const DISCOVERY_LIMIT: usize = 10;
25+
/// 相似概念提示上限(提示型——控制噪音)。
26+
pub const SIMILAR_LIMIT: usize = 6;
27+
28+
/// 跨体系相似概念提示(展示型:人工处置,不自动合并)。
29+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30+
#[serde(rename_all = "camelCase")]
31+
pub struct SimilarHint {
32+
pub system_id: i64,
33+
pub system_name: String,
34+
pub concept_id: i64,
35+
pub concept_name: String,
36+
/// 命中理由(overlap 口径——"名称互相包含")
37+
pub reason: String,
38+
}
39+
40+
/// 发现结果(前端概念详情区一次性拉取)。
41+
#[derive(Debug, Clone, PartialEq, Serialize)]
42+
#[serde(rename_all = "camelCase")]
43+
pub struct DiscoveryResult {
44+
/// 证据候选(KbHit 契约复用——snippet/来源字段与引用卡片一致)
45+
pub evidence: Vec<KbHit>,
46+
/// 相似概念提示(跨体系;无 → 空)
47+
pub similar: Vec<SimilarHint>,
48+
}
49+
50+
impl Db {
51+
/// 概念发现建议(只读编排;概念不存在 → None——命令层映射"概念不存在")。
52+
///
53+
/// @ai-context: 检索只读派生索引 kb_*;排除集 = 该概念在 knowledge_links
54+
/// 已引用的 note/fragment target(引用通道唯一入口且概念链
55+
/// 只落在自身体系内,无需 system 维度二次过滤)。
56+
pub fn kb_discovery_suggest(&self, concept_id: i64) -> Result<Option<DiscoveryResult>> {
57+
// ① 概念取回(查询面 name/essence + 排除集归属体系)
58+
let concept = self.with_conn(|conn| {
59+
Ok(conn
60+
.query_row(
61+
"SELECT system_id, name, essence FROM knowledge_concepts WHERE id=?1",
62+
[concept_id],
63+
|r| {
64+
Ok((
65+
r.get::<_, i64>(0)?,
66+
r.get::<_, String>(1)?,
67+
r.get::<_, Option<String>>(2)?,
68+
))
69+
},
70+
)
71+
.optional()?)
72+
})?;
73+
let Some((system_id, name, essence)) = concept else {
74+
return Ok(None);
75+
};
76+
// ② 排除集:本概念已链接的 note/fragment(target_type,target_id)
77+
let links = self.list_knowledge_links(system_id, None, Some(concept_id), None)?;
78+
let excluded: HashSet<(String, i64)> = links
79+
.into_iter()
80+
.filter(|l| l.target_type == "note" || l.target_type == "fragment")
81+
.map(|l| (l.target_type, l.target_id))
82+
.collect();
83+
// ③ 证据候选:name + essence 混合检索 → 排除已链接 → 截顶
84+
let mut query = name.clone();
85+
if let Some(e) = essence.as_deref().map(str::trim).filter(|e| !e.is_empty()) {
86+
query.push(' ');
87+
query.push_str(e);
88+
}
89+
let hits = self.kb_search(&query, DISCOVERY_LIMIT)?;
90+
let evidence: Vec<KbHit> = hits
91+
.into_iter()
92+
.filter(|h| {
93+
let id = match h.source_kind.as_str() {
94+
"note" => h.note_id,
95+
"fragment" => h.fragment_id,
96+
_ => None,
97+
};
98+
!id.is_some_and(|tid| excluded.contains(&(h.source_kind.clone(), tid)))
99+
})
100+
.take(DISCOVERY_LIMIT)
101+
.collect();
102+
// ④ 相似概念提示:全库概念(跨体系)与本品归一化名重叠 → 提示型
103+
let similar = self.similar_concepts_of(system_id, concept_id, &name)?;
104+
Ok(Some(DiscoveryResult { evidence, similar }))
105+
}
106+
107+
/// 跨体系相似概念(排除自身体系与自身;按重叠口径 ≤ SIMILAR_LIMIT)。
108+
///
109+
/// @ai-context: 提示型仅供人眼判读(不落库不建链)——口径宽松(归一化
110+
/// 后互相包含),宁可多示一人裁,不静默漏检交叉点。
111+
fn similar_concepts_of(
112+
&self,
113+
system_id: i64,
114+
concept_id: i64,
115+
name: &str,
116+
) -> Result<Vec<SimilarHint>> {
117+
let concepts = self.list_knowledge_concepts(None, None)?;
118+
if concepts.is_empty() {
119+
return Ok(Vec::new());
120+
}
121+
let systems = self.list_knowledge_systems()?;
122+
let name_map: std::collections::HashMap<i64, String> = systems
123+
.into_iter()
124+
.map(|s| (s.id, s.name.clone()))
125+
.collect();
126+
let norm = normalize_name(name);
127+
if norm.is_empty() {
128+
return Ok(Vec::new());
129+
}
130+
let mut hints: Vec<SimilarHint> = concepts
131+
.into_iter()
132+
.filter(|c| c.id != concept_id && c.system_id != system_id)
133+
.filter(|c| concept_name_overlap(&norm, &normalize_name(&c.name)))
134+
.map(|c| SimilarHint {
135+
system_id: c.system_id,
136+
system_name: name_map
137+
.get(&c.system_id)
138+
.cloned()
139+
.unwrap_or_else(|| format!("体系 #{}", c.system_id)),
140+
concept_id: c.id,
141+
concept_name: c.name,
142+
reason: "名称高度相似(互相包含)".to_string(),
143+
})
144+
.take(SIMILAR_LIMIT)
145+
.collect();
146+
hints.sort_by(|a, b| a.system_id.cmp(&b.system_id).then(a.concept_id.cmp(&b.concept_id)));
147+
Ok(hints)
148+
}
149+
}
150+
151+
/// 名称归一化(相似判定的公共口径):空白/全角/大小写折叠。
152+
///
153+
/// @ai-context: 判定"高相似"用**双向包含**而非相等——"晕染手法"与
154+
/// "眼影晕染手法"互为包含即提示;单字名不参与(噪音)。
155+
pub fn normalize_name(name: &str) -> String {
156+
name.chars()
157+
.filter(|c| !c.is_whitespace())
158+
.map(|c| match c {
159+
'\u{FF01}'..='\u{FF5E}' => char::from_u32(c as u32 - 0xFEE0).unwrap_or(c),
160+
_ => c,
161+
})
162+
.flat_map(|c| c.to_lowercase())
163+
.collect()
164+
}
165+
166+
/// 双向包含重叠(短边 ≥2 字才参与——防单字误报;归一化后输入)。
167+
pub fn concept_name_overlap(a: &str, b: &str) -> bool {
168+
let (short, long) = if a.chars().count() <= b.chars().count() { (a, b) } else { (b, a) };
169+
short.chars().count() >= 2 && long.contains(short)
170+
}
171+
172+
#[cfg(test)]
173+
#[path = "kb_discovery_tests.rs"]
174+
mod tests;

0 commit comments

Comments
 (0)