Skip to main content

qualia_client_core/
asset_recommendations.rs

1//! Device-aware LLM + ontology recommendations for Design Studio and native runtime.
2
3use 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}