|
| 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