Skip to main content

kimun_notes/components/
semantic_search.rs

1//! Semantic search surface (P4): a server-backed [`RowSource`] that queries the
2//! RAG server for similar chunks and lists the matching notes. Lives behind a
3//! drawer view; only usable when a server is configured and reachable.
4
5use std::collections::HashSet;
6use std::sync::Arc;
7
8use async_trait::async_trait;
9use kimun_core::NoteVault;
10use kimun_core::nfs::VaultPath;
11use kimun_server_client::ChunkResult;
12
13use ratatui::Frame;
14use ratatui::layout::Rect;
15
16use crate::components::event_state::EventState;
17use crate::components::events::{AppEvent, AppTx, FileOp, InputEvent};
18use crate::components::file_list::FileListEntry;
19use crate::components::note_browser::format_journal_date;
20use crate::components::query_list_panel::{ListPanelSpec, QueryListPanel};
21use crate::components::search_list::{Emit, RowSource};
22use crate::rag::rag_client;
23use crate::settings::SharedSettings;
24use crate::settings::icons::Icons;
25use crate::settings::themes::Theme;
26
27/// One note row per unique note among the server's chunk results, in result
28/// order (best first), deduplicated by canonical path.
29fn chunks_to_entries(chunks: Vec<ChunkResult>, vault: &NoteVault) -> Vec<FileListEntry> {
30    let mut seen: HashSet<VaultPath> = HashSet::new();
31    let mut out = Vec::new();
32    for chunk in chunks {
33        let path = VaultPath::new(&chunk.path);
34        // Dedup by canonical identity; keep the first (highest-ranked) chunk.
35        if !seen.insert(path.flatten().absolute()) {
36            continue;
37        }
38        let filename = path.get_parent_path().1;
39        // The chunk title is the matched section's breadcrumb — informative for
40        // "which part matched".
41        let title = if chunk.title.trim().is_empty() {
42            "<no title>".to_string()
43        } else {
44            chunk.title
45        };
46        let journal_date = vault.journal_date(&path).map(format_journal_date);
47        out.push(FileListEntry::Note {
48            path,
49            title,
50            filename,
51            journal_date,
52            is_open: false,
53        });
54    }
55    out
56}
57
58/// Server-backed semantic search source.
59pub struct SemanticSource {
60    vault: Arc<NoteVault>,
61    settings: SharedSettings,
62}
63
64impl SemanticSource {
65    pub fn new(vault: Arc<NoteVault>, settings: SharedSettings) -> Self {
66        Self { vault, settings }
67    }
68}
69
70#[async_trait]
71impl RowSource<FileListEntry> for SemanticSource {
72    async fn load(&self, query: &str, emit: Emit<FileListEntry>) {
73        if query.trim().is_empty() {
74            emit.replace(Vec::new());
75            return;
76        }
77        // Debounce: the load engine aborts this task on the next keystroke, so a
78        // short leading delay coalesces rapid typing into a single server request
79        // (each query is one HTTP POST + a vault-id read).
80        tokio::time::sleep(std::time::Duration::from_millis(250)).await;
81        let entries = match rag_client(&self.settings, &self.vault).await {
82            Some(client) => match client.search(query, None).await {
83                Ok(chunks) => chunks_to_entries(chunks, &self.vault),
84                // Offline / server error → no rows (the surface shows empty; the
85                // status indicator tells the user the server is unreachable).
86                Err(e) => {
87                    log::debug!("semantic search failed: {e}");
88                    Vec::new()
89                }
90            },
91            None => Vec::new(),
92        };
93        emit.replace(entries);
94    }
95}
96
97/// Spec for the semantic drawer view: a query input over server-ranked note
98/// results; Enter/click opens the note, right-click opens the file-ops menu.
99pub struct SemanticSpec;
100
101impl ListPanelSpec for SemanticSpec {
102    type Row = FileListEntry;
103    const TITLE: &'static str = "Semantic";
104    const HAS_FILTER: bool = true;
105    // Server-backed query: draw a bordered search box separated from the results
106    // and a "Searching…" indicator while the request is in flight.
107    const BORDERED_INPUT: bool = true;
108    // The server already ranks/filters by the query; don't re-filter its
109    // semantic results locally by the literal query text (that discards
110    // conceptually-relevant notes whose titles lack the words).
111    const LOCAL_FILTER: bool = false;
112
113    fn submit(row: &FileListEntry, tx: &AppTx) {
114        if let FileListEntry::Note { path, .. } = row {
115            tx.send(AppEvent::open(path.clone())).ok();
116        }
117    }
118
119    fn context_event(row: &FileListEntry) -> Option<AppEvent> {
120        match row {
121            FileListEntry::Note { path, .. } => {
122                Some(AppEvent::FileOp(FileOp::ShowMenu(path.clone())))
123            }
124            _ => None,
125        }
126    }
127
128    fn hints() -> Vec<(String, String)> {
129        vec![("Enter".into(), "Open".into())]
130    }
131}
132
133/// The SEMANTIC drawer view: type a query, see the notes the RAG server ranks
134/// most similar. Only meaningful when a server is configured + reachable.
135pub struct SemanticPanel {
136    vault: Arc<NoteVault>,
137    settings: SharedSettings,
138    body: QueryListPanel<SemanticSpec>,
139    source_installed: bool,
140}
141
142impl SemanticPanel {
143    pub fn new(vault: Arc<NoteVault>, settings: SharedSettings, icons: Icons) -> Self {
144        Self {
145            vault,
146            settings,
147            body: QueryListPanel::new(icons),
148            source_installed: false,
149        }
150    }
151
152    /// Installs the server-backed source the first time the view is opened.
153    pub fn ensure_source(&mut self, tx: &AppTx) {
154        if !self.source_installed {
155            self.body.set_source(
156                SemanticSource::new(self.vault.clone(), self.settings.clone()),
157                tx,
158            );
159            self.source_installed = true;
160        }
161    }
162
163    pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
164        self.body.hint_shortcuts()
165    }
166
167    pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
168        self.body.handle_input(event, tx)
169    }
170
171    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
172        self.body.render(f, rect, theme, focused);
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use kimun_core::VaultConfig;
180    use tempfile::TempDir;
181
182    fn chunk(path: &str, title: &str, score: f64) -> ChunkResult {
183        ChunkResult {
184            path: path.to_string(),
185            title: title.to_string(),
186            date: None,
187            content: String::new(),
188            hash: String::new(),
189            similarity_score: score,
190        }
191    }
192
193    #[tokio::test]
194    async fn dedups_by_note_keeping_first_and_preserves_order() {
195        let dir = TempDir::new().unwrap();
196        let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
197
198        // Two chunks of "/a.md" (best first) then one of "/b.md".
199        let chunks = vec![
200            chunk("/a.md", "A > Intro", 0.9),
201            chunk("/a.md", "A > Details", 0.7),
202            chunk("/b.md", "B", 0.5),
203        ];
204        let entries = chunks_to_entries(chunks, &vault);
205        assert_eq!(entries.len(), 2);
206        match (&entries[0], &entries[1]) {
207            (
208                FileListEntry::Note {
209                    path: pa,
210                    title: ta,
211                    ..
212                },
213                FileListEntry::Note { path: pb, .. },
214            ) => {
215                assert_eq!(pa.to_string(), "/a.md");
216                assert_eq!(ta, "A > Intro"); // the higher-ranked chunk won
217                assert_eq!(pb.to_string(), "/b.md");
218            }
219            _ => panic!("expected note entries"),
220        }
221    }
222}