1use qualia_core_db::resource_catalog::ResourceCatalog;
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6use sysinfo::System;
7
8use crate::context_binding::list_installed_ontology_ids;
9use crate::model_preferences::list_installed_model_ids;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum DeviceTier {
14 Edge,
15 Mainstream,
16 HighPerformance,
17}
18
19impl DeviceTier {
20 pub fn from_ram_gb(ram_gb: f64) -> Self {
21 if ram_gb < 6.0 {
22 Self::Edge
23 } else if ram_gb < 16.0 {
24 Self::Mainstream
25 } else {
26 Self::HighPerformance
27 }
28 }
29
30 pub fn label(self) -> &'static str {
31 match self {
32 Self::Edge => "edge",
33 Self::Mainstream => "mainstream",
34 Self::HighPerformance => "high_performance",
35 }
36 }
37}
38
39#[derive(Debug, Clone, Default, Serialize, Deserialize)]
40pub struct DeviceProfileInput {
41 #[serde(default)]
42 pub ram_gb: Option<f64>,
43 #[serde(default)]
44 pub has_webgpu: bool,
45 #[serde(default)]
46 pub cpu_cores: Option<u32>,
47 #[serde(default)]
48 pub platform: Option<String>,
49 #[serde(default)]
50 pub user_agent: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct DeviceProfile {
55 pub tier: DeviceTier,
56 pub ram_gb: f64,
57 pub has_webgpu: bool,
58 pub cpu_cores: u32,
59 pub platform: String,
60 pub source: String,
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64pub struct DesignContextInput {
65 #[serde(default)]
66 pub prompt: String,
67 #[serde(default)]
68 pub domains: Vec<String>,
69 #[serde(default)]
70 pub keywords: Vec<String>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum AssetKind {
76 Llm,
77 Ontology,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct InstallAction {
82 pub kind: String,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 pub job_payload: Option<serde_json::Value>,
85 pub cli_hint: String,
86 pub native_note: String,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct AssetRecommendation {
91 pub kind: AssetKind,
92 pub id: String,
93 pub name: String,
94 pub reason: String,
95 pub size_mb: f64,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub ram_estimate_mb: Option<u32>,
98 pub already_installed: bool,
99 pub priority: u8,
100 pub install: InstallAction,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct AssetRecommendationsResponse {
105 pub device: DeviceProfile,
106 pub inferred_domains: Vec<String>,
107 pub llms: Vec<AssetRecommendation>,
108 pub ontologies: Vec<AssetRecommendation>,
109 pub wiring_notes: Vec<String>,
110}
111
112pub fn native_device_profile() -> DeviceProfile {
113 let mut sys = System::new_all();
114 sys.refresh_memory();
115 let ram_gb = sys.total_memory() as f64 / 1024.0 / 1024.0 / 1024.0;
116 DeviceProfile {
117 tier: DeviceTier::from_ram_gb(ram_gb),
118 ram_gb,
119 has_webgpu: false,
120 cpu_cores: sys.cpus().len() as u32,
121 platform: std::env::consts::OS.to_string(),
122 source: "native_sysinfo".to_string(),
123 }
124}
125
126pub fn device_profile_from_input(input: &DeviceProfileInput) -> DeviceProfile {
127 if let Some(ram) = input.ram_gb {
128 return DeviceProfile {
129 tier: DeviceTier::from_ram_gb(ram),
130 ram_gb: ram,
131 has_webgpu: input.has_webgpu,
132 cpu_cores: input.cpu_cores.unwrap_or(4),
133 platform: input
134 .platform
135 .clone()
136 .unwrap_or_else(|| "browser".to_string()),
137 source: "client_reported".to_string(),
138 };
139 }
140 native_device_profile()
141}
142
143pub fn infer_domains_from_text(text: &str) -> Vec<String> {
144 let lower = text.to_lowercase();
145 let mut domains = Vec::new();
146 let rules: &[(&str, &[&str])] = &[
147 (
148 "product",
149 &[
150 "design", "product", "assembly", "module", "part", "housing", "switch", "socket",
151 "gadget", "device",
152 ],
153 ),
154 (
155 "electrical",
156 &[
157 "electric",
158 "electrician",
159 "power",
160 "mains",
161 "voltage",
162 "wiring",
163 "powerpoint",
164 ],
165 ),
166 (
167 "iot",
168 &["sensor", "wifi", "smart", "mcu", "bluetooth", "home"],
169 ),
170 (
171 "health",
172 &[
173 "medical",
174 "clinical",
175 "anatomy",
176 "patient",
177 "diagnosis",
178 "dicom",
179 ],
180 ),
181 (
182 "legal",
183 &["contract", "obligation", "rights", "policy", "consent"],
184 ),
185 (
186 "geography",
187 &["map", "location", "geo", "place", "building"],
188 ),
189 ("linguistics", &["word", "language", "lexicon", "ontology"]),
190 ];
191 for (domain, kws) in rules {
192 if kws.iter().any(|kw| lower.contains(kw)) {
193 domains.push(domain.to_string());
194 }
195 }
196 if domains.is_empty() {
197 domains.push("general".to_string());
198 }
199 domains.sort();
200 domains.dedup();
201 domains
202}
203
204fn llm_fits_ram(ram_gb: f64, need_mb: u32) -> bool {
205 ram_gb * 1024.0 * 0.72 >= need_mb as f64
206}
207
208fn score_llm(
209 catalog_entry: &qualia_core_db::resource_catalog::LLMResource,
210 device: &DeviceProfile,
211 installed: &[String],
212) -> Option<(u8, String)> {
213 let need = catalog_entry.ram_estimate_mb.or(catalog_entry.size_mb)?;
214 if !llm_fits_ram(device.ram_gb, need) {
215 return None;
216 }
217 let rec = catalog_entry.recommended_for.as_deref().unwrap_or(&[]);
218 let mut score: u8 = 40;
219 let mut reasons = Vec::new();
220
221 match device.tier {
222 DeviceTier::Edge => {
223 if rec.iter().any(|r| r == "very_low_ram" || r == "edge") {
224 score += 35;
225 reasons.push("edge tier match");
226 }
227 if need <= 1500 {
228 score += 15;
229 reasons.push("fits low RAM");
230 }
231 }
232 DeviceTier::Mainstream => {
233 if rec
234 .iter()
235 .any(|r| r == "edge" || r == "low_ram" || r == "general")
236 {
237 score += 25;
238 reasons.push("mainstream fit");
239 }
240 }
241 DeviceTier::HighPerformance => {
242 score += 10;
243 if need >= 4000 {
244 score += 20;
245 reasons.push("room for larger model");
246 }
247 }
248 }
249
250 if device.has_webgpu && need <= 1200 {
251 score += 10;
252 reasons.push("browser WebGPU friendly");
253 }
254
255 if installed.iter().any(|id| id == &catalog_entry.id) {
256 score = score.saturating_sub(30);
257 reasons.push("already installed");
258 }
259
260 if reasons.is_empty() {
261 reasons.push("catalog match");
262 }
263 Some((score.min(100), reasons.join("; ")))
264}
265
266fn ontology_domain_match(
267 ont: &qualia_core_db::resource_catalog::OntologyResource,
268 domains: &[String],
269) -> bool {
270 if domains.contains(&"general".to_string())
271 && ont
272 .tags
273 .as_ref()
274 .is_some_and(|t| t.contains(&"core".to_string()))
275 {
276 return true;
277 }
278 if let Some(d) = &ont.domain {
279 if domains.iter().any(|x| x == d) {
280 return true;
281 }
282 }
283 let tags = ont.tags.as_deref().unwrap_or(&[]);
284 domains.iter().any(|d| tags.iter().any(|t| t == d))
285}
286
287fn score_ontology(
288 ont: &qualia_core_db::resource_catalog::OntologyResource,
289 device: &DeviceProfile,
290 domains: &[String],
291 installed: &[String],
292) -> Option<(u8, String)> {
293 let size = ont.size_estimate_mb.unwrap_or(1.0);
294 let max_size = match device.tier {
295 DeviceTier::Edge => 3.0,
296 DeviceTier::Mainstream => 15.0,
297 DeviceTier::HighPerformance => 64.0,
298 };
299 if size > max_size {
300 return None;
301 }
302
303 let is_core = ont
304 .tags
305 .as_ref()
306 .is_some_and(|t| t.contains(&"core".to_string()));
307 let domain_hit = ontology_domain_match(ont, domains);
308 if !is_core && !domain_hit {
309 return None;
310 }
311
312 let mut score: u8 = if is_core { 70 } else { 45 };
313 let mut reasons = Vec::new();
314 if is_core {
315 reasons.push("core vocabulary");
316 }
317 if domain_hit {
318 score += 20;
319 reasons.push("domain match");
320 }
321 if size < 1.0 {
322 score += 10;
323 reasons.push("lightweight");
324 }
325 if installed.iter().any(|id| id == &ont.id) {
326 score = score.saturating_sub(25);
327 reasons.push("already installed");
328 }
329 Some((score.min(100), reasons.join("; ")))
330}
331
332pub fn recommend_assets(
333 catalog: &ResourceCatalog,
334 device: &DeviceProfile,
335 design: &DesignContextInput,
336 storage_root: Option<&Path>,
337) -> AssetRecommendationsResponse {
338 let mut domains = design.domains.clone();
339 if domains.is_empty() {
340 domains = infer_domains_from_text(&design.prompt);
341 }
342
343 let installed_llms = storage_root
344 .map(list_installed_model_ids)
345 .unwrap_or_default();
346 let installed_onts = storage_root
347 .map(list_installed_ontology_ids)
348 .unwrap_or_default();
349
350 let mut llms: Vec<AssetRecommendation> = catalog
351 .llms
352 .iter()
353 .filter_map(|llm| {
354 let (priority, reason) = score_llm(llm, device, &installed_llms)?;
355 let installed = installed_llms.iter().any(|id| id == &llm.id);
356 Some(AssetRecommendation {
357 kind: AssetKind::Llm,
358 id: llm.id.clone(),
359 name: llm.name.clone(),
360 reason,
361 size_mb: llm.size_mb.unwrap_or(0) as f64,
362 ram_estimate_mb: llm.ram_estimate_mb.or(llm.size_mb),
363 already_installed: installed,
364 priority,
365 install: InstallAction {
366 kind: "download_gguf".to_string(),
367 job_payload: None,
368 cli_hint: format!("qualia resources import llm {}", llm.id),
369 native_note: "Flutter/desktop: LLM Hub → install manifest; activates via model_lifecycle.".to_string(),
370 },
371 })
372 })
373 .collect();
374 llms.sort_by(|a, b| b.priority.cmp(&a.priority));
375 llms.truncate(4);
376
377 let mut ontologies: Vec<AssetRecommendation> = catalog
378 .ontologies
379 .iter()
380 .filter_map(|ont| {
381 let (priority, reason) = score_ontology(ont, device, &domains, &installed_onts)?;
382 let installed = installed_onts.iter().any(|id| id == &ont.id);
383 Some(AssetRecommendation {
384 kind: AssetKind::Ontology,
385 id: ont.id.clone(),
386 name: ont.name.clone(),
387 reason,
388 size_mb: ont.size_estimate_mb.unwrap_or(0.5),
389 ram_estimate_mb: None,
390 already_installed: installed,
391 priority,
392 install: InstallAction {
393 kind: "ontology_catalog_import".to_string(),
394 job_payload: Some(serde_json::json!({
395 "kind": "ontology_catalog_import",
396 "ontology_id": ont.id
397 })),
398 cli_hint: format!("qualia resources import ontology {}", ont.id),
399 native_note:
400 "Settings portal :8080 can enqueue the same job via POST /api/jobs."
401 .to_string(),
402 },
403 })
404 })
405 .collect();
406 ontologies.sort_by(|a, b| b.priority.cmp(&a.priority));
407 ontologies.truncate(6);
408
409 let wiring_notes = vec![
410 "Native runtime: graph daemon :4242 + installed ontologies improve SPARQL enrichment and chat grounding.".to_string(),
411 "LLM: local GGUF via qualia-client-core model_lifecycle; governed by orchestrate_inference (intent + provenance).".to_string(),
412 "Design Studio: qualia.design JSON → design_encode_wasm → Qualia Portal tensor SOA.".to_string(),
413 "Optional: connect http://127.0.0.1:8080 for one-click ontology import jobs and authoritative device RAM from desktop.".to_string(),
414 ];
415
416 AssetRecommendationsResponse {
417 device: device.clone(),
418 inferred_domains: domains,
419 llms,
420 ontologies,
421 wiring_notes,
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 #[test]
430 fn infers_product_and_electrical_domains() {
431 let d = infer_domains_from_text("two part smart powerpoint installed by electrician");
432 assert!(d.contains(&"product".to_string()));
433 assert!(d.contains(&"electrical".to_string()));
434 }
435
436 #[test]
437 fn edge_tier_from_low_ram() {
438 assert_eq!(DeviceTier::from_ram_gb(4.0), DeviceTier::Edge);
439 }
440}