Skip to main content

qualia_client_core/
cml_context.rs

1//! **CML context loop** — chat logs carry Context Markup that binds topic-related semantics into the
2//! person's *inforg* (their private hypermedia library), which is then reused, permissively, to improve
3//! the context given to the local agent.
4//!
5//! CML (Timothy C. Holborn's Context Markup Language — a working draft) treats *a concept as a context
6//! hash*: `q_hash(concept)` addresses the concept's sub-graph. Here a person marks context inline in a
7//! message —
8//!   `#project:tax-2026  #task:file-return  #topic:deductions  [[capital gains]]`
9//! — and each tag becomes a `cml:Proposed` concept stored alongside the turn in the inforg. A later turn
10//! that shares those concepts pulls the earlier context back in. Multi-part by construction: one message
11//! may carry a general concept, a project, a topic, and a task at once.
12//!
13//! v1 substrate: the concept identity is the hypermedia store's `fnv60(label)` edge object (so a search
14//! for the label matches). The fuller `cml.n3` IRI-hash concept graph compiled into `.q42` for the graph
15//! retrieval path is the next increment; this loop already works end-to-end over the inforg. Reuse is
16//! **permission-gated**: guardian-flagged (sensitive) entries are never auto-injected.
17
18use crate::wellfair::hypermedia_store::{HypermediaStore, LibraryEntry};
19use qualia_core_db::hypermedia::fnv60;
20use qualia_core_db::hypermedia::{ingest_with, Descriptors, TextProcessor};
21use std::collections::HashSet;
22use std::path::Path;
23use std::time::{SystemTime, UNIX_EPOCH};
24
25/// The CML concept tiers a chat message may carry (multi-part).
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ConceptTier {
28    General,
29    Project,
30    Topic,
31    Task,
32    Pursuit,
33}
34
35impl ConceptTier {
36    fn from_key(k: &str) -> Option<Self> {
37        match k.to_ascii_lowercase().as_str() {
38            "general" => Some(Self::General),
39            "project" | "proj" => Some(Self::Project),
40            "topic" => Some(Self::Topic),
41            "task" => Some(Self::Task),
42            "pursuit" | "goal" => Some(Self::Pursuit),
43            _ => None,
44        }
45    }
46    pub fn as_str(&self) -> &'static str {
47        match self {
48            Self::General => "general",
49            Self::Project => "project",
50            Self::Topic => "topic",
51            Self::Task => "task",
52            Self::Pursuit => "pursuit",
53        }
54    }
55}
56
57/// One context tag parsed from a message: a `cml:Proposed` concept at a tier.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct ContextTag {
60    pub tier: ConceptTier,
61    pub label: String,
62}
63
64impl ContextTag {
65    /// The CML concept IRI for this tag (`https://ns.webcivics.net/cml/concept/<tier>/<slug>`).
66    pub fn iri(&self) -> String {
67        format!(
68            "https://ns.webcivics.net/cml/concept/{}/{}",
69            self.tier.as_str(),
70            slug(&self.label)
71        )
72    }
73    /// The concept's context hash — CML's "a concept is a context hash".
74    pub fn context_hash(&self) -> u64 {
75        fnv60(self.iri().as_bytes())
76    }
77}
78
79fn slug(s: &str) -> String {
80    let lowered: String = s
81        .trim()
82        .to_lowercase()
83        .chars()
84        .map(|c| if c.is_alphanumeric() { c } else { '-' })
85        .collect();
86    lowered.trim_matches('-').to_string()
87}
88
89/// Parse inline CML from a message. Recognises `#project:label`, `#task:label`, `#topic:label`,
90/// `#pursuit:label`, `#general:label`, bare `#hashtag` (→ topic), and `[[general concept]]` (may contain
91/// spaces). Underscores in a `#key:value` label become spaces. Duplicates (same tier + label) removed.
92pub fn parse_cml_tags(text: &str) -> Vec<ContextTag> {
93    let mut tags: Vec<ContextTag> = Vec::new();
94    let mut push = |tier: ConceptTier, label: String| {
95        let label = label.trim().to_string();
96        if !label.is_empty()
97            && !tags
98                .iter()
99                .any(|t| t.tier == tier && t.label.eq_ignore_ascii_case(&label))
100        {
101            tags.push(ContextTag { tier, label });
102        }
103    };
104
105    // [[general concepts]] (may contain spaces)
106    let mut rest = text;
107    while let Some(start) = rest.find("[[") {
108        let after = &rest[start + 2..];
109        if let Some(end) = after.find("]]") {
110            push(ConceptTier::General, after[..end].to_string());
111            rest = &after[end + 2..];
112        } else {
113            break;
114        }
115    }
116
117    // #key:value and bare #hashtag tokens
118    for raw in text.split_whitespace() {
119        let tok = raw.trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?' | ';' | ')' | '('));
120        if let Some(body) = tok.strip_prefix('#') {
121            if body.is_empty() {
122                continue;
123            }
124            if let Some((k, v)) = body.split_once(':') {
125                match ConceptTier::from_key(k) {
126                    Some(tier) => push(tier, v.replace('_', " ")),
127                    None => push(ConceptTier::Topic, body.replace('_', " ")),
128                }
129            } else {
130                push(ConceptTier::Topic, body.replace('_', " "));
131            }
132        }
133    }
134    tags
135}
136
137fn now_unix() -> u64 {
138    SystemTime::now()
139        .duration_since(UNIX_EPOCH)
140        .map(|d| d.as_secs())
141        .unwrap_or(0)
142}
143
144/// Store a chat turn's CML context into the inforg — **only when the message carries explicit tags**
145/// (deliberate, permissive). The turn's text + its concept facets become a searchable library entry.
146/// Returns the concepts stored (empty if the message had no tags).
147pub fn ingest_turn(
148    storage: &Path,
149    session_id: &str,
150    text: &str,
151) -> Result<Vec<ContextTag>, String> {
152    let tags = parse_cml_tags(text);
153    if tags.is_empty() {
154        return Ok(Vec::new());
155    }
156    let store = HypermediaStore::open(storage).map_err(|e| e.to_string())?;
157
158    let mut topics: Vec<String> = Vec::new();
159    let mut projects: Vec<String> = Vec::new();
160    let mut purposes: Vec<String> = Vec::new();
161    for t in &tags {
162        match t.tier {
163            ConceptTier::Project => projects.push(t.label.clone()),
164            ConceptTier::Task | ConceptTier::Pursuit => purposes.push(t.label.clone()),
165            ConceptTier::Topic | ConceptTier::General => topics.push(t.label.clone()),
166        }
167    }
168
169    let digest = fnv60(text.as_bytes());
170    let uri = format!("urn:qualia:chat:{session_id}:{digest:016x}");
171    let r = ingest_with(
172        &TextProcessor::default(),
173        &uri,
174        "text/plain",
175        digest,
176        text.as_bytes(),
177    );
178    let subject = r.container.primary.subject();
179    let mut quins = r.quins;
180    let desc = Descriptors {
181        topics: topics.clone(),
182        projects: projects.clone(),
183        purposes: purposes.clone(),
184        ..Default::default()
185    };
186    let (dq, _lex) = qualia_core_db::hypermedia::descriptors_to_nquins(subject, &desc);
187    quins.extend(dq);
188
189    // Enrich chat turn with the same Rust CML context graph (TEXT→CONCEPT→LOGIC).
190    let units = crate::wellfair::cml_context::units_from_headings(text);
191    let g = crate::wellfair::cml_context::build_document_context(&uri, "chat-turn", &units);
192    for t in &g.topics {
193        if !topics.iter().any(|x| x == t) {
194            topics.push(t.clone());
195        }
196    }
197    for p in &g.purposes {
198        if !purposes.iter().any(|x| x == p) {
199            purposes.push(p.clone());
200        }
201    }
202    quins.extend(g.quins);
203
204    let mut entry = LibraryEntry {
205        asset_uri: uri,
206        primary_subject: subject,
207        media_type: "text/plain".to_string(),
208        quins,
209        topics,
210        projects,
211        purposes: purposes.clone(),
212        place: None,
213        occurred_at: None,
214        lat: None,
215        lon: None,
216        flags: Vec::new(),
217        ingested_unix: now_unix(),
218        excerpt: text.chars().take(240).collect(),
219        sensitivity: "public".into(),
220        section: "personal".into(),
221        commons_visibility: Default::default(),
222        cml_signals: g.signal_tags,
223        cml_concept_count: g.concepts.len() as u32,
224        cml_n3: if g.n3.len() > 16_000 {
225            format!("{}…", &g.n3[..16_000])
226        } else {
227            g.n3
228        },
229        cof_html: String::new(),
230        cof_segment_count: 0,
231        cof_segment_index: 0,
232        cof_profile: String::new(),
233    };
234    entry.recompute_section();
235    store.add(entry).map_err(|e| e.to_string())?;
236    Ok(tags)
237}
238
239const STOPWORDS: &[&str] = &[
240    "the", "and", "for", "with", "that", "this", "what", "how", "why", "who", "you", "your", "are",
241    "was", "can", "will", "into", "from", "about", "have", "has", "not", "but", "get", "got",
242    "tell", "give", "please", "would", "could", "should", "them", "they", "our", "out",
243];
244
245fn salient_terms(prompt: &str) -> Vec<String> {
246    let mut seen = HashSet::new();
247    let mut out = Vec::new();
248    for w in prompt.split(|c: char| !c.is_alphanumeric()) {
249        let w = w.to_lowercase();
250        if w.len() > 3 && !STOPWORDS.contains(&w.as_str()) && seen.insert(w.clone()) {
251            out.push(w);
252        }
253    }
254    out
255}
256
257fn absorb(entries: Vec<LibraryEntry>, chosen: &mut Vec<LibraryEntry>, seen: &mut HashSet<String>) {
258    for e in entries {
259        // Permission gate: guardian-flagged (sensitive) entries are never auto-injected.
260        if e.flags.is_empty() && seen.insert(e.asset_uri.clone()) {
261            chosen.push(e);
262        }
263    }
264}
265
266/// Retrieve inforg context relevant to `prompt` and format it as a prompt block (empty if nothing).
267/// Matches the prompt's explicit tags first (reliable), then a few salient terms (best-effort), over
268/// the person's library. Permission-gated (flagged entries excluded).
269pub fn retrieve_context(storage: &Path, prompt: &str, max_snippets: usize) -> String {
270    let store = match HypermediaStore::open(storage) {
271        Ok(s) => s,
272        Err(_) => return String::new(),
273    };
274    let mut chosen: Vec<LibraryEntry> = Vec::new();
275    let mut seen: HashSet<String> = HashSet::new();
276
277    for tag in parse_cml_tags(prompt) {
278        let facet = match tag.tier {
279            ConceptTier::Project => "project",
280            ConceptTier::Task | ConceptTier::Pursuit => "purpose",
281            _ => "topic",
282        };
283        if let Ok(entries) = store.search(facet, &tag.label) {
284            absorb(entries, &mut chosen, &mut seen);
285        }
286    }
287    for term in salient_terms(prompt).into_iter().take(6) {
288        if chosen.len() >= max_snippets {
289            break;
290        }
291        if let Ok(entries) = store.search("topic", &term) {
292            absorb(entries, &mut chosen, &mut seen);
293        }
294    }
295
296    if chosen.is_empty() {
297        return String::new();
298    }
299    let mut lines = Vec::new();
300    for e in chosen.into_iter().take(max_snippets) {
301        let mut facets: Vec<String> = Vec::new();
302        facets.extend(e.projects.iter().cloned());
303        facets.extend(e.topics.iter().cloned());
304        let tag = if facets.is_empty() {
305            String::new()
306        } else {
307            format!(" [{}]", facets.join(", "))
308        };
309        lines.push(format!("- {}{}", e.excerpt.trim(), tag));
310    }
311    format!(
312        "Relevant context from your library (inforg) — use for grounding, cite if used:\n{}",
313        lines.join("\n")
314    )
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    #[test]
322    fn parses_multi_part_context() {
323        let tags = parse_cml_tags(
324            "note #project:tax_2026 [[capital gains]] #deductions #task:file-return",
325        );
326        assert!(tags.contains(&ContextTag {
327            tier: ConceptTier::Project,
328            label: "tax 2026".into()
329        }));
330        assert!(tags.contains(&ContextTag {
331            tier: ConceptTier::General,
332            label: "capital gains".into()
333        }));
334        assert!(tags.contains(&ContextTag {
335            tier: ConceptTier::Topic,
336            label: "deductions".into()
337        }));
338        assert!(tags.contains(&ContextTag {
339            tier: ConceptTier::Task,
340            label: "file-return".into()
341        }));
342    }
343
344    #[test]
345    fn untagged_message_stores_nothing() {
346        let dir = tempfile::tempdir().unwrap();
347        let stored = ingest_turn(dir.path(), "s1", "just a plain message with no markup").unwrap();
348        assert!(stored.is_empty());
349        assert!(retrieve_context(dir.path(), "anything", 4).is_empty());
350    }
351
352    #[test]
353    fn tagged_turn_is_stored_and_reused() {
354        let dir = tempfile::tempdir().unwrap();
355        let stored = ingest_turn(
356            dir.path(),
357            "s1",
358            "The liver secretes bile. #project:hep-notes #topic:anatomy",
359        )
360        .unwrap();
361        assert_eq!(stored.len(), 2);
362        // A later turn sharing the project pulls the earlier context back in.
363        let ctx = retrieve_context(dir.path(), "remind me about #project:hep-notes", 4);
364        assert!(ctx.contains("liver"), "expected inforg recall, got: {ctx}");
365        // An unrelated project matches nothing.
366        assert!(retrieve_context(dir.path(), "unrelated #project:nothing-here", 4).is_empty());
367    }
368
369    #[test]
370    fn concept_is_a_context_hash() {
371        let t = ContextTag {
372            tier: ConceptTier::Topic,
373            label: "Capital Gains".into(),
374        };
375        assert_eq!(
376            t.iri(),
377            "https://ns.webcivics.net/cml/concept/topic/capital-gains"
378        );
379        assert_ne!(t.context_hash(), 0);
380    }
381}