1use 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct ContextTag {
60 pub tier: ConceptTier,
61 pub label: String,
62}
63
64impl ContextTag {
65 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 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
89pub 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 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 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
144pub 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 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 if e.flags.is_empty() && seen.insert(e.asset_uri.clone()) {
261 chosen.push(e);
262 }
263 }
264}
265
266pub 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 let ctx = retrieve_context(dir.path(), "remind me about #project:hep-notes", 4);
364 assert!(ctx.contains("liver"), "expected inforg recall, got: {ctx}");
365 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}