1use std::collections::HashSet;
8
9use qualia_core_db::q_hash;
10
11use crate::chat_session::{ChatEnvironment, OntologyScopeSummary};
12
13const MAX_ROUTED_ONTOLOGIES: usize = 4;
14const MAX_CONTEXT_NAMESPACES: usize = 16;
15
16#[derive(Debug, Clone, Default)]
17pub struct OntologyRoutingDecision {
18 pub ontology_ids: Vec<String>,
19 pub context_namespaces: Vec<u64>,
20 pub matched_terms: Vec<String>,
21 pub routing_brief: String,
22}
23
24pub fn route_prompt_to_ontologies(env: &ChatEnvironment, prompt: &str) -> OntologyRoutingDecision {
25 route_prompt_with_focus(env, prompt, &[])
26}
27
28pub fn route_prompt_with_focus(
32 env: &ChatEnvironment,
33 prompt: &str,
34 focus_terms: &[String],
35) -> OntologyRoutingDecision {
36 route_prompt_with_focus_and_allowlist(env, prompt, focus_terms, &[])
37}
38
39pub fn route_prompt_with_focus_and_allowlist(
43 env: &ChatEnvironment,
44 prompt: &str,
45 focus_terms: &[String],
46 allowed_ontology_ids: &[String],
47) -> OntologyRoutingDecision {
48 let allowed = |id: &str| {
49 allowed_ontology_ids.is_empty()
50 || allowed_ontology_ids.iter().any(|configured| configured == id)
51 };
52 let in_scope: Vec<&OntologyScopeSummary> = env
53 .ontology_summaries
54 .iter()
55 .filter(|summary| allowed(&summary.id))
56 .collect();
57 if in_scope.is_empty() {
58 return OntologyRoutingDecision::default();
59 }
60
61 let mut keywords = extract_keywords(prompt);
62 for term in focus_terms {
63 for keyword in extract_keywords(term) {
64 if !keywords.iter().any(|known| known == &keyword) {
65 keywords.push(keyword);
66 }
67 }
68 }
69 keywords.truncate(32);
70 let mut scored: Vec<(i32, &OntologyScopeSummary)> = in_scope
71 .iter()
72 .copied()
73 .map(|summary| (score_summary(summary, &keywords), summary))
74 .collect();
75
76 scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.id.cmp(&b.1.id)));
77
78 let mut ontology_ids = Vec::new();
79 let mut context_namespaces = Vec::new();
80 let mut matched_terms = Vec::new();
81 let mut seen_terms = HashSet::new();
82
83 for (score, summary) in &scored {
84 if *score <= 0 && !ontology_ids.is_empty() {
85 break;
86 }
87 if *score <= 0 && ontology_ids.len() >= MAX_ROUTED_ONTOLOGIES {
88 break;
89 }
90 if ontology_ids.len() >= MAX_ROUTED_ONTOLOGIES {
91 break;
92 }
93 ontology_ids.push(summary.id.clone());
94 extend_namespaces(&mut context_namespaces, summary);
95 for keyword in &keywords {
96 if summary_matches_keyword(summary, keyword) && seen_terms.insert(keyword.clone()) {
97 matched_terms.push(keyword.clone());
98 }
99 }
100 }
101
102 if ontology_ids.is_empty() {
103 for summary in in_scope.iter().take(MAX_ROUTED_ONTOLOGIES) {
104 ontology_ids.push(summary.id.clone());
105 extend_namespaces(&mut context_namespaces, summary);
106 }
107 }
108
109 if ontology_ids.iter().all(|id| !id.contains("wordnet")) {
110 if let Some(wordnet) = in_scope
111 .iter()
112 .copied()
113 .find(|o| o.id.contains("wordnet"))
114 .filter(|_| ontology_ids.len() < MAX_ROUTED_ONTOLOGIES)
115 {
116 ontology_ids.push(wordnet.id.clone());
117 extend_namespaces(&mut context_namespaces, wordnet);
118 }
119 }
120
121 context_namespaces.sort_unstable();
122 context_namespaces.dedup();
123 if context_namespaces.len() > MAX_CONTEXT_NAMESPACES {
124 context_namespaces.truncate(MAX_CONTEXT_NAMESPACES);
125 }
126
127 let routing_brief = if ontology_ids.is_empty() {
128 "[Ontology routing: no installed ontologies selected]".to_string()
129 } else if matched_terms.is_empty() {
130 format!(
131 "[Ontology routing: {} selected for general grounding]",
132 ontology_ids.join(", ")
133 )
134 } else {
135 format!(
136 "[Ontology routing: {} selected from prompt terms: {}]",
137 ontology_ids.join(", "),
138 matched_terms.join(", ")
139 )
140 };
141
142 OntologyRoutingDecision {
143 ontology_ids,
144 context_namespaces,
145 matched_terms,
146 routing_brief,
147 }
148}
149
150fn extract_keywords(prompt: &str) -> Vec<String> {
151 prompt
152 .split(|c: char| !c.is_alphanumeric())
153 .filter(|w| w.len() >= 3)
154 .map(|w| w.to_ascii_lowercase())
155 .take(24)
156 .collect()
157}
158
159fn score_summary(summary: &OntologyScopeSummary, keywords: &[String]) -> i32 {
160 if keywords.is_empty() {
161 return 1;
162 }
163
164 let mut score = 0;
165 for keyword in keywords {
166 if summary_matches_keyword(summary, keyword) {
167 score += 4;
168 }
169 score += domain_bonus(summary, keyword);
170 }
171 if summary.id.contains("wordnet") {
172 score += 1;
173 }
174 score
175}
176
177fn summary_matches_keyword(summary: &OntologyScopeSummary, keyword: &str) -> bool {
178 contains_term(&summary.id, keyword)
179 || contains_term(&summary.name, keyword)
180 || summary
181 .domain
182 .as_deref()
183 .map(|d| contains_term(d, keyword))
184 .unwrap_or(false)
185 || summary
186 .tags
187 .iter()
188 .flatten()
189 .any(|tag| contains_term(tag, keyword))
190}
191
192fn contains_term(text: &str, keyword: &str) -> bool {
193 let lower = text.to_ascii_lowercase();
194 lower.contains(keyword)
195}
196
197fn domain_bonus(summary: &OntologyScopeSummary, keyword: &str) -> i32 {
198 let health = [
199 "health", "medical", "medicine", "clinical", "patient", "drug", "fhir", "loinc", "snomed",
200 "anatomy", "body", "heart", "fever", "symptom",
201 ];
202 let legal = [
203 "legal",
204 "law",
205 "contract",
206 "rights",
207 "guardian",
208 "guardianship",
209 "consent",
210 "policy",
211 "agreement",
212 "duty",
213 ];
214 let civic = ["commons", "community", "governance", "public", "care"];
215
216 let domain = summary
217 .domain
218 .as_deref()
219 .unwrap_or_default()
220 .to_ascii_lowercase();
221 let tags = summary
222 .tags
223 .as_ref()
224 .map(|v| v.join(" ").to_ascii_lowercase())
225 .unwrap_or_default();
226 let haystack = format!("{} {}", domain, tags);
227
228 if health.contains(&keyword) && haystack.contains("health") {
229 return 6;
230 }
231 if legal.contains(&keyword)
232 && (haystack.contains("legal") || haystack.contains("rights") || haystack.contains("guard"))
233 {
234 return 6;
235 }
236 if civic.contains(&keyword) && haystack.contains("commons") {
237 return 4;
238 }
239 0
240}
241
242fn extend_namespaces(out: &mut Vec<u64>, summary: &OntologyScopeSummary) {
243 out.push(q_hash(&summary.id));
244 out.push(q_hash(&format!("ont:{}", summary.id)));
245 out.push(q_hash(&summary.name));
246 if let Some(domain) = &summary.domain {
247 out.push(q_hash(domain));
248 out.push(q_hash(&format!("domain:{domain}")));
249 }
250 if let Some(tags) = &summary.tags {
251 for tag in tags.iter().take(4) {
252 out.push(q_hash(tag));
253 }
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::chat_session::{ChatEnvironment, OntologyScopeSummary};
261
262 fn env() -> ChatEnvironment {
263 ChatEnvironment {
264 session_id: "s".into(),
265 active_model_profile_id: 0,
266 ontology_ids: vec!["snomed".into(), "legal-commons".into(), "wordnet".into()],
267 prior_session_ids: vec![],
268 graph_scope_hashes: vec![],
269 lexicon_prefixes: vec![],
270 capability_briefing: String::new(),
271 model_id: None,
272 model_modality: "text".into(),
273 context_window: 4096,
274 engine_capabilities: vec![],
275 installed_qapps: vec![],
276 ontology_summaries: vec![
277 OntologyScopeSummary {
278 id: "snomed".into(),
279 name: "SNOMED Clinical Terms".into(),
280 quin_count: 10,
281 q42_path: "snomed.q42".into(),
282 domain: Some("health".into()),
283 tags: Some(vec!["medical".into(), "clinical".into()]),
284 source: None,
285 },
286 OntologyScopeSummary {
287 id: "legal-commons".into(),
288 name: "Legal Commons".into(),
289 quin_count: 10,
290 q42_path: "legal.q42".into(),
291 domain: Some("legal".into()),
292 tags: Some(vec!["contract".into(), "rights".into()]),
293 source: None,
294 },
295 OntologyScopeSummary {
296 id: "wordnet".into(),
297 name: "WordNet".into(),
298 quin_count: 10,
299 q42_path: "wordnet.q42".into(),
300 domain: Some("lexical".into()),
301 tags: Some(vec!["language".into()]),
302 source: None,
303 },
304 ],
305 daemon_reachable: false,
306 session_kind: crate::chat_session::SessionKind::Solo,
307 participants: vec![],
308 graph_mutation: false,
309 axiom_bounds: crate::context_binding::AxiomBounds::default(),
310 }
311 }
312
313 #[test]
314 fn routes_medical_prompt_to_health_ontology() {
315 let decision = route_prompt_to_ontologies(&env(), "What does this patient fever indicate?");
316 assert!(decision.ontology_ids.iter().any(|id| id == "snomed"));
317 assert!(decision.context_namespaces.contains(&q_hash("health")));
318 }
319
320 #[test]
321 fn routes_guardianship_prompt_to_legal_ontology() {
322 let decision =
323 route_prompt_to_ontologies(&env(), "Draft a guardianship consent agreement.");
324 assert!(decision.ontology_ids.iter().any(|id| id == "legal-commons"));
325 assert!(decision.context_namespaces.contains(&q_hash("legal")));
326 }
327
328 #[test]
329 fn semantic_focus_can_narrow_routing_without_prompt_keywords() {
330 let decision = route_prompt_with_focus(
331 &env(),
332 "Please prepare a concise source note.",
333 &["clinical evidence".to_string(), "health".to_string()],
334 );
335 assert!(decision.ontology_ids.iter().any(|id| id == "snomed"));
336 assert!(decision.matched_terms.iter().any(|term| term == "clinical"));
337 }
338
339 #[test]
340 fn agent_allowlist_intersects_the_session_scope() {
341 let decision = route_prompt_with_focus_and_allowlist(
342 &env(),
343 "What does this patient fever indicate?",
344 &[],
345 &["legal-commons".to_string()],
346 );
347 assert_eq!(decision.ontology_ids, vec!["legal-commons".to_string()]);
348 assert!(!decision.context_namespaces.contains(&q_hash("health")));
349 }
350}