Skip to main content

qualia_client_core/
anatomy_context.rs

1//! Builds Anatomy representation context by merging knowledge catalog, chat text,
2//! and optional live daemon query results.
3
4use serde::{Deserialize, Serialize};
5use serde_json::json;
6use std::collections::{BTreeMap, BTreeSet};
7use std::path::PathBuf;
8
9#[derive(Debug, Deserialize)]
10struct ConditionCatalog {
11    #[serde(default)]
12    conditions: BTreeMap<String, ConditionEntry>,
13}
14
15#[derive(Debug, Deserialize)]
16struct ConditionEntry {
17    #[serde(default)]
18    primary_system: Option<String>,
19    #[serde(rename = "primarySystem", default)]
20    primary_system_camel: Option<String>,
21    #[serde(default)]
22    ontology_iri: Option<String>,
23    #[serde(rename = "ontologyIri", default)]
24    ontology_iri_camel: Option<String>,
25}
26
27impl ConditionEntry {
28    fn primary_system_label(&self) -> Option<String> {
29        self.primary_system
30            .clone()
31            .or_else(|| self.primary_system_camel.clone())
32    }
33}
34
35#[derive(Debug, Serialize)]
36pub struct AnatomyGraphContext {
37    pub conditions: Vec<String>,
38    pub systems: Vec<String>,
39    pub condition_impact_map: BTreeMap<String, String>,
40    pub source: String,
41    pub daemon_match_count: u64,
42    pub daemon_reachable: bool,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub dicom_overlay: Option<qualia_core_db::dicom::DicomOverlaySpec>,
45}
46
47fn anatomy_qapp_dir(qapp_name: &str) -> Result<PathBuf, String> {
48    let state = crate::state::APP_STATE
49        .get()
50        .ok_or("APP_STATE not initialized")?;
51    let data_dir = state.config.lock().unwrap().storage_path.clone();
52    let qapp_dir = crate::qapp_paths::qapps_dir(&data_dir).join(qapp_name);
53    if !qapp_dir.exists() {
54        return Err(format!("Qapp directory not found: {qapp_name}"));
55    }
56    Ok(qapp_dir)
57}
58
59fn load_dicom_organ_matchers(qapp_name: &str) -> Vec<qualia_core_db::dicom::DicomTagMatcher> {
60    let path = match anatomy_qapp_dir(qapp_name) {
61        Ok(dir) => dir.join("Knowledge/dicom-organ-map.json"),
62        Err(_) => return qualia_core_db::dicom::default_organ_matchers(),
63    };
64    if !path.exists() {
65        return qualia_core_db::dicom::default_organ_matchers();
66    }
67    let content = match std::fs::read_to_string(&path) {
68        Ok(content) => content,
69        Err(_) => return qualia_core_db::dicom::default_organ_matchers(),
70    };
71    match serde_json::from_str::<qualia_core_db::dicom::DicomOrganMapFile>(&content) {
72        Ok(map) if !map.tag_matchers.is_empty() => map.tag_matchers,
73        _ => qualia_core_db::dicom::default_organ_matchers(),
74    }
75}
76
77fn load_condition_catalog(qapp_name: &str) -> Result<ConditionCatalog, String> {
78    let path = anatomy_qapp_dir(qapp_name)?.join("Knowledge/condition-map.json");
79    if !path.exists() {
80        return Ok(ConditionCatalog {
81            conditions: BTreeMap::new(),
82        });
83    }
84    let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
85    serde_json::from_str(&content).map_err(|e| format!("Invalid condition-map.json: {e}"))
86}
87
88fn condition_aliases(label: &str) -> Vec<String> {
89    let lower = label.to_lowercase();
90    let mut aliases = vec![lower.clone()];
91    match lower.as_str() {
92        "type 2 diabetes mellitus" => {
93            aliases.extend(["diabetes", "type 2 diabetes", "t2dm"].map(str::to_string))
94        }
95        "type 1 diabetes mellitus" => {
96            aliases.extend(["type 1 diabetes", "t1dm"].map(str::to_string))
97        }
98        "chronic kidney disease (ckd)" => {
99            aliases.extend(["ckd", "chronic kidney disease", "kidney disease"].map(str::to_string))
100        }
101        "chronic obstructive pulmonary disease (copd)" => {
102            aliases.extend(["copd", "emphysema"].map(str::to_string))
103        }
104        "non-alcoholic fatty liver disease (nafld)" => {
105            aliases.extend(["nafld", "fatty liver"].map(str::to_string))
106        }
107        "major depressive disorder" => {
108            aliases.extend(["depression", "depressive"].map(str::to_string))
109        }
110        "obstructive sleep apnea" => aliases.extend(["sleep apnea", "osa"].map(str::to_string)),
111        "atrial fibrillation" => {
112            aliases.extend(["afib", "a-fib", "arrhythmia"].map(str::to_string))
113        }
114        "peripheral artery disease" => {
115            aliases.extend(["pad", "peripheral arterial"].map(str::to_string))
116        }
117        "rheumatoid arthritis" => aliases.extend(["ra", "rheumatoid"].map(str::to_string)),
118        "coronary artery disease" => aliases.extend(["cad", "coronary"].map(str::to_string)),
119        _ => {}
120    }
121    aliases
122}
123
124fn infer_conditions_from_text(text: &str, catalog: &ConditionCatalog) -> Vec<String> {
125    let haystack = text.to_lowercase();
126    let mut hits = Vec::new();
127
128    for (label, _) in &catalog.conditions {
129        let aliases = condition_aliases(label);
130        if aliases.iter().any(|alias| haystack.contains(alias)) {
131            hits.push(label.clone());
132        }
133    }
134
135    hits.sort();
136    hits.dedup();
137    hits
138}
139
140fn systems_from_conditions(catalog: &ConditionCatalog, conditions: &[String]) -> Vec<String> {
141    let mut systems = BTreeSet::new();
142    for label in conditions {
143        if let Some(entry) = catalog.conditions.get(label) {
144            if let Some(system) = entry.primary_system_label() {
145                systems.insert(system);
146            }
147        }
148    }
149    systems.into_iter().collect()
150}
151
152fn build_condition_impact_map(
153    catalog: &ConditionCatalog,
154    conditions: &[String],
155) -> BTreeMap<String, String> {
156    let mut map = BTreeMap::new();
157    for label in conditions {
158        let Some(entry) = catalog.conditions.get(label) else {
159            continue;
160        };
161        let Some(system) = entry.primary_system_label() else {
162            continue;
163        };
164        map.insert(
165            system.clone(),
166            format!("Chat/graph context linked {label} to this system."),
167        );
168    }
169    map
170}
171
172fn label_for_subject_hash(hash: u64, catalog: &ConditionCatalog) -> Option<String> {
173    if let Some(label) = qualia_core_db::daemon_graph::condition_label_for_subject_hash(hash) {
174        return Some(label.to_string());
175    }
176    for (label, entry) in &catalog.conditions {
177        let iri = entry
178            .ontology_iri
179            .as_ref()
180            .or(entry.ontology_iri_camel.as_ref())?;
181        if qualia_core_db::q_hash(iri) == hash {
182            return Some(label.clone());
183        }
184    }
185    None
186}
187
188fn conditions_from_daemon_graph(
189    body: &serde_json::Value,
190    catalog: &ConditionCatalog,
191) -> Vec<String> {
192    let Some(graph) = body.get("@graph").and_then(|g| g.as_array()) else {
193        return Vec::new();
194    };
195
196    let mut hits = BTreeSet::new();
197    for node in graph {
198        let Some(subject_str) = node.get("subject").and_then(|v| v.as_str()) else {
199            continue;
200        };
201        let Ok(hash) = subject_str.parse::<u64>() else {
202            continue;
203        };
204        if let Some(label) = label_for_subject_hash(hash, catalog) {
205            hits.insert(label);
206        }
207    }
208    hits.into_iter().collect()
209}
210
211fn query_daemon_context(qapp_name: &str, catalog: &ConditionCatalog) -> (bool, u64, Vec<String>) {
212    if crate::daemon_status() != "running" {
213        return (false, 0, Vec::new());
214    }
215
216    let port = crate::get_active_daemon_port();
217    if port == 0 {
218        return (false, 0, Vec::new());
219    }
220
221    let token = match crate::issue_qapp_session_token(qapp_name) {
222        Ok(token) => token,
223        Err(_) => return (false, 0, Vec::new()),
224    };
225
226    let client = match reqwest::blocking::Client::builder()
227        .timeout(std::time::Duration::from_secs(3))
228        .build()
229    {
230        Ok(client) => client,
231        Err(_) => return (false, 0, Vec::new()),
232    };
233
234    let url = format!("http://127.0.0.1:{port}/query");
235    let response = client
236        .post(&url)
237        .header("X-Qualia-Token", token)
238        .header("Accept", "application/ld+json")
239        .json(&serde_json::json!({
240            "query": "?subject ?predicate ?object .",
241            "format": "json-ld"
242        }))
243        .send();
244
245    let Ok(response) = response else {
246        return (false, 0, Vec::new());
247    };
248    if !response.status().is_success() {
249        return (true, 0, Vec::new());
250    }
251
252    let Ok(body) = response.json::<serde_json::Value>() else {
253        return (true, 0, Vec::new());
254    };
255
256    let match_count = body
257        .get("match_count")
258        .and_then(|v| v.as_u64())
259        .unwrap_or(0);
260    let graph_conditions = conditions_from_daemon_graph(&body, catalog);
261    (true, match_count, graph_conditions)
262}
263
264fn resolve_dicom_overlay(
265    qapp_name: &str,
266    combined_text: &str,
267    dicom_file_path: Option<&str>,
268) -> Option<qualia_core_db::dicom::DicomOverlaySpec> {
269    let matchers = load_dicom_organ_matchers(qapp_name);
270
271    if let Some(path) = dicom_file_path {
272        let trimmed = path.trim();
273        if !trimmed.is_empty() {
274            if let Ok(spec) =
275                qualia_core_db::dicom::overlay_spec_from_file(std::path::Path::new(trimmed))
276            {
277                return Some(spec);
278            }
279        }
280    }
281
282    qualia_core_db::dicom::infer_overlay_spec_from_text(combined_text, &matchers)
283}
284
285/// Merge installed-app knowledge, chat text, and daemon reachability into Anatomy payload data.
286pub fn build_anatomy_graph_context(
287    qapp_name: String,
288    user_prompt: String,
289    agent_reply: String,
290) -> Result<AnatomyGraphContext, String> {
291    build_anatomy_graph_context_with_dicom(qapp_name, user_prompt, agent_reply, None)
292}
293
294/// Same as [`build_anatomy_graph_context`] but optionally ingests a local `.dcm` file path.
295pub fn build_anatomy_graph_context_with_dicom(
296    qapp_name: String,
297    user_prompt: String,
298    agent_reply: String,
299    dicom_file_path: Option<String>,
300) -> Result<AnatomyGraphContext, String> {
301    let catalog = load_condition_catalog(&qapp_name)?;
302    let combined = format!("{user_prompt}\n{agent_reply}");
303    let mut conditions = infer_conditions_from_text(&combined, &catalog);
304
305    let (daemon_reachable, daemon_match_count, graph_conditions) =
306        query_daemon_context(&qapp_name, &catalog);
307    for label in graph_conditions {
308        if !conditions.contains(&label) {
309            conditions.push(label);
310        }
311    }
312    conditions.sort();
313    conditions.dedup();
314
315    let source = if daemon_reachable && daemon_match_count > 0 {
316        "daemon+chat+knowledge".to_string()
317    } else if daemon_reachable {
318        "chat+knowledge+daemon-empty".to_string()
319    } else {
320        "chat+knowledge".to_string()
321    };
322
323    let systems = systems_from_conditions(&catalog, &conditions);
324    let condition_impact_map = build_condition_impact_map(&catalog, &conditions);
325    let dicom_overlay = resolve_dicom_overlay(&qapp_name, &combined, dicom_file_path.as_deref());
326
327    Ok(AnatomyGraphContext {
328        conditions,
329        systems,
330        condition_impact_map,
331        source,
332        daemon_match_count,
333        daemon_reachable,
334        dicom_overlay,
335    })
336}
337
338/// JSON blob for Anatomy / chat handoff.
339pub fn build_anatomy_graph_context_json(
340    qapp_name: String,
341    user_prompt: String,
342    agent_reply: String,
343) -> Result<String, String> {
344    build_anatomy_graph_context_json_with_dicom(qapp_name, user_prompt, agent_reply, None)
345}
346
347pub fn build_anatomy_graph_context_json_with_dicom(
348    qapp_name: String,
349    user_prompt: String,
350    agent_reply: String,
351    dicom_file_path: Option<String>,
352) -> Result<String, String> {
353    let ctx = build_anatomy_graph_context_with_dicom(
354        qapp_name,
355        user_prompt,
356        agent_reply,
357        dicom_file_path,
358    )?;
359    let mut payload = json!({
360        "conditions": ctx.conditions,
361        "systems": ctx.systems,
362        "conditionImpactMap": ctx.condition_impact_map,
363        "source": ctx.source,
364        "daemonMatchCount": ctx.daemon_match_count,
365        "daemonReachable": ctx.daemon_reachable,
366    });
367    if let Some(spec) = ctx.dicom_overlay {
368        if let Ok(value) = serde_json::to_value(spec) {
369            payload["dicomOverlay"] = value;
370        }
371    }
372    serde_json::to_string(&payload).map_err(|e| e.to_string())
373}
374
375pub fn parse_dicom_metadata_json(file_path: String) -> Result<String, String> {
376    qualia_core_db::dicom::metadata_json_from_file(std::path::Path::new(&file_path))
377}
378
379pub fn build_dicom_overlay_spec_json(file_path: String) -> Result<String, String> {
380    qualia_core_db::dicom::overlay_spec_json_from_file(std::path::Path::new(&file_path))
381}