1use std::collections::HashSet;
4use std::path::Path;
5
6use qualia_core_db::{q42_reader::read_q42_quins, q_hash, NQuin};
7use serde::{Deserialize, Serialize};
8
9use crate::chat_session::ChatEnvironment;
10use crate::resource_import;
11
12const MAX_RETRIEVAL_TRIPLES: usize = 48;
13const OBJECT_HASH_MASK: u64 = 0x0FFF_FFFF_FFFF_FFFF;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct GraphCitation {
17 pub ontology_id: String,
18 pub subject_hash: String,
19 pub predicate_hash: String,
20 pub object_hash: String,
21 pub label: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct RetrievalBundle {
26 pub triple_count: usize,
27 pub citations: Vec<GraphCitation>,
28 pub provenance_hashes: Vec<u64>,
29 pub context_block: String,
30 pub daemon_queried: bool,
31 pub daemon_match_count: u64,
32}
33
34pub fn retrieve_graph_context(
35 storage: &Path,
36 env: &ChatEnvironment,
37 user_prompt: &str,
38 routed_ontology_ids: &[String],
39) -> RetrievalBundle {
40 let keywords = extract_keywords(user_prompt);
41 let mut citations = Vec::new();
42 let mut provenance_hashes = Vec::new();
43 let mut seen: HashSet<(u64, u64, u64)> = HashSet::new();
44
45 let ontology_ids = if routed_ontology_ids.is_empty() {
46 &env.ontology_ids
47 } else {
48 routed_ontology_ids
49 };
50
51 for ont_id in ontology_ids {
52 let q42_path = resource_import::index_dir(storage).join(format!("{ont_id}.q42"));
53 if !q42_path.is_file() {
54 continue;
55 }
56 let Ok(quins) = read_q42_quins(&q42_path) else {
57 continue;
58 };
59
60 for quin in &quins {
61 if citations.len() >= MAX_RETRIEVAL_TRIPLES {
62 break;
63 }
64 if !matches_keywords(quin, &keywords) && !keywords.is_empty() {
65 continue;
66 }
67 let key = (quin.subject, quin.predicate, quin.object);
68 if !seen.insert(key) {
69 continue;
70 }
71 let citation_hash = quin.subject ^ quin.predicate ^ quin.object;
72 provenance_hashes.push(citation_hash);
73 citations.push(GraphCitation {
74 ontology_id: ont_id.clone(),
75 subject_hash: format!("0x{:016x}", quin.subject),
76 predicate_hash: format!("0x{:016x}", quin.predicate),
77 object_hash: format!("0x{:016x}", quin.object & OBJECT_HASH_MASK),
78 label: format!(
79 "{} → {} → {}",
80 short_hash(quin.subject),
81 short_hash(quin.predicate),
82 short_hash(quin.object)
83 ),
84 });
85 }
86 }
87
88 let (daemon_queried, daemon_match_count, daemon_extra) =
89 query_daemon_for_prompt(user_prompt, &keywords);
90
91 if daemon_queried && daemon_match_count > 0 {
92 provenance_hashes.push(q_hash("qualia:daemon_graph"));
93 if citations.is_empty() && !daemon_extra.is_empty() {
94 citations.push(GraphCitation {
95 ontology_id: "daemon".to_string(),
96 subject_hash: "live".to_string(),
97 predicate_hash: "graph".to_string(),
98 object_hash: format!("{daemon_match_count}"),
99 label: daemon_extra,
100 });
101 }
102 }
103
104 provenance_hashes.sort_unstable();
105 provenance_hashes.dedup();
106
107 let context_block = format_retrieval_block(&citations, daemon_match_count);
108
109 RetrievalBundle {
110 triple_count: citations.len(),
111 citations,
112 provenance_hashes,
113 context_block,
114 daemon_queried,
115 daemon_match_count,
116 }
117}
118
119fn extract_keywords(prompt: &str) -> Vec<String> {
120 prompt
121 .split(|c: char| !c.is_alphanumeric())
122 .filter(|w| w.len() >= 4)
123 .map(|w| w.to_lowercase())
124 .take(16)
125 .collect()
126}
127
128fn matches_keywords(quin: &NQuin, keywords: &[String]) -> bool {
129 if keywords.is_empty() {
130 return true;
131 }
132 let sub = format!("{:016x}", quin.subject);
133 let pred = format!("{:016x}", quin.predicate);
134 let obj = format!("{:016x}", quin.object);
135 keywords
136 .iter()
137 .any(|kw| sub.contains(kw) || pred.contains(kw) || obj.contains(kw))
138}
139
140fn short_hash(h: u64) -> String {
141 format!("{:08x}", (h & 0xFFFF_FFFF) as u32)
142}
143
144fn format_retrieval_block(citations: &[GraphCitation], daemon_matches: u64) -> String {
145 if citations.is_empty() && daemon_matches == 0 {
146 return "[Graph retrieval: no scoped triples matched — rely on environment capabilities only]"
147 .to_string();
148 }
149
150 let mut lines = vec![format!(
151 "[Graph retrieval: {} scoped citation(s), daemon matches: {daemon_matches}]",
152 citations.len()
153 )];
154 for (i, c) in citations.iter().take(24).enumerate() {
155 lines.push(format!(" {}. [{}] {}", i + 1, c.ontology_id, c.label));
156 }
157 lines.join("\n")
158}
159
160fn query_daemon_for_prompt(prompt: &str, keywords: &[String]) -> (bool, u64, String) {
161 if crate::daemon_status() != "running" {
162 return (false, 0, String::new());
163 }
164 let port = crate::get_active_daemon_port();
165 if port == 0 {
166 return (false, 0, String::new());
167 }
168
169 let client = match reqwest::blocking::Client::builder()
170 .timeout(std::time::Duration::from_secs(4))
171 .build()
172 {
173 Ok(c) => c,
174 Err(_) => return (false, 0, String::new()),
175 };
176
177 let token = crate::issue_qapp_session_token("Chat").unwrap_or_default();
178 let url = format!("http://127.0.0.1:{port}/query");
179
180 let query = if keywords.is_empty() {
181 "?subject ?predicate ?object .".to_string()
182 } else {
183 format!("# Scoped chat retrieval for: {}", keywords.join(", "))
184 };
185
186 let response = client
187 .post(&url)
188 .header("X-Qualia-Token", token)
189 .header("Accept", "application/ld+json")
190 .json(&serde_json::json!({
191 "query": query,
192 "format": "json-ld",
193 "prompt_hint": prompt.chars().take(120).collect::<String>()
194 }))
195 .send();
196
197 let Ok(resp) = response else {
198 return (true, 0, String::new());
199 };
200 if !resp.status().is_success() {
201 return (true, 0, String::new());
202 }
203
204 let Ok(body) = resp.json::<serde_json::Value>() else {
205 return (true, 0, String::new());
206 };
207
208 let match_count = body
209 .get("match_count")
210 .and_then(|v| v.as_u64())
211 .unwrap_or(0);
212
213 let summary = if match_count > 0 {
214 format!("Daemon graph returned {match_count} quin(s) for scoped query")
215 } else {
216 String::new()
217 };
218
219 (true, match_count, summary)
220}