1use 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
285pub 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
294pub 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
338pub 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}