194 lines
6.1 KiB
Rust
194 lines
6.1 KiB
Rust
use crate::EventRecord;
|
|
use anyhow::Result;
|
|
use std::collections::{BTreeMap, HashMap};
|
|
use std::fs;
|
|
|
|
/// Obsidian vault projector (deterministic, byte-identical).
|
|
pub struct ObsidianProjector {
|
|
vault_dir: String,
|
|
_emit_evidence: bool,
|
|
}
|
|
|
|
/// Vault note metadata (stable frontmatter order).
|
|
#[derive(Debug, Clone)]
|
|
pub struct VaultNote {
|
|
pub project: String,
|
|
pub level: String,
|
|
pub query_id: Option<String>,
|
|
pub updated: String,
|
|
pub chunks_seen: u32,
|
|
pub chunks_used: u32,
|
|
pub run_id: String,
|
|
pub body: String,
|
|
pub parents: Vec<(String, String)>,
|
|
}
|
|
|
|
impl ObsidianProjector {
|
|
/// Create projector.
|
|
pub fn new(_log_dir: &str, vault_dir: &str, emit_evidence: bool) -> Self {
|
|
Self {
|
|
vault_dir: vault_dir.to_string(),
|
|
_emit_evidence: emit_evidence,
|
|
}
|
|
}
|
|
|
|
/// Project log to vault (deterministic).
|
|
pub fn project(&self, events: &[EventRecord]) -> Result<()> {
|
|
fs::create_dir_all(&self.vault_dir)?;
|
|
|
|
// Group by project and query
|
|
let mut by_project: HashMap<String, HashMap<String, Vec<&EventRecord>>> = HashMap::new();
|
|
|
|
for event in events {
|
|
by_project
|
|
.entry(event.project.clone())
|
|
.or_insert_with(HashMap::new)
|
|
.entry(event.query.clone())
|
|
.or_insert_with(Vec::new)
|
|
.push(event);
|
|
}
|
|
|
|
// Generate notes per project (in sorted order for determinism)
|
|
let mut sorted_projects: Vec<_> = by_project.iter().collect();
|
|
sorted_projects.sort_by_key(|(p, _)| p.as_str());
|
|
|
|
for (project, queries) in sorted_projects {
|
|
let proj_dir = format!("{}/{}", self.vault_dir, project);
|
|
fs::create_dir_all(&proj_dir)?;
|
|
|
|
// Generate index (L2)
|
|
let index_note = VaultNote {
|
|
project: project.clone(),
|
|
level: "L2".to_string(),
|
|
query_id: None,
|
|
updated: "2026-01-01".to_string(),
|
|
chunks_seen: 0,
|
|
chunks_used: 0,
|
|
run_id: "index".to_string(),
|
|
body: String::new(),
|
|
parents: vec![],
|
|
};
|
|
self.write_note(&proj_dir, "index", &index_note)?;
|
|
|
|
// Generate per-query notes (L1) in sorted order
|
|
let mut sorted_queries: Vec<_> = queries.iter().collect();
|
|
sorted_queries.sort_by_key(|(qid, _)| qid.as_str());
|
|
|
|
for (query_id, query_events) in sorted_queries {
|
|
let (chunks_seen, chunks_used, body, parents) =
|
|
Self::summarize_query(query_events);
|
|
|
|
let note = VaultNote {
|
|
project: project.clone(),
|
|
level: "L1".to_string(),
|
|
query_id: Some(query_id.to_string()),
|
|
updated: "2026-01-01".to_string(),
|
|
chunks_seen,
|
|
chunks_used,
|
|
run_id: "run1".to_string(),
|
|
body,
|
|
parents,
|
|
};
|
|
|
|
self.write_note(&proj_dir, query_id, ¬e)?;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Write note with deterministic formatting.
|
|
fn write_note(&self, dir: &str, name: &str, note: &VaultNote) -> Result<()> {
|
|
// Stable frontmatter order (BTreeMap keeps keys sorted)
|
|
let mut fm = BTreeMap::new();
|
|
fm.insert("chunks_seen", note.chunks_seen.to_string());
|
|
fm.insert("chunks_used", note.chunks_used.to_string());
|
|
fm.insert("level", note.level.clone());
|
|
fm.insert("project", note.project.clone());
|
|
if let Some(qid) = ¬e.query_id {
|
|
fm.insert("query_id", qid.clone());
|
|
}
|
|
fm.insert("run_id", note.run_id.clone());
|
|
fm.insert("updated", note.updated.clone());
|
|
|
|
// Build frontmatter
|
|
let mut content = String::from("---\n");
|
|
for (k, v) in fm.iter() {
|
|
content.push_str(&format!("{}: {}\n", k, v));
|
|
}
|
|
content.push_str("---\n");
|
|
|
|
// Title
|
|
let title = note.query_id.as_ref().unwrap_or(¬e.project);
|
|
content.push_str(&format!("# {}\n\n", title));
|
|
|
|
// Body
|
|
if note.body.is_empty() {
|
|
content.push_str("No evidence found.\n\n");
|
|
} else {
|
|
content.push_str(¬e.body);
|
|
if !note.body.ends_with('\n') {
|
|
content.push('\n');
|
|
}
|
|
content.push('\n');
|
|
}
|
|
|
|
// Provenance (sorted)
|
|
if !note.parents.is_empty() {
|
|
content.push_str("## Provenance\n");
|
|
let mut sorted_parents = note.parents.clone();
|
|
sorted_parents.sort();
|
|
for (source, time) in sorted_parents {
|
|
content.push_str(&format!("- [[{}-{}]]\n", source, time));
|
|
}
|
|
}
|
|
|
|
// Ensure exactly one trailing newline
|
|
if !content.ends_with('\n') {
|
|
content.push('\n');
|
|
}
|
|
|
|
// Write to file
|
|
let path = format!("{}/{}.md", dir, name);
|
|
fs::write(&path, &content)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Summarize query events.
|
|
fn summarize_query(
|
|
events: &[&EventRecord],
|
|
) -> (u32, u32, String, Vec<(String, String)>) {
|
|
let mut chunks_seen = 0u32;
|
|
let mut chunks_used = 0u32;
|
|
let mut body = String::new();
|
|
let mut parents = Vec::new();
|
|
|
|
for event in events.iter() {
|
|
if event.event_type.contains("Gate") {
|
|
chunks_seen += 1;
|
|
}
|
|
if event.event_type.contains("Evidence") {
|
|
chunks_used += 1;
|
|
}
|
|
// Simplified parent extraction
|
|
if let Some(obj) = event.data.as_object() {
|
|
if let Some(parent) = obj.get("parent") {
|
|
if let Some(s) = parent.as_str() {
|
|
parents.push((s.to_string(), format!("t{}", event.turn)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if chunks_used > 0 {
|
|
body = format!(
|
|
"Extracted from {} chunks, using {}\n",
|
|
chunks_seen, chunks_used
|
|
);
|
|
}
|
|
|
|
(chunks_seen, chunks_used, body, parents)
|
|
}
|
|
}
|