Skip to main content

qualia_client_core/
ontology_router.rs

1//! Prompt-to-ontology routing for contextual inference.
2//!
3//! This module chooses which installed ontologies are most relevant for a turn,
4//! derives stable namespace hashes for the LLM intent frame, and produces a
5//! short routing briefing that can be injected into the augmented prompt.
6
7use 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
28/// Route a prompt with bounded, agent-declared semantic focus terms.  These
29/// terms are only relevance hints: dataset access, tool permission, and remote
30/// disclosure remain governed elsewhere.
31pub 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
39/// Route within the intersection of the session environment and a named
40/// agent's explicit data-source allowlist.  The allowlist can only narrow a
41/// session; an empty list preserves the session's existing boundary.
42pub 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}