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