Skip to main content

kimun_notes/ask/
mod.rs

1pub mod citations;
2pub mod locate;
3pub mod save;
4
5use crate::server_client::dto::ChunkResult;
6use kimun_core::nfs::VaultPath;
7use kimun_core::note::BREADCRUMB_SEP;
8
9/// How many trailing `Done` turns feed conversation history sent to the server.
10const HISTORY_WINDOW: usize = 5;
11
12/// Human-readable joiner for a chunk's nested heading segments, replacing core's
13/// control-char [`BREADCRUMB_SEP`] for display (`"Chapter › Section"`).
14const HEADING_JOINER: &str = " \u{203a} ";
15
16/// A single retrieved chunk backing an answer — one row of a **Turn**'s
17/// sources, shown in CONTEXT.md's **Sources view** / **Source reader**.
18#[derive(Debug, Clone)]
19pub struct AskSource {
20    pub path: VaultPath,
21    pub heading: String,
22    /// The chunk's journal date (`YYYY-MM-DD`), when the note is a dated journal
23    /// entry — carried separately from `heading` so the two render as distinct,
24    /// spaced elements. `None` for a non-journal note.
25    pub date: Option<String>,
26    pub score: f64,
27    pub text: String,
28    /// The 1-based `[n]` citation number this source answers to — the explicit
29    /// pairing every citation lookup keys on, never vec position. Normalized to
30    /// be non-zero at construction (see [`AskSource::from_chunk`]), so no
31    /// downstream lookup ever sees the wire's `0` "absent" sentinel.
32    pub ordinal: usize,
33}
34
35impl AskSource {
36    /// Build from a wire chunk at 0-based `position` in its list. The ordinal is
37    /// normalized ONCE, here: a server that sends it wins; a `0` (older server
38    /// that omits the field) falls back to `position + 1`, which reproduces the
39    /// old vec-order convention. Every consumer downstream sees a real ordinal.
40    ///
41    /// For a journal chunk the wire `title` arrives date-prefixed (e.g.
42    /// `2026-04-08Afternoon` — the date stem glued to the section heading with
43    /// no separator). We strip that prefix here, keeping `date` and `heading`
44    /// separate, mirroring the prompt builder's title handling
45    /// (`llmclients::build_prompt`) so both surfaces agree.
46    ///
47    /// A nested-section chunk's title is core's breadcrumb — the heading path
48    /// joined with the control-char [`BREADCRUMB_SEP`] (`"Chapter\x1fSection"`).
49    /// We split it and re-join with a readable `HEADING_JOINER` so the raw
50    /// separator never reaches a Sources row or the reader title; the innermost
51    /// segment (which drives section matching in `locate`) is recovered by
52    /// [`AskSource::match_heading`].
53    pub fn from_chunk(position: usize, c: ChunkResult) -> Self {
54        let stripped = strip_date_prefix(&c.title, c.date.as_deref());
55        let heading = stripped
56            .split(BREADCRUMB_SEP)
57            .filter(|s| !s.is_empty())
58            .collect::<Vec<_>>()
59            .join(HEADING_JOINER);
60        Self {
61            path: VaultPath::new(&c.path),
62            heading,
63            date: c.date,
64            score: c.similarity_score,
65            text: c.content,
66            ordinal: if c.ordinal == 0 {
67                position + 1
68            } else {
69                c.ordinal
70            },
71        }
72    }
73
74    /// The innermost heading segment — the one that identifies the retrieved
75    /// section within its note, for `locate::section_range`. `heading` is the
76    /// full breadcrumb rejoined with `HEADING_JOINER` for display, so section
77    /// matching takes only the last segment (`"Chapter › Section"` → `"Section"`).
78    pub fn match_heading(&self) -> &str {
79        self.heading
80            .rsplit(HEADING_JOINER)
81            .next()
82            .unwrap_or(&self.heading)
83    }
84
85    /// The heading with its date rejoined for a single-line title (the source
86    /// reader's panel title): `"2026-04-08 · Afternoon"` for a journal entry,
87    /// the bare heading otherwise.
88    pub fn display_heading(&self) -> String {
89        match &self.date {
90            Some(date) if !self.heading.is_empty() => format!("{date} · {}", self.heading),
91            Some(date) => date.clone(),
92            None => self.heading.clone(),
93        }
94    }
95}
96
97/// Strip a leading journal `date` prefix from a wire `title`, mirroring the
98/// server prompt builder's logic (`llmclients::build_prompt`): trim, drop the
99/// date prefix if present, trim again. With no date, or a title that doesn't
100/// start with it, the (trimmed) title is returned unchanged.
101fn strip_date_prefix(title: &str, date: Option<&str>) -> String {
102    let trimmed = title.trim();
103    match date {
104        Some(date) => trimmed
105            .strip_prefix(date)
106            .map(|rest| rest.trim().to_string())
107            .unwrap_or_else(|| trimmed.to_string()),
108        None => trimmed.to_string(),
109    }
110}
111
112/// A turn's lifecycle. `Streaming` is reserved for a future streaming feature
113/// and is never constructed in v1.
114#[allow(dead_code)]
115pub enum TurnStatus {
116    Thinking,
117    Streaming,
118    Done,
119    Error(String),
120}
121
122/// One question/answer exchange (CONTEXT.md: **Turn**). Always knows its own sources.
123pub struct Turn {
124    pub id: u64,
125    pub question: String,
126    pub answer: String,
127    pub sources: Vec<AskSource>,
128    pub status: TurnStatus,
129}
130
131impl Turn {
132    /// Resolve a `[n]` citation to the source it addresses — matched by the
133    /// source's `ordinal`, NOT its position in `sources`. This is the single
134    /// seam every citation lookup goes through, so the pairing survives any
135    /// reorder of the sources vec. `None` for a citation with no matching
136    /// source (a gap — e.g. the model cited `[2]` but ordinal 2 was dropped).
137    pub fn source_for_citation(&self, n: usize) -> Option<&AskSource> {
138        self.sources.iter().find(|s| s.ordinal == n)
139    }
140}
141
142/// The running ask conversation (CONTEXT.md: **Thread**): an ordered list of
143/// turns plus which one is selected for viewing.
144#[derive(Default)]
145pub struct Thread {
146    turns: Vec<Turn>,
147    next_id: u64,
148    selected: usize,
149}
150
151impl Thread {
152    /// Append a new `Thinking` turn for `question`, select it, and return its id.
153    pub fn ask(&mut self, question: String) -> u64 {
154        let id = self.bump();
155        self.turns.push(Turn {
156            id,
157            question,
158            answer: String::new(),
159            sources: vec![],
160            status: TurnStatus::Thinking,
161        });
162        self.selected = self.turns.len() - 1;
163        id
164    }
165
166    /// Resolve a `Thinking` turn into `Done`. Returns `false` (no-op) for an
167    /// unknown id or a turn that isn't currently `Thinking` (stale completion).
168    pub fn complete(&mut self, id: u64, answer: String, sources: Vec<AskSource>) -> bool {
169        let Some(turn) = self.thinking_turn_mut(id) else {
170            return false;
171        };
172        turn.answer = answer;
173        turn.sources = sources;
174        turn.status = TurnStatus::Done;
175        true
176    }
177
178    /// Resolve a `Thinking` turn into `Error`. Same stale-completion rules as `complete`.
179    pub fn fail(&mut self, id: u64, error: String) -> bool {
180        let Some(turn) = self.thinking_turn_mut(id) else {
181            return false;
182        };
183        turn.status = TurnStatus::Error(error);
184        true
185    }
186
187    /// Rewind a `Done`/`Error` turn back to `Thinking`, keeping its sources, and
188    /// return its question so the caller can re-issue the request.
189    pub fn regenerate(&mut self, id: u64) -> Option<String> {
190        let turn = self.turns.iter_mut().find(|t| t.id == id)?;
191        if matches!(turn.status, TurnStatus::Thinking | TurnStatus::Streaming) {
192            return None;
193        }
194        turn.status = TurnStatus::Thinking;
195        Some(turn.question.clone())
196    }
197
198    /// The last `HISTORY_WINDOW` `Done` turns before the newest in-flight
199    /// (`Thinking`/`Streaming`) turn, as `(question, answer)` pairs with
200    /// citation markers stripped.
201    pub fn history(&self) -> Vec<(String, String)> {
202        let boundary = self
203            .turns
204            .iter()
205            .rposition(|t| matches!(t.status, TurnStatus::Thinking | TurnStatus::Streaming))
206            .unwrap_or(self.turns.len());
207        // Want the LAST `HISTORY_WINDOW` Dones, not the first: `.rev()` needs
208        // a `DoubleEndedIterator`, which `Filter` only gets because the slice
209        // `.iter()` underneath it is one. `.rev().take(N)` then walks from
210        // the end to grab those last N (in reverse order); the explicit
211        // `.reverse()` below restores chronological order.
212        let mut done: Vec<_> = self.turns[..boundary]
213            .iter()
214            .filter(|t| matches!(t.status, TurnStatus::Done))
215            .rev()
216            .take(HISTORY_WINDOW)
217            .collect();
218        done.reverse();
219        done.into_iter()
220            .map(|t| (t.question.clone(), citations::strip(&t.answer)))
221            .collect()
222    }
223
224    /// The currently selected turn, if any.
225    pub fn selected(&self) -> Option<&Turn> {
226        self.turns.get(self.selected)
227    }
228
229    /// Move the selection to the previous (older) turn, if any.
230    pub fn select_prev(&mut self) {
231        self.selected = self.selected.saturating_sub(1);
232    }
233
234    /// Move the selection to the next (newer) turn, if any.
235    pub fn select_next(&mut self) {
236        if self.selected + 1 < self.turns.len() {
237            self.selected += 1;
238        }
239    }
240
241    /// Select the most recent turn.
242    pub fn select_last(&mut self) {
243        self.selected = self.turns.len().saturating_sub(1);
244    }
245
246    /// Select the turn at `idx` directly, clamped to the valid range.
247    /// No-op on an empty thread — there is no turn to select.
248    pub fn select_index(&mut self, idx: usize) {
249        if self.turns.is_empty() {
250            return;
251        }
252        self.selected = idx.min(self.turns.len() - 1);
253    }
254
255    /// Drop all turns, resetting the thread.
256    pub fn clear(&mut self) {
257        self.turns.clear();
258        self.selected = 0;
259    }
260
261    /// All turns, oldest first.
262    pub fn turns(&self) -> &[Turn] {
263        &self.turns
264    }
265
266    /// Whether the thread has no turns.
267    pub fn is_empty(&self) -> bool {
268        self.turns.is_empty()
269    }
270
271    fn bump(&mut self) -> u64 {
272        let id = self.next_id;
273        self.next_id += 1;
274        id
275    }
276
277    fn thinking_turn_mut(&mut self, id: u64) -> Option<&mut Turn> {
278        self.turns
279            .iter_mut()
280            .find(|t| t.id == id && matches!(t.status, TurnStatus::Thinking))
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::server_client::dto::ChunkResult;
288
289    fn ask_source(path: &str, ordinal: usize) -> AskSource {
290        AskSource {
291            path: VaultPath::new(path),
292            heading: "h".into(),
293            date: None,
294            score: 1.0,
295            text: String::new(),
296            ordinal,
297        }
298    }
299
300    fn turn_with_sources(sources: Vec<AskSource>) -> Turn {
301        Turn {
302            id: 0,
303            question: "q".into(),
304            answer: String::new(),
305            sources,
306            status: TurnStatus::Done,
307        }
308    }
309
310    #[test]
311    fn source_for_citation_matches_by_ordinal_not_position() {
312        // Sources deliberately shuffled: vec position 0 holds ordinal 3.
313        let turn = turn_with_sources(vec![
314            ask_source("c.md", 3),
315            ask_source("a.md", 1),
316            ask_source("b.md", 2),
317        ]);
318        // `[1]` resolves to the ordinal-1 source, wherever it sits in the vec.
319        assert_eq!(
320            turn.source_for_citation(1).unwrap().path.to_string(),
321            "a.md"
322        );
323        assert_eq!(
324            turn.source_for_citation(2).unwrap().path.to_string(),
325            "b.md"
326        );
327        assert_eq!(
328            turn.source_for_citation(3).unwrap().path.to_string(),
329            "c.md"
330        );
331    }
332
333    #[test]
334    fn source_for_citation_returns_none_for_a_gap() {
335        // Ordinal 2 was dropped: a `[2]` citation has no source to resolve to.
336        let turn = turn_with_sources(vec![ask_source("a.md", 1), ask_source("c.md", 3)]);
337        assert!(turn.source_for_citation(2).is_none());
338    }
339
340    #[test]
341    fn from_chunk_falls_back_to_position_when_ordinal_absent() {
342        let wire = ChunkResult {
343            path: "a.md".into(),
344            title: "t".into(),
345            date: None,
346            content: String::new(),
347            hash: String::new(),
348            similarity_score: 0.9,
349            ordinal: 0, // older server: field absent → 0
350        };
351        // 0-based position 4 → 1-based ordinal 5.
352        assert_eq!(AskSource::from_chunk(4, wire).ordinal, 5);
353    }
354
355    #[test]
356    fn from_chunk_honors_a_server_assigned_ordinal() {
357        let wire = ChunkResult {
358            path: "a.md".into(),
359            title: "t".into(),
360            date: None,
361            content: String::new(),
362            hash: String::new(),
363            similarity_score: 0.9,
364            ordinal: 7,
365        };
366        // Server ordinal wins over position.
367        assert_eq!(AskSource::from_chunk(0, wire).ordinal, 7);
368    }
369
370    #[test]
371    fn from_chunk_splits_a_date_prefixed_journal_title() {
372        // The server glues the date stem onto the section heading with no
373        // separator ("2026-04-08Afternoon"); date and heading must come apart.
374        let wire = ChunkResult {
375            path: "journal/2026-04-08.md".into(),
376            title: "2026-04-08Afternoon".into(),
377            date: Some("2026-04-08".into()),
378            content: String::new(),
379            hash: String::new(),
380            similarity_score: 0.9,
381            ordinal: 1,
382        };
383        let src = AskSource::from_chunk(0, wire);
384        assert_eq!(src.heading, "Afternoon");
385        assert_eq!(src.date.as_deref(), Some("2026-04-08"));
386        assert_eq!(src.display_heading(), "2026-04-08 · Afternoon");
387    }
388
389    #[test]
390    fn from_chunk_renders_a_nested_breadcrumb_title_readably() {
391        // A nested-section chunk's title is core's breadcrumb, joined with the
392        // control-char separator; it must render as "Chapter › Section" and the
393        // raw U+001F must never survive.
394        let wire = ChunkResult {
395            path: "notes/book.md".into(),
396            title: format!("Chapter{}Section", kimun_core::note::BREADCRUMB_SEP),
397            date: None,
398            content: String::new(),
399            hash: String::new(),
400            similarity_score: 0.5,
401            ordinal: 1,
402        };
403        let src = AskSource::from_chunk(0, wire);
404        assert_eq!(src.heading, "Chapter \u{203a} Section");
405        assert!(!src.heading.contains('\u{1f}'), "no control char leaks");
406        assert_eq!(src.display_heading(), "Chapter \u{203a} Section");
407        // Section matching keys on the innermost segment only.
408        assert_eq!(src.match_heading(), "Section");
409    }
410
411    #[test]
412    fn nested_source_locates_via_the_innermost_heading() {
413        use crate::ask::locate;
414        // A note where the retrieved section sits under a nested heading. The
415        // chunk text is absent verbatim, so resolution falls through to the
416        // innermost-heading match — proving `match_heading` feeds `locate`.
417        let wire = ChunkResult {
418            path: "notes/book.md".into(),
419            title: format!("Chapter{}Section", kimun_core::note::BREADCRUMB_SEP),
420            date: None,
421            content: "normalized, not verbatim".into(),
422            hash: String::new(),
423            similarity_score: 0.5,
424            ordinal: 1,
425        };
426        let src = AskSource::from_chunk(0, wire);
427        let note = "# Chapter\nintro\n## Section\nthe real body\n";
428        let r = locate::section_range(note, src.match_heading(), &src.text).unwrap();
429        assert!(note[r].contains("the real body"));
430    }
431
432    #[test]
433    fn from_chunk_leaves_a_non_journal_title_unchanged() {
434        let wire = ChunkResult {
435            path: "notes/ideas.md".into(),
436            title: "Project Ideas".into(),
437            date: None,
438            content: String::new(),
439            hash: String::new(),
440            similarity_score: 0.5,
441            ordinal: 1,
442        };
443        let src = AskSource::from_chunk(0, wire);
444        assert_eq!(src.heading, "Project Ideas");
445        assert_eq!(src.date, None);
446        assert_eq!(src.display_heading(), "Project Ideas");
447    }
448
449    fn done(thread: &mut Thread, q: &str, a: &str) {
450        let id = thread.ask(q.to_string());
451        assert!(thread.complete(id, a.to_string(), vec![]));
452    }
453
454    #[test]
455    fn ask_appends_a_thinking_turn_and_selects_it() {
456        let mut t = Thread::default();
457        let id = t.ask("q?".into());
458        assert_eq!(t.turns().len(), 1);
459        assert!(matches!(t.selected().unwrap().status, TurnStatus::Thinking));
460        assert_eq!(t.selected().unwrap().id, id);
461    }
462
463    #[test]
464    fn history_takes_last_five_done_turns_and_strips_citations() {
465        let mut t = Thread::default();
466        for i in 0..7 {
467            done(&mut t, &format!("q{i}"), &format!("a{i} [1]"));
468        }
469        t.ask("new".into()); // the in-flight turn history is built for
470        let h = t.history();
471        assert_eq!(h.len(), 5);
472        assert_eq!(h[0].0, "q2");
473        assert_eq!(h[4].1, "a6"); // "[1]" stripped
474    }
475
476    #[test]
477    fn stale_completion_is_dropped() {
478        let mut t = Thread::default();
479        let id = t.ask("q".into());
480        t.clear();
481        assert!(!t.complete(id, "late".into(), vec![]));
482        assert!(t.is_empty());
483    }
484
485    #[test]
486    fn stale_fail_is_dropped() {
487        let mut t = Thread::default();
488        let id = t.ask("q".into());
489        t.clear();
490        assert!(!t.fail(id, "late error".into()));
491        assert!(t.is_empty());
492    }
493
494    #[test]
495    fn history_skips_error_turns_but_keeps_the_dones_around_them() {
496        let mut t = Thread::default();
497        done(&mut t, "q0", "a0");
498        let err_id = t.ask("q1".into());
499        t.fail(err_id, "boom".into());
500        done(&mut t, "q2", "a2");
501        let h = t.history();
502        assert_eq!(h.len(), 2, "the Error turn itself is not in history");
503        assert_eq!(h[0].0, "q0");
504        assert_eq!(h[1].0, "q2");
505    }
506
507    #[test]
508    fn regenerate_returns_none_for_unknown_id_or_a_thinking_turn() {
509        let mut t = Thread::default();
510        assert!(t.regenerate(999).is_none(), "unknown id");
511        let id = t.ask("q".into()); // still Thinking
512        assert!(
513            t.regenerate(id).is_none(),
514            "in-flight turn can't regenerate"
515        );
516    }
517
518    #[test]
519    fn select_prev_and_select_next_clamp_at_the_ends() {
520        let mut t = Thread::default();
521        done(&mut t, "q0", "a0");
522        done(&mut t, "q1", "a1"); // selected == q1
523
524        t.select_prev();
525        assert_eq!(t.selected().unwrap().question, "q0");
526        t.select_prev(); // already at 0: clamp, no panic
527        assert_eq!(t.selected().unwrap().question, "q0");
528
529        t.select_next();
530        assert_eq!(t.selected().unwrap().question, "q1");
531        t.select_next(); // already at the end: clamp
532        assert_eq!(t.selected().unwrap().question, "q1");
533    }
534
535    #[test]
536    fn select_index_clamps_to_valid_range_and_noops_on_empty() {
537        let mut t = Thread::default();
538        t.select_index(3); // empty thread: no-op, no panic
539        assert!(t.selected().is_none());
540
541        done(&mut t, "q0", "a0");
542        done(&mut t, "q1", "a1");
543        done(&mut t, "q2", "a2");
544        t.select_index(1);
545        assert_eq!(t.selected().unwrap().question, "q1");
546        t.select_index(100);
547        assert_eq!(
548            t.selected().unwrap().question,
549            "q2",
550            "clamps to the last turn"
551        );
552    }
553
554    #[test]
555    fn regenerate_rewinds_a_done_turn_keeping_sources() {
556        let mut t = Thread::default();
557        let id = t.ask("q".into());
558        let src = AskSource {
559            path: kimun_core::nfs::VaultPath::new("a.md"),
560            heading: "h".into(),
561            date: None,
562            score: 0.9,
563            text: "body".into(),
564            ordinal: 1,
565        };
566        t.complete(id, "a".into(), vec![src]);
567        assert_eq!(t.regenerate(id).as_deref(), Some("q"));
568        let turn = t.selected().unwrap();
569        assert!(matches!(turn.status, TurnStatus::Thinking));
570        assert_eq!(turn.sources.len(), 1, "regenerate reuses the same sources");
571    }
572}