use tga::classify::pipeline_buckets::bucket_counts;
use tga::classify::ClassificationPipeline;
use tga::core::config::{BucketMap, BucketSource, ClassificationConfig, Config};
use tga::core::db::{CheckpointMode, Database};
use crate::commands::args::ClassifyArgs;
use crate::commands::date_range::resolve_date_range;
pub async fn run(config: Config, db: &mut Database, args: ClassifyArgs) -> anyhow::Result<()> {
let mut cfg = config;
if cfg.classification.is_none() && (args.rules.is_some() || args.use_llm) {
cfg.classification = Some(ClassificationConfig::default());
}
if let Some(ref mut c) = cfg.classification {
if let Some(rules) = args.rules {
c.rules_files.insert(0, rules);
}
if args.use_llm {
c.use_llm = true;
}
if args.no_external {
c.no_external = true;
}
}
let (resolved_since, resolved_until) = resolve_date_range(
args.weeks,
args.since.as_deref(),
args.until.as_deref(),
None,
)?;
let effective_since = resolved_since;
let effective_until = resolved_until;
if (effective_since.is_some() || effective_until.is_some()) && !args.force {
tracing::warn!(
"--since/--until/--weeks was supplied without --force; ignoring date window. \
Pass --force to re-classify commits already in the DB."
);
}
let shas = match &args.shas {
Some(path) => Some(read_sha_list(path)?),
None => None,
};
if shas.is_some() && !args.force {
tracing::warn!(
"--shas without --force classifies only listed commits that have no verdict yet"
);
}
let cfg_source = cfg.source_path.clone();
let pipeline = ClassificationPipeline::new(cfg)
.with_shas(shas)
.with_force(args.force)
.with_since(effective_since.clone())
.with_until(effective_until.clone())
.with_repos(args.repos.clone());
if args.backfill_complexity {
let updated = pipeline.backfill_complexity(db).await?;
println!("Backfilled complexity for {updated} commit(s)");
if let Err(e) = db.wal_checkpoint(CheckpointMode::Truncate) {
tracing::warn!(error = %e, "WAL TRUNCATE checkpoint failed after backfill");
}
return Ok(());
}
let (buckets, bucket_source) = pipeline.bucket_map_with_source()?;
let stats = pipeline.run(db).await?;
if let Err(e) = db.wal_checkpoint(CheckpointMode::Truncate) {
tracing::warn!(error = %e, "WAL TRUNCATE checkpoint failed after classify — \
the WAL may not be flushed; run `sqlite3 <db> 'PRAGMA wal_checkpoint(TRUNCATE)'` manually");
}
println!(
"Classified {}/{} commits ({:.1}% coverage)",
stats.classified, stats.total_commits, stats.coverage_pct
);
if !stats.by_method.is_empty() {
println!("By method:");
for (method, count) in &stats.by_method {
println!(" {method}: {count}");
}
}
if !stats.by_category.is_empty() {
println!("By category:");
for (category, count) in &stats.by_category {
println!(" {category}: {count}");
}
}
print_buckets(&buckets, bucket_source, &stats.by_category);
print_llm_usage(&stats.llm_usage);
print_skipped_recovery(&stats.llm_usage, &cfg_source, db)?;
Ok(())
}
fn print_buckets(
buckets: &BucketMap,
source: BucketSource,
by_category: &std::collections::HashMap<String, usize>,
) {
let rows = bucket_counts(
buckets,
by_category.iter().map(|(c, &n)| (c.as_str(), n as u64)),
);
if rows.is_empty() {
return;
}
println!("By bucket (map from {}):", source.describe());
for row in rows {
let parts: Vec<String> = row
.categories
.iter()
.map(|(c, n)| format!("{c} {n}"))
.collect();
println!(" {}: {} ({})", row.bucket, row.total, parts.join(", "));
}
}
pub(crate) fn print_skipped_recovery(
u: &tga::classify::LlmUsageTotals,
config_path: &Option<std::path::PathBuf>,
db: &Database,
) -> anyhow::Result<()> {
if u.skipped == 0 || u.skipped_shas.is_empty() {
return Ok(());
}
let db_path = db
.connection()
.path()
.filter(|p| !p.is_empty())
.map(std::path::PathBuf::from);
let dir = db_path
.as_deref()
.and_then(|p| p.parent())
.filter(|d| !d.as_os_str().is_empty())
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| std::path::PathBuf::from("."));
let file = dir.join("tga-llm-skipped.txt");
std::fs::write(&file, format!("{}\n", u.skipped_shas.join("\n")))
.map_err(|e| anyhow::anyhow!("cannot write {}: {e}", file.display()))?;
let mut cmd = String::from("tga");
if let Some(c) = config_path {
cmd.push_str(&format!(" --config {}", shell_quote(c)));
}
if let Some(d) = &db_path {
cmd.push_str(&format!(" --database {}", shell_quote(d)));
}
cmd.push_str(&format!(" classify --force --shas {}", shell_quote(&file)));
println!(
"LLM skipped {} commit(s) (spend cap or payload dump); they kept their rule verdict. \
Re-send them with:\n {cmd}",
u.skipped_shas.len()
);
Ok(())
}
fn shell_quote(p: &std::path::Path) -> String {
let s = p.display().to_string();
if !s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || "_./-".contains(c))
{
s
} else {
format!("'{}'", s.replace('\'', r"'\''"))
}
}
fn print_llm_usage(u: &tga::classify::LlmUsageTotals) {
if u.calls == 0 {
return;
}
println!(
"LLM calls: {} (adopted {}, not adopted {}, abstained {}, out-of-set {}, failed {}, \
skipped {})",
u.calls, u.adopted, u.not_adopted, u.abstained, u.out_of_set, u.failed, u.skipped
);
println!(
"LLM tokens: input {}, output {} ({} of {} calls reported usage)",
u.input_tokens, u.output_tokens, u.calls_with_usage, u.calls
);
if u.calls_with_usage > 0 {
let n = u.calls_with_usage as f64;
println!(
"LLM tokens per call: input {:.1}, output {:.1}",
u.input_tokens as f64 / n,
u.output_tokens as f64 / n
);
}
}
fn read_sha_list(path: &std::path::Path) -> anyhow::Result<Vec<String>> {
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("cannot read --shas file {}: {e}", path.display()))?;
Ok(text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(str::to_string)
.collect())
}