use serde::{Deserialize, Serialize};
use crate::hardware::HardwareInfo;
use crate::intent::{Privacy, QualityTier, UseCase};
use crate::nudge::NudgeState;
use crate::recommend::{recommend, FitStatus, Recommendation};
use crate::schema::ModelSchema;
use crate::update_prefs::{UpdatePolicy, UpdatePreferences};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConciergeSuggestion {
pub use_case: UseCase,
pub model_id: String,
pub display_name: String,
pub download_mb: u64,
pub message: String,
pub dismiss_key: String,
}
pub const DEFAULT_CONCIERGE_THROTTLE_SECS: u64 = 7 * 24 * 60 * 60;
pub const DEFAULT_WATCHED_USE_CASES: &[UseCase] = &[UseCase::Assistant, UseCase::Coding];
fn use_case_slug(use_case: UseCase) -> &'static str {
match use_case {
UseCase::Assistant => "assistant",
UseCase::Coding => "coding",
UseCase::Summarize => "summarize",
UseCase::Vision => "vision",
UseCase::Transcription => "transcription",
UseCase::Search => "search",
}
}
fn dismiss_key(use_case: UseCase, model_id: &str) -> String {
format!("concierge:{}=>{model_id}", use_case_slug(use_case))
}
#[allow(clippy::too_many_arguments)]
pub fn decide_concierge(
models: &[&ModelSchema],
hw: &HardwareInfo,
use_cases: &[UseCase],
tier: QualityTier,
prefs: &UpdatePreferences,
state: &NudgeState,
now_secs: u64,
throttle_secs: u64,
inference_active: bool,
) -> Vec<ConciergeSuggestion> {
if inference_active || matches!(prefs.policy, UpdatePolicy::Off) {
return Vec::new();
}
let throttled = state.last_concierge_secs != 0
&& now_secs.saturating_sub(state.last_concierge_secs) < throttle_secs;
if throttled {
return Vec::new();
}
let mut out = Vec::new();
for &use_case in use_cases {
let set = recommend(models, hw, use_case, tier, Privacy::OnDevice);
if set.picks.iter().any(|p| p.already_installed) {
continue;
}
let pick = match set
.picks
.iter()
.find(|p| !p.already_installed && p.is_local && p.fit == FitStatus::Fits)
{
Some(p) => p,
None => continue,
};
let key = dismiss_key(use_case, &pick.model_id);
if state.dismissed.iter().any(|k| k == &key) {
continue;
}
out.push(ConciergeSuggestion {
use_case,
model_id: pick.model_id.clone(),
display_name: pick.display_name.clone(),
download_mb: pick.download_mb,
message: suggestion_message(use_case, pick),
dismiss_key: key,
});
}
out
}
fn suggestion_message(use_case: UseCase, pick: &Recommendation) -> String {
let purpose = match use_case {
UseCase::Assistant => "chat & general help",
UseCase::Coding => "coding",
UseCase::Summarize => "summarizing",
UseCase::Vision => "understanding images",
UseCase::Transcription => "transcription",
UseCase::Search => "semantic search",
};
let mb = pick.download_mb;
let size = if mb >= 1024 {
format!("{:.1} GB", mb as f64 / 1024.0)
} else {
format!("{mb} MB")
};
format!(
"You have no model for {purpose}. {} fits your machine ({size}) — install it?",
pick.display_name
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hardware::GpuBackend;
use crate::schema::{
CostModel, ModelCapability, ModelSchema, ModelSource, PerformanceEnvelope, TrustTier,
};
fn model(id: &str, installed: bool, download_mb: u64, caps: &[ModelCapability]) -> ModelSchema {
ModelSchema {
id: id.into(),
name: id.into(),
provider: "qwen".into(),
family: "qwen3".into(),
version: String::new(),
capabilities: caps.to_vec(),
context_length: 32768,
param_count: "4B".into(),
quantization: Some("Q4_K_M".into()),
performance: PerformanceEnvelope::default(),
cost: CostModel {
size_mb: Some(download_mb),
ram_mb: Some(download_mb),
..Default::default()
},
source: ModelSource::Local {
hf_repo: "x/y".into(),
hf_filename: "m.gguf".into(),
tokenizer_repo: "x/y".into(),
},
tags: vec![],
supported_params: vec![],
public_benchmarks: vec![],
trust_tier: TrustTier::Curated,
deprecated: false,
available: installed,
}
}
fn prefs(policy: UpdatePolicy) -> UpdatePreferences {
UpdatePreferences {
policy,
..Default::default()
}
}
fn hw() -> HardwareInfo {
HardwareInfo {
os: "test".into(),
arch: "test".into(),
cpu_cores: 8,
total_ram_mb: 16_384,
gpu_backend: GpuBackend::Metal,
gpu_memory_mb: None,
gpu_devices: vec![],
recommended_model: String::new(),
recommended_context: 4096,
max_model_mb: 0,
}
}
#[test]
fn off_policy_suggests_nothing() {
let m = [model("chat-a", false, 2000, &[ModelCapability::Generate])];
let refs: Vec<&ModelSchema> = m.iter().collect();
let out = decide_concierge(
&refs,
&hw(),
&[UseCase::Assistant],
QualityTier::Balanced,
&prefs(UpdatePolicy::Off),
&NudgeState::default(),
100,
10,
false,
);
assert!(out.is_empty());
}
#[test]
fn active_inference_defers() {
let m = [model("chat-a", false, 2000, &[ModelCapability::Generate])];
let refs: Vec<&ModelSchema> = m.iter().collect();
let out = decide_concierge(
&refs,
&hw(),
&[UseCase::Assistant],
QualityTier::Balanced,
&prefs(UpdatePolicy::Notify),
&NudgeState::default(),
100,
10,
true,
);
assert!(out.is_empty());
}
#[test]
fn suggests_when_lane_unserved() {
let m = [model("chat-a", false, 2000, &[ModelCapability::Generate])];
let refs: Vec<&ModelSchema> = m.iter().collect();
let out = decide_concierge(
&refs,
&hw(),
&[UseCase::Assistant],
QualityTier::Balanced,
&prefs(UpdatePolicy::Notify),
&NudgeState::default(),
100,
10,
false,
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].use_case, UseCase::Assistant);
assert_eq!(out[0].model_id, "chat-a");
assert!(out[0].message.contains("install it?"));
}
#[test]
fn served_lane_suggests_nothing() {
let m = [model("chat-a", true, 2000, &[ModelCapability::Generate])];
let refs: Vec<&ModelSchema> = m.iter().collect();
let out = decide_concierge(
&refs,
&hw(),
&[UseCase::Assistant],
QualityTier::Balanced,
&prefs(UpdatePolicy::Notify),
&NudgeState::default(),
100,
10,
false,
);
assert!(out.is_empty());
}
#[test]
fn dismissed_suggestion_not_repeated() {
let m = [model("chat-a", false, 2000, &[ModelCapability::Generate])];
let refs: Vec<&ModelSchema> = m.iter().collect();
let mut state = NudgeState::default();
state.dismiss(&dismiss_key(UseCase::Assistant, "chat-a"));
let out = decide_concierge(
&refs,
&hw(),
&[UseCase::Assistant],
QualityTier::Balanced,
&prefs(UpdatePolicy::Notify),
&state,
100,
10,
false,
);
assert!(out.is_empty());
}
#[test]
fn throttled_within_window() {
let m = [model("chat-a", false, 2000, &[ModelCapability::Generate])];
let refs: Vec<&ModelSchema> = m.iter().collect();
let state = NudgeState {
last_concierge_secs: 95,
..Default::default()
};
let out = decide_concierge(
&refs,
&hw(),
&[UseCase::Assistant],
QualityTier::Balanced,
&prefs(UpdatePolicy::Notify),
&state,
100,
10,
false,
);
assert!(out.is_empty());
}
#[test]
fn not_throttled_by_upgrade_nudge_field() {
let m = [model("chat-a", false, 2000, &[ModelCapability::Generate])];
let refs: Vec<&ModelSchema> = m.iter().collect();
let state = NudgeState {
last_nudge_secs: 99, last_concierge_secs: 0, ..Default::default()
};
let out = decide_concierge(
&refs,
&hw(),
&[UseCase::Assistant],
QualityTier::Balanced,
&prefs(UpdatePolicy::Notify),
&state,
100,
10,
false,
);
assert_eq!(out.len(), 1, "concierge must fire regardless of the upgrade nudge's window");
}
#[test]
fn dismiss_key_is_stable_slug_not_debug() {
assert_eq!(dismiss_key(UseCase::Assistant, "chat-a"), "concierge:assistant=>chat-a");
assert_eq!(dismiss_key(UseCase::Coding, "code-x"), "concierge:coding=>code-x");
}
}