1use crate::error::{Result, UserError};
11use crate::model::{lookup_model, model_support_tier, ModelSupportTier};
12use serde::{Deserialize, Serialize};
13
14pub const PROFILE_EVIDENCE_VERSION: &str = "0.0.22-observatory-v1";
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum QualityProfile {
25 Speed,
27 Balance,
29 Quality,
31}
32
33impl QualityProfile {
34 pub fn parse(s: &str) -> Result<Self> {
35 match s.trim().to_ascii_lowercase().as_str() {
36 "speed" | "fast" => Ok(Self::Speed),
37 "balance" | "balanced" | "default" => Ok(Self::Balance),
38 "quality" | "accurate" | "high" => Ok(Self::Quality),
39 other => Err(UserError::Other {
40 message: format!(
41 "unknown quality profile '{other}'\n \
42 Hint: use speed | balance | quality"
43 ),
44 }
45 .into()),
46 }
47 }
48
49 pub fn as_str(self) -> &'static str {
50 match self {
51 Self::Speed => "speed",
52 Self::Balance => "balance",
53 Self::Quality => "quality",
54 }
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60pub struct ProfileResolution {
61 pub profile: String,
62 pub model: String,
63 pub evidence_version: String,
64 pub language: String,
65 pub reasons: Vec<String>,
66 pub alternatives: Vec<String>,
67 pub approx_bytes: u64,
68 pub support_tier: String,
69}
70
71pub fn resolve_profile(profile: QualityProfile, language: &str) -> Result<ProfileResolution> {
78 let lang = language.trim().to_ascii_lowercase();
79 let english = lang == "en" || lang == "eng" || lang.starts_with("en-");
80
81 let (model, reasons): (&str, Vec<String>) = match profile {
82 QualityProfile::Speed => {
83 if english {
84 (
85 "tiny.en-q5_1",
86 vec![
87 "speed profile prefers smallest supported download".into(),
88 "language is English → english-only quantised tiny".into(),
89 ],
90 )
91 } else {
92 (
93 "tiny-q5_1",
94 vec![
95 "speed profile prefers smallest supported download".into(),
96 "language is not fixed English → multilingual tiny-q5_1".into(),
97 ],
98 )
99 }
100 }
101 QualityProfile::Balance => {
102 if english {
103 (
104 "base.en",
105 vec![
106 "balance profile tracks the product default family".into(),
107 "language is English → base.en".into(),
108 format!(
109 "evidence_version={PROFILE_EVIDENCE_VERSION} does not change global default"
110 ),
111 ],
112 )
113 } else {
114 (
115 "base",
116 vec![
117 "balance profile tracks the product default model `base`".into(),
118 format!("evidence_version={PROFILE_EVIDENCE_VERSION} (STT observatory)"),
119 ],
120 )
121 }
122 }
123 QualityProfile::Quality => {
124 if english {
125 (
126 "small.en",
127 vec![
128 "quality profile selects next supported size above base".into(),
129 "language is English → small.en".into(),
130 "experimental large-v3-q5_0 is never selected by profiles".into(),
131 ],
132 )
133 } else {
134 (
135 "small",
136 vec![
137 "quality profile selects next supported size above base".into(),
138 "experimental large-v3-q5_0 is never selected by profiles".into(),
139 ],
140 )
141 }
142 }
143 };
144
145 let info = lookup_model(model)?;
146 match model_support_tier(info.name) {
147 ModelSupportTier::Supported => {}
148 ModelSupportTier::Experimental => {
149 return Err(UserError::Other {
150 message: format!(
151 "internal error: profile resolved experimental model '{}'",
152 info.name
153 ),
154 }
155 .into());
156 }
157 }
158
159 let candidates: &[&str] = match profile {
160 QualityProfile::Speed => &["tiny", "tiny-q8_0", "base-q5_1"],
161 QualityProfile::Balance => &["base-q5_1", "base-q8_0", "small-q5_1"],
162 QualityProfile::Quality => &["small-q5_1", "medium", "large-v3-turbo"],
163 };
164 let alternatives: Vec<String> = candidates
165 .iter()
166 .copied()
167 .filter(|m| lookup_model(m).is_ok() && model_support_tier(m) == ModelSupportTier::Supported)
168 .filter(|m| *m != info.name)
169 .map(str::to_string)
170 .collect();
171
172 Ok(ProfileResolution {
173 profile: profile.as_str().into(),
174 model: info.name.into(),
175 evidence_version: PROFILE_EVIDENCE_VERSION.into(),
176 language: language.into(),
177 reasons,
178 alternatives,
179 approx_bytes: info.approx_bytes,
180 support_tier: "supported".into(),
181 })
182}
183
184pub fn format_recommendation(res: &ProfileResolution) -> String {
186 let mut out = String::new();
187 out.push_str(&format!(
188 "profile={} → model={} (tier={}, ~{} MB)\n",
189 res.profile,
190 res.model,
191 res.support_tier,
192 res.approx_bytes / 1_000_000
193 ));
194 out.push_str(&format!("evidence_version={}\n", res.evidence_version));
195 out.push_str(&format!("language={}\n", res.language));
196 out.push_str("reasons:\n");
197 for r in &res.reasons {
198 out.push_str(&format!(" - {r}\n"));
199 }
200 if !res.alternatives.is_empty() {
201 out.push_str(&format!("alternatives: {}\n", res.alternatives.join(", ")));
202 }
203 out.push_str("override: pass --model <id> to force a catalogue model\n");
204 out
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn speed_never_experimental() {
213 for lang in ["auto", "en", "fr"] {
214 let r = resolve_profile(QualityProfile::Speed, lang).unwrap();
215 assert_eq!(model_support_tier(&r.model), ModelSupportTier::Supported);
216 assert!(!r.model.contains("large-v3-q5_0"));
217 }
218 }
219
220 #[test]
221 fn balance_defaults_to_base_family() {
222 let r = resolve_profile(QualityProfile::Balance, "auto").unwrap();
223 assert_eq!(r.model, "base");
224 let en = resolve_profile(QualityProfile::Balance, "en").unwrap();
225 assert_eq!(en.model, "base.en");
226 }
227
228 #[test]
229 fn quality_uses_small_not_experimental_large() {
230 let r = resolve_profile(QualityProfile::Quality, "auto").unwrap();
231 assert_eq!(r.model, "small");
232 assert_ne!(r.model, "large-v3-q5_0");
233 }
234
235 #[test]
236 fn parse_aliases() {
237 assert_eq!(
238 QualityProfile::parse("fast").unwrap(),
239 QualityProfile::Speed
240 );
241 assert_eq!(
242 QualityProfile::parse("balanced").unwrap(),
243 QualityProfile::Balance
244 );
245 }
246}