use crate::catalog::CatalogEntry;
use crate::cluster::ClusterResult;
use crate::config::KibbleConfig;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, serde::Serialize, PartialEq)]
pub struct TopicSummary {
pub path: String,
pub size: usize,
pub samples: Vec<String>,
}
pub fn assign_doc_topics(
assign: &[usize],
doc_ids: &[String],
labels: &[String],
n_clusters: usize,
entries: &mut [CatalogEntry],
) -> Vec<TopicSummary> {
let mut per_doc: HashMap<&str, HashMap<usize, usize>> = HashMap::new();
for (i, &c) in assign.iter().enumerate() {
*per_doc.entry(doc_ids[i].as_str()).or_default().entry(c).or_default() += 1;
}
let mut doc_topic: HashMap<&str, (usize, f32)> = HashMap::new();
for (doc, counts) in &per_doc {
let total: usize = counts.values().sum();
let mut best_c = usize::MAX;
let mut best_n = 0usize;
for (&c, &n) in counts {
if n > best_n || (n == best_n && c < best_c) {
best_n = n;
best_c = c;
}
}
doc_topic.insert(doc, (best_c, best_n as f32 / total as f32));
}
let mut sizes = vec![0usize; n_clusters];
let mut samples: Vec<Vec<String>> = vec![Vec::new(); n_clusters];
for e in entries.iter_mut() {
if let Some(&(c, conf)) = doc_topic.get(e.doc_id.as_str()) {
e.auto_topic = Some(labels.get(c).cloned().unwrap_or_default());
e.topic_confidence = Some(conf);
if c < n_clusters {
sizes[c] += 1;
if samples[c].len() < 3 {
samples[c].push(e.title.clone());
}
}
}
}
(0..n_clusters)
.map(|c| TopicSummary {
path: labels.get(c).cloned().unwrap_or_default(),
size: sizes[c],
samples: std::mem::take(&mut samples[c]),
})
.collect()
}
pub async fn apply_auto_topics(
cfg: &KibbleConfig,
repo_root: &Path,
cat_dir: &Path,
curated: &crate::build::CuratedSplits,
inline_clusters: Option<&ClusterResult>,
entries: &mut [CatalogEntry],
) -> std::io::Result<()> {
let embed = &cfg.understand.embed;
if embed.base_url.is_empty() {
eprintln!("kibble: auto-topics skipped (no embed backend — set [understand.embed].base_url)");
return Ok(());
}
let mut answers: Vec<String> = Vec::new();
let mut doc_ids: Vec<String> = Vec::new();
let mut train_idx: Vec<usize> = Vec::new();
for (si, split) in [&curated.train, &curated.valid, &curated.test].iter().enumerate() {
for (r, id) in split.clean.iter().zip(split.clean_doc_ids.iter()) {
if si == 0 { train_idx.push(answers.len()); }
answers.push(crate::build::clean_text(crate::build::last_content(r, "assistant")));
doc_ids.push(id.clone());
}
for (r, id) in split.raw.iter().zip(split.raw_doc_ids.iter()) {
if si == 0 { train_idx.push(answers.len()); }
answers.push(crate::build::last_content(r, "assistant").to_string());
doc_ids.push(id.clone());
}
}
if answers.is_empty() {
eprintln!("kibble: auto-topics skipped (no rows)");
return Ok(());
}
let store = repo_root.join(&embed.store);
let vecs = match crate::embed::EndpointEmbedder::new(embed, cfg.network.proxy.as_deref()) {
Ok(e) => match crate::vectors::get_or_embed(&e, &store, &embed.model, &answers, embed.batch_size).await {
Ok(v) => v,
Err(err) => { eprintln!("kibble: auto-topics skipped (embed failed): {err}"); return Ok(()); }
},
Err(err) => { eprintln!("kibble: auto-topics skipped (embed init failed): {err}"); return Ok(()); }
};
let cr: ClusterResult = if let Some(c) = inline_clusters {
c.clone()
} else {
let train_answers: Vec<String> = train_idx.iter().map(|&i| answers[i].clone()).collect();
let train_vecs: Vec<Vec<f32>> = train_idx.iter().map(|&i| vecs[i].clone()).collect();
if train_answers.is_empty() {
eprintln!("kibble: auto-topics skipped (no train rows)");
return Ok(());
}
crate::cluster::cluster_from(&train_answers, &train_vecs, cfg.cluster.k, &embed.model, cfg.cluster.min_cluster_size)
};
if cr.centroids.is_empty() {
eprintln!("kibble: auto-topics skipped (no centroids)");
return Ok(());
}
let labels = match crate::cluster::hier_labels(cfg, &cr.labels, &cr.samples).await {
Some(names) => names,
None => {
eprintln!("kibble: auto-topics skipped (no [ask] LLM or unparseable hierarchical names)");
return Ok(());
}
};
let assign = crate::cluster::assign_topics(&cr.centroids, &vecs);
let topics = assign_doc_topics(&assign, &doc_ids, &labels, cr.centroids.len(), entries);
std::fs::write(cat_dir.join("topics.json"), serde_json::to_string_pretty(&topics)?)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::CatalogEntry;
fn entry(doc_id: &str, title: &str) -> CatalogEntry {
CatalogEntry {
doc_id: doc_id.into(), source: "s".into(), title: title.into(), role: "style".into(),
topics: vec![], lora_bucket: "style".into(), rag: false, is_reference: false, chars: 1,
auto_topic: None, topic_confidence: None,
}
}
#[test]
fn majority_and_confidence() {
let assign = vec![0, 0, 1, 1];
let doc_ids = vec!["A".into(), "A".into(), "A".into(), "B".into()];
let labels = vec!["T0".into(), "T1".into()];
let mut es = vec![entry("A", "Atitle"), entry("B", "Btitle")];
let topics = assign_doc_topics(&assign, &doc_ids, &labels, 2, &mut es);
assert_eq!(es[0].auto_topic.as_deref(), Some("T0"));
assert_eq!(es[0].topic_confidence, Some(2.0 / 3.0));
assert_eq!(es[1].auto_topic.as_deref(), Some("T1"));
assert_eq!(es[1].topic_confidence, Some(1.0));
assert_eq!(topics[0], TopicSummary { path: "T0".into(), size: 1, samples: vec!["Atitle".into()] });
assert_eq!(topics[1].size, 1);
}
#[test]
fn tie_breaks_to_lowest_cluster_index() {
let assign = vec![1, 0];
let doc_ids = vec!["A".into(), "A".into()];
let labels = vec!["T0".into(), "T1".into()];
let mut es = vec![entry("A", "t")];
assign_doc_topics(&assign, &doc_ids, &labels, 2, &mut es);
assert_eq!(es[0].auto_topic.as_deref(), Some("T0"));
assert_eq!(es[0].topic_confidence, Some(0.5));
}
#[test]
fn zero_row_document_stays_none() {
let assign = vec![0];
let doc_ids = vec!["A".into()];
let labels = vec!["T0".into()];
let mut es = vec![entry("A", "a"), entry("Z", "z")];
assign_doc_topics(&assign, &doc_ids, &labels, 1, &mut es);
assert!(es[1].auto_topic.is_none() && es[1].topic_confidence.is_none());
}
}