use std::sync::Arc;
use serde::{Deserialize, Serialize};
use turbovault_core::okf::Citation;
use turbovault_core::prelude::*;
use turbovault_parser::{ContentBlock, parse_blocks, parse_citations};
use turbovault_vault::VaultManager;
const MAX_CLAIMS: usize = 200;
const MIN_CLAIM_WORDS: usize = 5;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroundingAnalysis {
pub path: String,
pub claim_count: usize,
pub citation_count: usize,
pub has_citations: bool,
pub uncited: bool,
pub has_schema_section: bool,
pub has_examples_section: bool,
pub claims_truncated: bool,
pub claims: Vec<String>,
pub citations: Vec<Citation>,
pub guidance: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UngroundedNote {
pub path: String,
pub claim_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UngroundedReport {
pub total_notes: usize,
pub ungrounded_count: usize,
pub notes: Vec<UngroundedNote>,
}
pub struct GroundingTools {
manager: Arc<VaultManager>,
}
impl GroundingTools {
pub fn new(manager: Arc<VaultManager>) -> Self {
Self { manager }
}
fn rel(&self, path: &std::path::Path) -> String {
path.strip_prefix(self.manager.vault_path())
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
pub async fn analyze_note(&self, path: &str) -> Result<GroundingAnalysis> {
let file_path = std::path::PathBuf::from(path);
let vault_file = self.manager.parse_file(&file_path).await?;
let body = &vault_file.content;
let all_claims = extract_claims(body);
let claim_count = all_claims.len();
let claims_truncated = claim_count > MAX_CLAIMS;
let claims: Vec<String> = all_claims.into_iter().take(MAX_CLAIMS).collect();
let citations = parse_citations(body);
let citation_count = citations.len();
let has_citations = citation_count > 0;
let headings = turbovault_parser::parse_headings(body);
let has_section = |name: &str| {
headings
.iter()
.any(|h| h.text.trim().eq_ignore_ascii_case(name))
};
let has_schema_section = has_section("schema");
let has_examples_section = has_section("examples");
let uncited = claim_count > 0 && citation_count == 0;
let mut guidance = vec![
"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(),
"Compare `claims` across related notes to score contradiction (conflicting join keys, enums, definitions) and redundancy (claims that only restate schema).".to_string(),
];
if uncited {
guidance.push(
"This note makes claims but cites no source — prioritize it for grounding review."
.to_string(),
);
}
Ok(GroundingAnalysis {
path: self.rel(&file_path),
claim_count,
citation_count,
has_citations,
uncited,
has_schema_section,
has_examples_section,
claims_truncated,
claims,
citations,
guidance,
})
}
pub async fn find_ungrounded_notes(&self, limit: usize) -> Result<UngroundedReport> {
let files = self.manager.vault_files_validated().await;
let mut total_notes = 0usize;
let mut ungrounded: Vec<UngroundedNote> = Vec::new();
for vault_file in &files {
total_notes += 1;
let body = &vault_file.content;
if parse_citations(body).is_empty() {
let claim_count = extract_claims(body).len();
if claim_count > 0 {
ungrounded.push(UngroundedNote {
path: self.rel(&vault_file.path),
claim_count,
});
}
}
}
ungrounded.sort_by(|a, b| b.claim_count.cmp(&a.claim_count).then(a.path.cmp(&b.path)));
let ungrounded_count = ungrounded.len();
ungrounded.truncate(limit);
Ok(UngroundedReport {
total_notes,
ungrounded_count,
notes: ungrounded,
})
}
}
fn extract_claims(body: &str) -> Vec<String> {
let mut prose = String::new();
for block in parse_blocks(body) {
collect_prose(&block, &mut prose);
}
let mut claims = Vec::new();
for sentence in split_sentences(&prose) {
let s = sentence.trim();
let words = s.split_whitespace().count();
if words >= MIN_CLAIM_WORDS
&& s.chars().any(|c| c.is_alphabetic())
&& !s.starts_with("http://")
&& !s.starts_with("https://")
{
claims.push(s.to_string());
}
}
claims
}
fn collect_prose(block: &ContentBlock, out: &mut String) {
match block {
ContentBlock::Paragraph { .. }
| ContentBlock::Blockquote { .. }
| ContentBlock::List { .. } => {
let text = block.to_plain_text();
if !text.trim().is_empty() {
out.push_str(text.trim());
out.push('\n');
}
}
_ => {}
}
}
fn split_sentences(text: &str) -> Vec<String> {
let mut sentences = Vec::new();
let mut current = String::new();
let chars: Vec<char> = text.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if c == '\n' {
if !current.trim().is_empty() {
sentences.push(std::mem::take(&mut current));
} else {
current.clear();
}
continue;
}
current.push(c);
if matches!(c, '.' | '?' | '!') {
let next_is_break = chars.get(i + 1).map(|n| n.is_whitespace()).unwrap_or(true);
if next_is_break && !current.trim().is_empty() {
sentences.push(std::mem::take(&mut current));
}
}
}
if !current.trim().is_empty() {
sentences.push(current);
}
sentences
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn make_manager(vault_dir: &Path) -> Arc<VaultManager> {
use turbovault_core::{ServerConfig, VaultConfig};
let mut config = ServerConfig::new();
config
.vaults
.push(VaultConfig::builder("test", vault_dir).build().unwrap());
Arc::new(VaultManager::new(config).unwrap())
}
#[test]
fn extracts_prose_sentences_as_claims() {
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";
let claims = extract_claims(body);
assert_eq!(claims.len(), 2);
assert!(claims[0].contains("one row per completed order"));
assert!(!claims.iter().any(|c| c.contains("SELECT")));
}
#[test]
fn short_fragments_are_not_claims() {
let body = "Hello world.\n\nThis sentence is long enough to count as a claim here.\n";
let claims = extract_claims(body);
assert_eq!(claims.len(), 1);
}
#[tokio::test]
async fn analyze_note_flags_uncited_claims() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::write(
temp.path().join("uncited.md"),
"---\ntype: Table\n---\n# Schema\n\nThe orders table holds one row per completed order in USD.\n",
)
.unwrap();
std::fs::write(
temp.path().join("cited.md"),
"---\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",
)
.unwrap();
let manager = make_manager(temp.path());
manager.initialize().await.unwrap();
let tools = GroundingTools::new(manager);
let uncited = tools.analyze_note("uncited.md").await.unwrap();
assert!(uncited.claim_count >= 1);
assert_eq!(uncited.citation_count, 0);
assert!(uncited.uncited);
assert!(uncited.has_schema_section);
let cited = tools.analyze_note("cited.md").await.unwrap();
assert_eq!(cited.citation_count, 1);
assert!(!cited.uncited);
}
#[tokio::test]
async fn find_ungrounded_lists_only_uncited_claim_notes() {
let temp = tempfile::TempDir::new().unwrap();
std::fs::write(
temp.path().join("uncited.md"),
"---\ntype: Table\n---\nThe orders table holds one row per completed order today.\n",
)
.unwrap();
std::fs::write(
temp.path().join("cited.md"),
"The customers table holds one row per registered account here.\n\n# Citations\n\n[1] [s](https://x.example)\n",
)
.unwrap();
std::fs::write(
temp.path().join("empty.md"),
"---\ntype: Table\n---\n# Just a heading\n",
)
.unwrap();
let manager = make_manager(temp.path());
manager.initialize().await.unwrap();
let tools = GroundingTools::new(manager);
let report = tools.find_ungrounded_notes(10).await.unwrap();
assert_eq!(report.total_notes, 3);
assert_eq!(report.ungrounded_count, 1);
assert_eq!(report.notes[0].path, "uncited.md");
}
}