Skip to main content

kimun_notes/ask/
save.rs

1//! Turning an ask [`Turn`] into a saved vault note
2//! (CONTEXT.md: **Saved answer**). The question becomes the note title, its
3//! citation markers become wikilinks so the note joins the vault link graph.
4
5use kimun_core::nfs::VaultPath;
6use kimun_core::nfs::filename::note_name_from_title;
7
8use super::{Turn, citations};
9
10/// The default path offered when saving `question` as a note: `ask/<slug>.md`.
11pub fn suggested_path(question: &str) -> VaultPath {
12    VaultPath::new("ask").append(&VaultPath::note_path_from(note_name_from_title(question)))
13}
14
15/// The clean source names addressed by **citation number**: `names[n - 1]` is
16/// the clean name of the source whose ordinal is `n`, so `link_sources` can
17/// rewrite `[n]` → `[[name]]` by the same explicit pairing the rest of Ask
18/// uses. The vec is sized to the largest ordinal; a citation number with no
19/// backing source (a gap) gets an empty-string sentinel `link_sources` treats
20/// as out-of-range, leaving that `[n]` untouched. This is the ONE place the
21/// ordinal→name mapping is built for saving — never `sources[n - 1]`.
22pub fn citation_names(turn: &Turn) -> Vec<String> {
23    let max = turn.sources.iter().map(|s| s.ordinal).max().unwrap_or(0);
24    let mut names = vec![String::new(); max];
25    for s in &turn.sources {
26        if (1..=max).contains(&s.ordinal) {
27            names[s.ordinal - 1] = s.path.get_clean_name();
28        }
29    }
30    names
31}
32
33/// Renders `turn` as note content: the question as an `# ` title, the answer
34/// with citation markers converted to `[[source]]` wikilinks, and a
35/// `## Sources` footer.
36///
37/// The footer lists every one of `turn.sources` (deduped by clean name, in
38/// source/rank order), not just the ones the answer actually cited: a source
39/// the model retrieved but under-cited was still part of the turn's evidence,
40/// and provenance must not be silently dropped (CONTEXT.md: **Saved
41/// answer** — "backlinks from the sources find it").
42pub fn note_content(turn: &Turn) -> String {
43    // Citations resolve by ordinal (the pairing contract); the footer lists the
44    // sources in vec/rank order.
45    let linked = citations::link_sources(&turn.answer, &citation_names(turn));
46
47    let mut seen = Vec::new();
48    for s in &turn.sources {
49        let name = s.path.get_clean_name();
50        if !seen.contains(&name) {
51            seen.push(name);
52        }
53    }
54
55    let mut out = format!("# {}\n\n{}\n\n## Sources\n", turn.question, linked);
56    for name in &seen {
57        out.push_str(&format!("- [[{name}]]\n"));
58    }
59    out
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65    use crate::ask::{AskSource, TurnStatus};
66
67    fn turn_with(question: &str, answer: &str, sources: Vec<AskSource>) -> Turn {
68        // Mirror `AskSource::from_chunk`'s fallback: a fixture source left at
69        // ordinal 0 gets its 1-based vec position, so the common case reads as
70        // the old position convention while ordinal-explicit tests stay exact.
71        let sources = sources
72            .into_iter()
73            .enumerate()
74            .map(|(i, mut s)| {
75                if s.ordinal == 0 {
76                    s.ordinal = i + 1;
77                }
78                s
79            })
80            .collect();
81        Turn {
82            id: 0,
83            question: question.to_string(),
84            answer: answer.to_string(),
85            sources,
86            status: TurnStatus::Done,
87        }
88    }
89
90    fn source(path: &str, heading: &str) -> AskSource {
91        source_ord(path, heading, 0)
92    }
93
94    /// Build a source pinned to an explicit citation `ordinal` — for the pairing
95    /// tests that need ordinals out of vec order or with gaps.
96    fn source_ord(path: &str, heading: &str, ordinal: usize) -> AskSource {
97        AskSource {
98            path: VaultPath::new(path),
99            heading: heading.to_string(),
100            date: None,
101            score: 1.0,
102            text: String::new(),
103            ordinal,
104        }
105    }
106
107    #[test]
108    fn note_content_links_citations_and_lists_sources() {
109        let turn = turn_with(
110            "Why kimün?",
111            "Because notes [1]. And general knowledge.",
112            vec![source("projects/kimun.md", "intro")],
113        );
114        let body = note_content(&turn);
115        assert!(body.starts_with("# Why kimün?\n"));
116        assert!(body.contains("Because notes [[kimun]]."));
117        assert!(body.contains("## Sources"));
118        assert!(body.contains("- [[kimun]]"));
119    }
120
121    #[test]
122    fn note_content_lists_distinct_sources_in_first_seen_order() {
123        let turn = turn_with(
124            "q",
125            "a [1] b [2] c [1]",
126            vec![
127                source("projects/alpha.md", "h1"),
128                source("projects/beta.md", "h2"),
129            ],
130        );
131        let body = note_content(&turn);
132        let sources_section = body.split("## Sources").nth(1).unwrap();
133        let alpha_pos = sources_section.find("[[alpha]]").unwrap();
134        let beta_pos = sources_section.find("[[beta]]").unwrap();
135        assert!(alpha_pos < beta_pos);
136        assert_eq!(sources_section.matches("[[alpha]]").count(), 1);
137    }
138
139    #[test]
140    fn note_content_lists_uncited_sources_too() {
141        let turn = turn_with(
142            "q",
143            "only cites the first source [1]",
144            vec![
145                source("projects/alpha.md", "h1"),
146                source("projects/beta.md", "h2"),
147            ],
148        );
149        let body = note_content(&turn);
150        let sources_section = body.split("## Sources").nth(1).unwrap();
151        assert!(sources_section.contains("[[alpha]]"));
152        assert!(
153            sources_section.contains("[[beta]]"),
154            "an uncited source must still appear in the footer: {sources_section:?}"
155        );
156    }
157
158    #[test]
159    fn note_content_dedupes_sources_sharing_a_clean_name() {
160        let turn = turn_with(
161            "q",
162            "cites nothing in particular",
163            vec![source("a/note.md", "h1"), source("b/note.md", "h2")],
164        );
165        let body = note_content(&turn);
166        let sources_section = body.split("## Sources").nth(1).unwrap();
167        assert_eq!(sources_section.matches("[[note]]").count(), 1);
168    }
169
170    #[test]
171    fn citations_link_by_ordinal_even_when_sources_are_shuffled() {
172        // Sources in vec order [c, a, b] but ordinals [3, 1, 2]: `[1]` must
173        // link the ordinal-1 source (alpha), NOT the first vec element (charlie).
174        let turn = turn_with(
175            "q",
176            "first [1] second [2] third [3]",
177            vec![
178                source_ord("projects/charlie.md", "h", 3),
179                source_ord("projects/alpha.md", "h", 1),
180                source_ord("projects/beta.md", "h", 2),
181            ],
182        );
183        let body = note_content(&turn);
184        assert!(
185            body.contains("first [[alpha]]"),
186            "`[1]` → ordinal-1 source: {body}"
187        );
188        assert!(
189            body.contains("second [[beta]]"),
190            "`[2]` → ordinal-2 source: {body}"
191        );
192        assert!(
193            body.contains("third [[charlie]]"),
194            "`[3]` → ordinal-3 source: {body}"
195        );
196    }
197
198    #[test]
199    fn a_gap_leaves_the_citation_marker_untouched() {
200        // Ordinal 2 was dropped: `[2]` has no backing source and must stay `[2]`,
201        // while `[1]` and `[3]` still link.
202        let turn = turn_with(
203            "q",
204            "a [1] b [2] c [3]",
205            vec![
206                source_ord("projects/alpha.md", "h", 1),
207                source_ord("projects/charlie.md", "h", 3),
208            ],
209        );
210        let body = note_content(&turn);
211        assert!(body.contains("a [[alpha]]"), "{body}");
212        assert!(
213            body.contains("b [2] c"),
214            "gap `[2]` stays a literal marker: {body}"
215        );
216        assert!(body.contains("[[charlie]]"), "{body}");
217    }
218
219    #[test]
220    fn suggested_path_nests_under_ask_and_slugs_the_question() {
221        let path = suggested_path("How do I Ship v2?");
222        assert_eq!(path.to_string(), "ask/how-do-i-ship-v2.md");
223    }
224
225    /// Regression test for a real bug: `suggested_path` used to build the
226    /// slug with `VaultPath::new` instead of `VaultPath::note_path_from`, so
227    /// it never got the `.md` extension. `ensure_note()` then rejected every
228    /// save unconditionally (see `core::nfs::create_note_exclusive`), not
229    /// just for long or punctuation-heavy questions as it first appeared.
230    #[tokio::test]
231    async fn suggested_path_can_actually_be_saved_as_a_note() {
232        use kimun_core::{NoteVault, VaultConfig};
233
234        let dir = tempfile::TempDir::new().unwrap();
235        let vault = NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
236            .await
237            .unwrap();
238        vault.validate_and_init().await.unwrap();
239
240        let path = suggested_path("David Howell (sometimes refered as David H)?");
241        vault.create_note(&path, "content").await.unwrap();
242    }
243}