turbovault_tools/
grounding.rs1use std::sync::Arc;
18
19use serde::{Deserialize, Serialize};
20use turbovault_core::okf::Citation;
21use turbovault_core::prelude::*;
22use turbovault_parser::{ContentBlock, parse_blocks, parse_citations};
23use turbovault_vault::VaultManager;
24
25const MAX_CLAIMS: usize = 200;
27const MIN_CLAIM_WORDS: usize = 5;
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct GroundingAnalysis {
33 pub path: String,
35 pub claim_count: usize,
37 pub citation_count: usize,
39 pub has_citations: bool,
41 pub uncited: bool,
43 pub has_schema_section: bool,
45 pub has_examples_section: bool,
47 pub claims_truncated: bool,
49 pub claims: Vec<String>,
51 pub citations: Vec<Citation>,
53 pub guidance: Vec<String>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct UngroundedNote {
60 pub path: String,
61 pub claim_count: usize,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct UngroundedReport {
67 pub total_notes: usize,
69 pub ungrounded_count: usize,
71 pub notes: Vec<UngroundedNote>,
73}
74
75pub struct GroundingTools {
77 manager: Arc<VaultManager>,
78}
79
80impl GroundingTools {
81 pub fn new(manager: Arc<VaultManager>) -> Self {
82 Self { manager }
83 }
84
85 fn rel(&self, path: &std::path::Path) -> String {
86 path.strip_prefix(self.manager.vault_path())
87 .unwrap_or(path)
88 .to_string_lossy()
89 .replace('\\', "/")
90 }
91
92 pub async fn analyze_note(&self, path: &str) -> Result<GroundingAnalysis> {
94 let file_path = std::path::PathBuf::from(path);
95 let vault_file = self.manager.parse_file(&file_path).await?;
96 let body = &vault_file.content;
97
98 let all_claims = extract_claims(body);
99 let claim_count = all_claims.len();
100 let claims_truncated = claim_count > MAX_CLAIMS;
101 let claims: Vec<String> = all_claims.into_iter().take(MAX_CLAIMS).collect();
102
103 let citations = parse_citations(body);
104 let citation_count = citations.len();
105 let has_citations = citation_count > 0;
106
107 let headings = turbovault_parser::parse_headings(body);
109 let has_section = |name: &str| {
110 headings
111 .iter()
112 .any(|h| h.text.trim().eq_ignore_ascii_case(name))
113 };
114 let has_schema_section = has_section("schema");
115 let has_examples_section = has_section("examples");
116 let uncited = claim_count > 0 && citation_count == 0;
117
118 let mut guidance = vec![
119 "Feed `claims` + `citations` (and the cited sources) to an LLM judge to score hallucination-free grounding: the fraction of claims supported by a cited source.".to_string(),
120 "Compare `claims` across related notes to score contradiction (conflicting join keys, enums, definitions) and redundancy (claims that only restate schema).".to_string(),
121 ];
122 if uncited {
123 guidance.push(
124 "This note makes claims but cites no source — prioritize it for grounding review."
125 .to_string(),
126 );
127 }
128
129 Ok(GroundingAnalysis {
130 path: self.rel(&file_path),
131 claim_count,
132 citation_count,
133 has_citations,
134 uncited,
135 has_schema_section,
136 has_examples_section,
137 claims_truncated,
138 claims,
139 citations,
140 guidance,
141 })
142 }
143
144 pub async fn find_ungrounded_notes(&self, limit: usize) -> Result<UngroundedReport> {
146 let files = self.manager.vault_files_validated().await;
148 let mut total_notes = 0usize;
149 let mut ungrounded: Vec<UngroundedNote> = Vec::new();
150
151 for vault_file in &files {
152 total_notes += 1;
153 let body = &vault_file.content;
154 if parse_citations(body).is_empty() {
155 let claim_count = extract_claims(body).len();
156 if claim_count > 0 {
157 ungrounded.push(UngroundedNote {
158 path: self.rel(&vault_file.path),
159 claim_count,
160 });
161 }
162 }
163 }
164
165 ungrounded.sort_by(|a, b| b.claim_count.cmp(&a.claim_count).then(a.path.cmp(&b.path)));
166 let ungrounded_count = ungrounded.len();
167 ungrounded.truncate(limit);
168
169 Ok(UngroundedReport {
170 total_notes,
171 ungrounded_count,
172 notes: ungrounded,
173 })
174 }
175}
176
177fn extract_claims(body: &str) -> Vec<String> {
183 let mut prose = String::new();
184 for block in parse_blocks(body) {
185 collect_prose(&block, &mut prose);
186 }
187
188 let mut claims = Vec::new();
189 for sentence in split_sentences(&prose) {
190 let s = sentence.trim();
191 let words = s.split_whitespace().count();
192 if words >= MIN_CLAIM_WORDS
193 && s.chars().any(|c| c.is_alphabetic())
194 && !s.starts_with("http://")
195 && !s.starts_with("https://")
196 {
197 claims.push(s.to_string());
198 }
199 }
200 claims
201}
202
203fn collect_prose(block: &ContentBlock, out: &mut String) {
205 match block {
206 ContentBlock::Paragraph { .. }
207 | ContentBlock::Blockquote { .. }
208 | ContentBlock::List { .. } => {
209 let text = block.to_plain_text();
210 if !text.trim().is_empty() {
211 out.push_str(text.trim());
212 out.push('\n');
213 }
214 }
215 _ => {}
217 }
218}
219
220fn split_sentences(text: &str) -> Vec<String> {
222 let mut sentences = Vec::new();
223 let mut current = String::new();
224 let chars: Vec<char> = text.chars().collect();
225 for (i, &c) in chars.iter().enumerate() {
226 if c == '\n' {
227 if !current.trim().is_empty() {
228 sentences.push(std::mem::take(&mut current));
229 } else {
230 current.clear();
231 }
232 continue;
233 }
234 current.push(c);
235 if matches!(c, '.' | '?' | '!') {
236 let next_is_break = chars.get(i + 1).map(|n| n.is_whitespace()).unwrap_or(true);
238 if next_is_break && !current.trim().is_empty() {
239 sentences.push(std::mem::take(&mut current));
240 }
241 }
242 }
243 if !current.trim().is_empty() {
244 sentences.push(current);
245 }
246 sentences
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use std::path::Path;
253
254 fn make_manager(vault_dir: &Path) -> Arc<VaultManager> {
255 use turbovault_core::{ServerConfig, VaultConfig};
256 let mut config = ServerConfig::new();
257 config
258 .vaults
259 .push(VaultConfig::builder("test", vault_dir).build().unwrap());
260 Arc::new(VaultManager::new(config).unwrap())
261 }
262
263 #[test]
264 fn extracts_prose_sentences_as_claims() {
265 let body = "# Schema\n\nThe orders table has one row per completed order. It is joined to customers on customer_id.\n\n```sql\nSELECT 1\n```\n";
266 let claims = extract_claims(body);
267 assert_eq!(claims.len(), 2);
268 assert!(claims[0].contains("one row per completed order"));
269 assert!(!claims.iter().any(|c| c.contains("SELECT")));
271 }
272
273 #[test]
274 fn short_fragments_are_not_claims() {
275 let body = "Hello world.\n\nThis sentence is long enough to count as a claim here.\n";
276 let claims = extract_claims(body);
277 assert_eq!(claims.len(), 1);
278 }
279
280 #[tokio::test]
281 async fn analyze_note_flags_uncited_claims() {
282 let temp = tempfile::TempDir::new().unwrap();
283 std::fs::write(
284 temp.path().join("uncited.md"),
285 "---\ntype: Table\n---\n# Schema\n\nThe orders table holds one row per completed order in USD.\n",
286 )
287 .unwrap();
288 std::fs::write(
289 temp.path().join("cited.md"),
290 "---\ntype: Table\n---\n# Notes\n\nThe customers table holds one row per registered customer account.\n\n# Citations\n\n[1] [src](https://x.example)\n",
291 )
292 .unwrap();
293
294 let manager = make_manager(temp.path());
295 manager.initialize().await.unwrap();
296 let tools = GroundingTools::new(manager);
297
298 let uncited = tools.analyze_note("uncited.md").await.unwrap();
299 assert!(uncited.claim_count >= 1);
300 assert_eq!(uncited.citation_count, 0);
301 assert!(uncited.uncited);
302 assert!(uncited.has_schema_section);
303
304 let cited = tools.analyze_note("cited.md").await.unwrap();
305 assert_eq!(cited.citation_count, 1);
306 assert!(!cited.uncited);
307 }
308
309 #[tokio::test]
310 async fn find_ungrounded_lists_only_uncited_claim_notes() {
311 let temp = tempfile::TempDir::new().unwrap();
312 std::fs::write(
313 temp.path().join("uncited.md"),
314 "---\ntype: Table\n---\nThe orders table holds one row per completed order today.\n",
315 )
316 .unwrap();
317 std::fs::write(
318 temp.path().join("cited.md"),
319 "The customers table holds one row per registered account here.\n\n# Citations\n\n[1] [s](https://x.example)\n",
320 )
321 .unwrap();
322 std::fs::write(
323 temp.path().join("empty.md"),
324 "---\ntype: Table\n---\n# Just a heading\n",
325 )
326 .unwrap();
327
328 let manager = make_manager(temp.path());
329 manager.initialize().await.unwrap();
330 let tools = GroundingTools::new(manager);
331
332 let report = tools.find_ungrounded_notes(10).await.unwrap();
333 assert_eq!(report.total_notes, 3);
334 assert_eq!(report.ungrounded_count, 1);
335 assert_eq!(report.notes[0].path, "uncited.md");
336 }
337}