use anyhow::Result;
use serde::{Deserialize, Serialize};
use crate::{CompletionRequest, Message, ModelAdapter};
const ECHO_ADAPTER_NAME: &str = "echo";
pub const ECHO_PASSTHROUGH_NOTE: &str = "adapter is the echo stub (no local .gguf) — output is a \
pass-through of the input, NOT model preprocessing; do not trust it as filtered/compressed.";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Preprocessed<T> {
pub output: T,
pub report: DropReport,
}
impl<T> Preprocessed<T> {
fn new(output: T, report: DropReport) -> Self {
Self { output, report }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DropReport {
pub step: String,
pub input_units: usize,
pub kept_units: usize,
pub dropped: Vec<String>,
pub notes: Vec<String>,
}
impl DropReport {
pub const AUTHORITATIVE: bool = false;
fn new(step: &'static str, input_units: usize) -> Self {
Self {
step: step.to_string(),
input_units,
kept_units: 0,
dropped: Vec::new(),
notes: Vec::new(),
}
}
#[must_use]
pub fn dropped_count(&self) -> usize {
self.dropped.len()
}
fn note(&mut self, note: impl Into<String>) {
self.notes.push(note.into());
}
}
fn is_echo(adapter: &dyn ModelAdapter) -> bool {
adapter.name() == ECHO_ADAPTER_NAME
}
fn route(adapter: &dyn ModelAdapter, system: &str, user: &str) -> Result<String> {
let request =
CompletionRequest::new([Message::system(system), Message::user(user)]);
Ok(adapter.complete(&request)?.content)
}
pub fn summarize(
adapter: &dyn ModelAdapter,
text: &str,
target_lines: usize,
) -> Result<Preprocessed<String>> {
let source_lines = text.lines().count();
let mut report = DropReport::new("summarize", source_lines);
let raw = route(
adapter,
&format!(
"Compress the text the user sends to at most {target_lines} lines. Keep the key \
facts and drop filler. Output only the compressed lines, nothing else."
),
text,
)?;
let reply_lines: Vec<&str> = raw.lines().collect();
let kept: Vec<&str> = reply_lines.iter().take(target_lines).copied().collect();
let overflow: Vec<String> =
reply_lines.iter().skip(target_lines).map(|l| (*l).to_string()).collect();
report.kept_units = kept.len();
report.dropped = overflow;
report.note(
"summary is a lossy, non-authoritative compression — the source text is unchanged and \
re-derivable; do not treat the summary as the source of truth.",
);
if report.dropped_count() > 0 {
report.note(format!(
"hard-capped at {target_lines} lines; {} overflow line(s) dropped (listed verbatim, \
recoverable).",
report.dropped_count()
));
}
if is_echo(adapter) {
report.note(ECHO_PASSTHROUGH_NOTE);
}
Ok(Preprocessed::new(kept.join("\n"), report))
}
pub fn triage(
adapter: &dyn ModelAdapter,
query: &str,
candidates: &[String],
) -> Result<Preprocessed<Vec<String>>> {
let mut report = DropReport::new("triage", candidates.len());
if candidates.is_empty() {
report.note("no candidates to triage.");
return Ok(Preprocessed::new(Vec::new(), report));
}
let mut listing = format!("QUERY: {query}\n\nCANDIDATES:\n");
for (i, c) in candidates.iter().enumerate() {
listing.push_str(&format!("{i}. {c}\n"));
}
let raw = route(
adapter,
"You are filtering search hits. Reply with only the numbers of the candidates that could \
plausibly relate to the QUERY, comma-separated. When unsure, keep it. Reply nothing else.",
&listing,
)?;
let mut selected = parse_indices(&raw, candidates.len());
let failsafe = selected.is_empty();
if failsafe {
selected = (0..candidates.len()).collect();
report.note(
"model reply named no valid candidate — kept ALL conservatively (a false negative is \
not recoverable here, so we never drop on an unusable reply).",
);
}
let mut kept = Vec::new();
for (i, c) in candidates.iter().enumerate() {
if selected.contains(&i) {
kept.push(c.clone());
} else {
report.dropped.push(c.clone());
}
}
report.kept_units = kept.len();
if report.dropped_count() > 0 {
report.note(format!(
"filtered out {} of {} candidate(s) as implausible (listed verbatim, recoverable — \
re-check if a false negative is suspected).",
report.dropped_count(),
candidates.len()
));
}
if is_echo(adapter) && !failsafe {
report.note(ECHO_PASSTHROUGH_NOTE);
}
Ok(Preprocessed::new(kept, report))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Bucket {
pub label: String,
pub items: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DiagnosticBuckets {
pub buckets: Vec<Bucket>,
}
pub fn classify_diagnostics(
adapter: &dyn ModelAdapter,
raw: &str,
) -> Result<Preprocessed<DiagnosticBuckets>> {
let blocks = split_diagnostics(raw);
let mut report = DropReport::new("classify_diagnostics", blocks.len());
let mut distinct: Vec<String> = Vec::new();
for b in blocks {
if distinct.contains(&b) {
report.dropped.push(b);
} else {
distinct.push(b);
}
}
report.kept_units = distinct.len();
if report.dropped_count() > 0 {
report.note(format!(
"removed {} exact-duplicate diagnostic(s) (recoverable; identical to a kept entry).",
report.dropped_count()
));
}
if !distinct.is_empty() {
let gloss = route(
adapter,
"Summarize the compiler diagnostics the user sends in one short sentence naming the \
likely root theme. This is advisory only.",
&distinct.join("\n\n"),
)?;
let gloss = first_line(&gloss);
if !gloss.is_empty() {
report.note(format!("local-model gloss (non-authoritative): {gloss}"));
}
}
report.note(
"buckets assigned deterministically by severity prefix (§0.3), NOT by model judgment.",
);
if is_echo(adapter) {
report.note(ECHO_PASSTHROUGH_NOTE);
}
Ok(Preprocessed::new(bucket_by_severity(&distinct), report))
}
pub fn draft_commit_message(
adapter: &dyn ModelAdapter,
diff: &str,
) -> Result<Preprocessed<String>> {
let diff_lines = diff.lines().count();
let mut report = DropReport::new("draft_commit_message", diff_lines);
let raw = route(
adapter,
"Write ONE concise commit subject line (imperative mood, under 72 chars) summarizing the \
diff the user sends. Output only that line.",
diff,
)?;
let subject = first_line(&raw);
report.kept_units = usize::from(!subject.is_empty());
report.note(
"draft only — a lossy, non-authoritative summary of the diff; review and rewrite before \
committing. The full diff is unchanged.",
);
if is_echo(adapter) {
report.note(ECHO_PASSTHROUGH_NOTE);
}
Ok(Preprocessed::new(subject, report))
}
fn split_diagnostics(raw: &str) -> Vec<String> {
let by_block: Vec<String> = raw
.split("\n\n")
.map(|b| b.trim().to_string())
.filter(|b| !b.is_empty())
.collect();
if by_block.len() > 1 {
return by_block;
}
raw.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect()
}
fn severity_of(block: &str) -> &'static str {
let head = block.lines().next().unwrap_or("").trim_start();
if head.starts_with("error") {
"error"
} else if head.starts_with("warning") {
"warning"
} else if head.starts_with("note") {
"note"
} else if head.starts_with("help") {
"help"
} else {
"other"
}
}
fn bucket_by_severity(distinct: &[String]) -> DiagnosticBuckets {
const ORDER: [&str; 5] = ["error", "warning", "note", "help", "other"];
let mut buckets = Vec::new();
for label in ORDER {
let items: Vec<String> = distinct
.iter()
.filter(|d| severity_of(d) == label)
.cloned()
.collect();
if !items.is_empty() {
buckets.push(Bucket { label: label.to_string(), items });
}
}
DiagnosticBuckets { buckets }
}
fn first_line(text: &str) -> String {
text.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("").to_string()
}
fn parse_indices(reply: &str, max: usize) -> Vec<usize> {
let mut found: Vec<usize> = Vec::new();
let mut digits = String::new();
let flush = |digits: &mut String, found: &mut Vec<usize>| {
if let Ok(n) = digits.parse::<usize>()
&& n < max
&& !found.contains(&n)
{
found.push(n);
}
digits.clear();
};
for ch in reply.chars() {
if ch.is_ascii_digit() {
digits.push(ch);
} else if !digits.is_empty() {
flush(&mut digits, &mut found);
}
}
if !digits.is_empty() {
flush(&mut digits, &mut found);
}
found.sort_unstable();
found
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CompletionResponse, EchoAdapter};
use std::sync::atomic::{AtomicUsize, Ordering};
struct FixedAdapter {
reply: String,
calls: AtomicUsize,
}
impl FixedAdapter {
fn new(reply: &str) -> Self {
Self { reply: reply.to_string(), calls: AtomicUsize::new(0) }
}
}
impl ModelAdapter for FixedAdapter {
fn name(&self) -> &str {
"fixed-test"
}
fn complete(&self, _request: &CompletionRequest) -> Result<CompletionResponse> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(CompletionResponse { content: self.reply.clone(), model: "fixed-test".into() })
}
}
const _: () = assert!(!DropReport::AUTHORITATIVE);
#[test]
fn summarize_routes_through_echo_and_reports_every_dropped_line() {
let text = "line one\nline two\nline three\nline four\nline five";
let pre = summarize(&EchoAdapter, text, 2).unwrap();
assert_eq!(pre.output, "line one\nline two");
assert_eq!(pre.report.input_units, 5);
assert_eq!(pre.report.kept_units, 2);
assert_eq!(pre.report.dropped, vec!["line three", "line four", "line five"]);
assert!(pre.report.notes.iter().any(|n| n == ECHO_PASSTHROUGH_NOTE));
}
#[test]
fn summarize_drops_nothing_when_within_budget() {
let pre = summarize(&EchoAdapter, "only\ntwo", 5).unwrap();
assert_eq!(pre.output, "only\ntwo");
assert_eq!(pre.report.dropped_count(), 0);
}
#[test]
fn triage_echo_keeps_all_and_flags_the_stub() {
let candidates =
vec!["hit a".to_string(), "hit b".to_string(), "hit c".to_string()];
let pre = triage(&EchoAdapter, "find the thing", &candidates).unwrap();
assert_eq!(pre.output, candidates);
assert_eq!(pre.report.dropped_count(), 0);
assert_eq!(pre.report.kept_units, 3);
assert!(pre.report.notes.iter().any(|n| n == ECHO_PASSTHROUGH_NOTE));
}
#[test]
fn triage_keeps_the_model_selected_subset_and_reports_the_rest() {
let adapter = FixedAdapter::new("keep 0, 2");
let candidates =
vec!["c0".to_string(), "c1".to_string(), "c2".to_string()];
let pre = triage(&adapter, "q", &candidates).unwrap();
assert_eq!(pre.output, vec!["c0".to_string(), "c2".to_string()]);
assert_eq!(pre.report.dropped, vec!["c1".to_string()]);
assert_eq!(pre.report.kept_units, 2);
assert_eq!(adapter.calls.load(Ordering::SeqCst), 1, "must route through the adapter");
assert!(!pre.report.notes.iter().any(|n| n == ECHO_PASSTHROUGH_NOTE));
}
#[test]
fn triage_fails_safe_to_keep_all_on_unusable_reply() {
let adapter = FixedAdapter::new("i have no idea, sorry");
let candidates = vec!["c0".to_string(), "c1".to_string()];
let pre = triage(&adapter, "q", &candidates).unwrap();
assert_eq!(pre.output, candidates);
assert_eq!(pre.report.dropped_count(), 0);
assert!(pre.report.notes.iter().any(|n| n.contains("kept ALL conservatively")));
}
#[test]
fn classify_dedups_and_buckets_by_severity() {
let raw = "error[E0432]: unresolved import `foo`\n\
error[E0432]: unresolved import `foo`\n\
warning: unused variable `x`\n\
note: `#[warn(unused)]` on by default";
let pre = classify_diagnostics(&EchoAdapter, raw).unwrap();
assert_eq!(pre.report.input_units, 4);
assert_eq!(pre.report.kept_units, 3);
assert_eq!(pre.report.dropped, vec!["error[E0432]: unresolved import `foo`"]);
let labels: Vec<&str> = pre.output.buckets.iter().map(|b| b.label.as_str()).collect();
assert_eq!(labels, vec!["error", "warning", "note"]);
assert_eq!(pre.output.buckets[0].items.len(), 1);
assert!(pre.report.notes.iter().any(|n| n.contains("deterministically by severity")));
assert!(pre.report.notes.iter().any(|n| n == ECHO_PASSTHROUGH_NOTE));
}
#[test]
fn draft_commit_message_returns_a_subject_and_flags_non_authoritative() {
let pre = draft_commit_message(&EchoAdapter, "add preprocess module\nmore detail").unwrap();
assert_eq!(pre.output, "add preprocess module");
assert!(pre.report.notes.iter().any(|n| n.contains("draft only")));
assert!(pre.report.notes.iter().any(|n| n == ECHO_PASSTHROUGH_NOTE));
}
#[test]
fn parse_indices_ignores_out_of_range_and_dedups() {
assert_eq!(parse_indices("keep 0, 2 and 9", 3), vec![0, 2]);
assert_eq!(parse_indices("1 1 1", 3), vec![1]);
assert_eq!(parse_indices("none here", 3), Vec::<usize>::new());
}
}