Skip to main content

qualia_client_core/
chat_retrieval.rs

1//! Scoped graph retrieval for chat — local `.q42` scan + optional daemon query.
2
3use 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}