1use std::fmt::Write as _;
13
14use serde::Serialize;
15
16use crate::maintenance::MaintenanceReport;
17use crate::portfolio::{Portfolio, PortfolioAction, Verdict};
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct ConciergeAnswer {
22 pub answer: String,
24 pub summary: String,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub model_used: Option<String>,
29 pub ungrounded: Vec<String>,
31}
32
33fn gb(bytes: u64) -> f64 {
34 bytes as f64 / 1e9
35}
36
37fn verdict_label(verdict: Verdict) -> &'static str {
38 match verdict {
39 Verdict::Protected => "protected",
40 Verdict::NeverRunsHere => "cannot run on this machine",
41 Verdict::CannotLoad => "never loaded successfully",
42 Verdict::Superseded => "replaced by a newer model",
43 Verdict::Idle => "unused",
44 Verdict::Active => "in use",
45 Verdict::Unknown => "not enough history yet",
46 }
47}
48
49pub fn summarize(
53 portfolio: &Portfolio,
54 maintenance: &MaintenanceReport,
55 memory_policy: &str,
56) -> String {
57 let mut out = String::new();
58 let installed = portfolio.models.len();
59 let _ = writeln!(out, "Installed local models: {installed}.");
60 for assessment in &portfolio.models {
61 let freed = assessment
62 .retire
63 .as_ref()
64 .filter(|_| assessment.action == PortfolioAction::Retire)
65 .map(|r| format!("; retiring it would free {:.1} GB", gb(r.freed_bytes)))
66 .unwrap_or_default();
67 let _ = writeln!(
68 out,
69 "- {} ({}): {}{}{}",
70 assessment.name,
71 assessment.model_id,
72 verdict_label(assessment.verdict),
73 if assessment.evidence.is_empty() {
74 String::new()
75 } else {
76 format!(" — {}", assessment.evidence.join("; "))
77 },
78 freed
79 );
80 }
81 if portfolio.reclaimable_bytes > 0 {
82 let _ = writeln!(
83 out,
84 "Retiring every model recommended for it would free {:.1} GB.",
85 gb(portfolio.reclaimable_bytes)
86 );
87 }
88 let policy = match maintenance.policy {
89 crate::update_prefs::UpdatePolicy::Auto => "auto (CAR maintains local models itself)",
90 crate::update_prefs::UpdatePolicy::Notify => "notify (CAR suggests, you decide)",
91 crate::update_prefs::UpdatePolicy::Off => "off (no maintenance)",
92 };
93 let _ = writeln!(out, "Update policy: {policy}.");
94 if maintenance.auto_grace_until.is_some() {
95 let _ = writeln!(
96 out,
97 "This is the first week under the default policy, so maintenance only suggests for now."
98 );
99 }
100 if let Some(disk) = maintenance.disk {
101 let _ = writeln!(
102 out,
103 "Disk: {:.0} GB free of {:.0} GB{}.",
104 gb(disk.free_bytes),
105 gb(disk.total_bytes),
106 if maintenance.pressured {
107 " — space is short, so unused models may be retired"
108 } else {
109 ""
110 }
111 );
112 }
113 let queued: Vec<String> = maintenance
114 .plan
115 .act
116 .iter()
117 .chain(maintenance.plan.suggest.iter())
118 .map(|d| format!("{} ({:.1} GB)", d.model_id, gb(d.freed_bytes)))
119 .collect();
120 if queued.is_empty() {
121 let _ = writeln!(out, "Maintenance has nothing to retire right now.");
122 } else {
123 let _ = writeln!(out, "Maintenance would retire: {}.", queued.join(", "));
124 }
125 for held in &maintenance.plan.held {
126 let _ = writeln!(out, "Kept {}: {}.", held.model_id, held.why);
127 }
128 let _ = writeln!(out, "Memory policy for local models: {memory_policy}.");
129 if let Some(sizing) = &maintenance.resource_policy {
130 let _ = writeln!(
131 out,
132 "The memory policy {} change from {:?} to {:?}: {}.",
133 if sizing.applied { "did" } else { "would" },
134 sizing.from,
135 sizing.to,
136 sizing.reason
137 );
138 }
139 if !portfolio.recent_actions.is_empty() {
140 let _ = writeln!(
141 out,
142 "What the concierge did on its own in the last 30 days:"
143 );
144 for action in &portfolio.recent_actions {
145 let _ = writeln!(
146 out,
147 "- {} {}{}{}",
148 action.kind,
149 action.subject,
150 if action.interrupted {
151 " (interrupted; finished at the next daemon start)"
152 } else {
153 ""
154 },
155 action
156 .undo
157 .as_deref()
158 .map(|undo| format!(" — undo with `{undo}`"))
159 .unwrap_or_default()
160 );
161 }
162 }
163 for pin in &portfolio.revoked_pins {
164 let named = if pin.pin == pin.model_id {
165 String::new()
166 } else {
167 format!(" (as `{}`)", pin.pin)
168 };
169 let _ = writeln!(
170 out,
171 "Its publisher revoked {}; agents still pin it{named}: {}. Point them at another model.",
172 pin.model_id,
173 pin.agents.join(", ")
174 );
175 }
176 for copy in &portfolio.revoked_copies {
177 let _ = writeln!(
178 out,
179 "{} holds {:.1} GB in a directory named for {}, which its publisher revoked; CAR never removes it on its own.",
180 copy.path.display(),
181 copy.bytes as f64 / 1e9,
182 copy.model_id
183 );
184 }
185 let user_orphans: Vec<&str> = portfolio
186 .orphans
187 .iter()
188 .filter(|o| o.owner == crate::retire::OrphanOwner::User)
189 .map(|o| o.repo.as_str())
190 .collect();
191 if !user_orphans.is_empty() {
192 let _ = writeln!(
193 out,
194 "{} Hugging Face repos no CAR model uses are yours and are never touched.",
195 user_orphans.len()
196 );
197 }
198 out
199}
200
201pub fn trim_repetition(text: &str) -> String {
204 let mut seen = std::collections::HashSet::new();
205 let mut kept = Vec::new();
206 for line in text.lines() {
207 let key = line.trim().to_lowercase();
208 if key.len() >= 24 && !seen.insert(key) {
209 break;
210 }
211 kept.push(line);
212 }
213 while kept.last().is_some_and(|line| line.trim().is_empty()) {
214 kept.pop();
215 }
216 kept.join("\n")
217}
218
219const MODEL_FAMILIES: [&str; 9] = [
222 "llama", "gpt", "mistral", "gemma", "deepseek", "phi", "claude", "grok", "qwen",
223];
224
225pub fn ungrounded_ids(reply: &str, evidence: &str) -> Vec<String> {
231 let evidence = evidence.to_lowercase();
232 let mut out: Vec<String> = Vec::new();
233 for raw in reply.split(|c: char| c.is_whitespace() || "()[]{}<>,;\"'`*".contains(c)) {
234 let token = raw.trim_end_matches(['.', ':', '!', '?']);
235 let parts: Vec<&str> = token.split('/').collect();
236 let valid = |part: &str| {
237 part.len() >= 2
238 && part.chars().any(|c| c.is_ascii_alphabetic())
239 && part
240 .chars()
241 .all(|c| c.is_ascii_alphanumeric() || "._:-".contains(c))
242 };
243 let is_id = parts.len() >= 2
244 && parts.iter().all(|p| valid(p))
245 && parts
246 .last()
247 .is_some_and(|name| name.chars().any(|c| c.is_ascii_digit()));
248 if !is_id {
249 continue;
250 }
251 let lower = token.to_lowercase();
252 if !evidence.contains(&lower) && !out.iter().any(|o| o.eq_ignore_ascii_case(token)) {
253 out.push(token.to_string());
254 }
255 }
256 let reply = reply.to_lowercase();
257 for family in MODEL_FAMILIES {
258 let named = reply
259 .split(|c: char| !c.is_ascii_alphanumeric())
260 .any(|word| word.starts_with(family));
261 if named && !evidence.contains(family) {
262 out.push(format!("{family} (family)"));
263 }
264 }
265 out
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn a_loop_is_cut_at_its_first_repeat() {
274 let reply = "Keep qwen3-8b, it is in use.\n\
275 You could retire the vision model to free space.\n\
276 Keep qwen3-8b, it is in use.\n\
277 You could retire the vision model to free space.\n";
278 assert_eq!(
279 trim_repetition(reply),
280 "Keep qwen3-8b, it is in use.\nYou could retire the vision model to free space."
281 );
282 assert_eq!(trim_repetition("- a\n- a\nok"), "- a\n- a\nok");
284 }
285
286 #[test]
287 fn only_ids_missing_from_the_evidence_are_flagged() {
288 let evidence = "- Qwen3-8B (mlx/qwen3-8b:4bit): in use";
289 let reply = "Keep mlx/qwen3-8b:4bit. Consider meta/llama-4-70b, \
290 and see mlx/qwen3-8b:4bit again; 24/7 uptime; a/b test; \
291 read/write and/or input/output.";
292 assert_eq!(
293 ungrounded_ids(reply, evidence),
294 vec!["meta/llama-4-70b".to_string(), "llama (family)".to_string()]
295 );
296 assert!(ungrounded_ids("(mlx/qwen3-8b:4bit).", evidence).is_empty());
297 assert_eq!(
299 ungrounded_ids("Try Mistral Large or parslee/openrouter/fast-2.", evidence),
300 vec![
301 "parslee/openrouter/fast-2".to_string(),
302 "mistral (family)".to_string()
303 ]
304 );
305 assert!(ungrounded_ids("Qwen3 is fine.", evidence).is_empty());
307 }
308
309 #[test]
310 fn the_summary_states_the_facts_without_a_model() {
311 use crate::maintenance::{Decision, MaintenancePlan};
312 use crate::portfolio::{
313 Assessment, PortfolioPolicy, RecentAction, RetireSummary, RevokedPin,
314 };
315 let portfolio = Portfolio {
316 policy: PortfolioPolicy::new(100, Some(0)),
317 models: vec![Assessment {
318 model_id: "mlx/old:4bit".into(),
319 name: "Old".into(),
320 verdict: Verdict::Idle,
321 action: PortfolioAction::Retire,
322 evidence: vec!["unused 40 days (threshold 30)".into()],
323 kept: false,
324 last_used: None,
325 retire: Some(RetireSummary {
326 freed_bytes: 4_300_000_000,
327 digest: "d".into(),
328 refusals: Vec::new(),
329 also_retires: Vec::new(),
330 }),
331 }],
332 reclaimable_bytes: 4_300_000_000,
333 orphans: Vec::new(),
334 recent_actions: vec![RecentAction {
335 at: 90,
336 kind: "retire".into(),
337 subject: "mlx/gone:4bit".into(),
338 detail: String::new(),
339 undo: Some("car models pull mlx/gone:4bit".into()),
340 use_case: None,
341 prior: None,
342 interrupted: false,
343 }],
344 revoked_pins: vec![RevokedPin {
345 pin: "Bad Model".into(),
346 model_id: "mlx/bad:4bit".into(),
347 agents: vec!["digest".into(), "monthly".into()],
348 }],
349 revoked_copies: vec![crate::retire::RevokedCopy {
350 model_id: "mlx/bad:4bit".into(),
351 path: "/models/bad".into(),
352 bytes: 2_000_000_000,
353 }],
354 };
355 let report = MaintenanceReport {
356 plan: MaintenancePlan {
357 suggest: vec![Decision {
358 model_id: "mlx/old:4bit".into(),
359 verdict: Verdict::Idle,
360 freed_bytes: 4_300_000_000,
361 digest: "d".into(),
362 reason: String::new(),
363 }],
364 ..Default::default()
365 },
366 ..Default::default()
367 };
368 let summary = summarize(&portfolio, &report, "everyday");
369 assert!(summary.contains("Old (mlx/old:4bit): unused"), "{summary}");
370 assert!(summary.contains("free 4.3 GB"), "{summary}");
371 assert!(
372 summary.contains("Maintenance would retire: mlx/old:4bit (4.3 GB)"),
373 "{summary}"
374 );
375 assert!(
376 summary.contains("undo with `car models pull mlx/gone:4bit`"),
377 "{summary}"
378 );
379 assert!(
380 summary.contains("Memory policy for local models: everyday"),
381 "{summary}"
382 );
383 assert!(
384 summary.contains(
385 "revoked mlx/bad:4bit; agents still pin it (as `Bad Model`): digest, monthly."
386 ),
387 "{summary}"
388 );
389 assert!(
390 summary.contains("/models/bad holds 2.0 GB in a directory named for mlx/bad:4bit"),
391 "{summary}"
392 );
393 }
394}