Skip to main content

qualia_client_core/
chat_ontology.rs

1//! Chat discourse ontology — branch types grounded in WordNet when `wordnet.q42` is present.
2
3use std::path::{Path, PathBuf};
4use std::sync::OnceLock;
5
6use qualia_core_db::q_hash;
7use serde::{Deserialize, Serialize};
8
9use crate::resource_import;
10
11static WORDNET_LEX: OnceLock<Option<qualia_core_db::q42_lex::Q42Lexicon>> = OnceLock::new();
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ChatBranchType {
15    pub id: String,
16    pub label: String,
17    pub emoji: String,
18    pub description: String,
19    pub wordnet_lemmas: Vec<String>,
20    pub keywords: Vec<String>,
21    #[serde(default)]
22    pub wordnet_grounding_hash: Option<String>,
23    #[serde(default)]
24    pub wordnet_gloss: Option<String>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct BranchClassification {
29    pub branch_type_id: String,
30    pub label: String,
31    pub emoji: String,
32    pub confidence: f32,
33    pub wordnet_grounding_hash: Option<String>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ChatReaction {
38    pub message_lamport: u64,
39    pub emoji: String,
40    pub author_did: String,
41    pub author_name: Option<String>,
42    pub created_at: u64,
43}
44
45const DISCOURSE_TYPES: &[(&str, &str, &str, &str, &[&str], &[&str])] = &[
46    (
47        "comment",
48        "Comment",
49        "💬",
50        "General remark or observation",
51        &["comment", "remark", "note", "statement"],
52        &["note", "fwiw", "interesting"],
53    ),
54    (
55        "correction",
56        "Correction",
57        "✏️",
58        "Fixes or amends prior content",
59        &["correction", "amendment", "rectification"],
60        &[
61            "actually",
62            "incorrect",
63            "mistake",
64            "fix",
65            "wrong",
66            "should be",
67        ],
68    ),
69    (
70        "inquiry",
71        "Inquiry",
72        "❓",
73        "Question or request for information",
74        &["question", "inquiry", "query"],
75        &[
76            "?",
77            "how",
78            "why",
79            "what",
80            "when",
81            "where",
82            "could you",
83            "can you",
84        ],
85    ),
86    (
87        "agreement",
88        "Agreement",
89        "✅",
90        "Endorses or confirms prior content",
91        &["agreement", "assent", "concurrence"],
92        &["yes", "agree", "correct", "exactly", "right", "+1"],
93    ),
94    (
95        "objection",
96        "Objection",
97        "⚠️",
98        "Disagrees or challenges prior content",
99        &["objection", "disagreement", "dissent"],
100        &["disagree", "no", "however", "but", "not quite", "object"],
101    ),
102    (
103        "clarification",
104        "Clarification",
105        "🔍",
106        "Explains or disambiguates",
107        &["clarification", "explanation", "elucidation"],
108        &["clarify", "mean", "to be clear", "in other words", "i.e."],
109    ),
110    (
111        "evidence",
112        "Evidence",
113        "📎",
114        "Cites sources or supporting facts",
115        &["evidence", "proof", "citation"],
116        &[
117            "source",
118            "cite",
119            "according to",
120            "study",
121            "data shows",
122            "provenance",
123        ],
124    ),
125    (
126        "suggestion",
127        "Suggestion",
128        "💡",
129        "Proposes an idea or next step",
130        &["suggestion", "proposal", "recommendation"],
131        &["suggest", "could", "might", "recommend", "try", "idea"],
132    ),
133    (
134        "summary",
135        "Summary",
136        "📋",
137        "Synthesizes or restates thread content",
138        &["summary", "synopsis", "recap"],
139        &["summary", "in short", "tl;dr", "to summarize", "overall"],
140    ),
141    (
142        "humor",
143        "Humor",
144        "😄",
145        "Light or playful response",
146        &["humor", "joke", "wit"],
147        &["lol", "haha", "😄", "😂", "jk"],
148    ),
149];
150
151pub fn local_wordnet_dir() -> PathBuf {
152    if let Ok(dir) = std::env::var("QUALIA_WORDNET_DIR") {
153        let trimmed = dir.trim();
154        if !trimmed.is_empty() {
155            return PathBuf::from(trimmed);
156        }
157    }
158    PathBuf::from("Local_LIbraries/wordnet")
159}
160
161pub fn resolve_wordnet_q42(storage: &Path) -> Option<PathBuf> {
162    let index = resource_import::index_dir(storage);
163    let local = local_wordnet_dir();
164    let candidates = [
165        index.join("wordnet.q42"),
166        index.join("princeton.q42"),
167        index.join("wordnet-rdf.q42"),
168        index.join("english-wordnet.q42"),
169        local.join("wordnet.q42"),
170        local.join("princeton.q42"),
171        PathBuf::from("docs/data/wordnet/princeton.q42"),
172        PathBuf::from("docs/playground/wordnet.q42"),
173        PathBuf::from("wordnet.q42"),
174    ];
175    candidates.into_iter().find(|p| p.is_file())
176}
177
178pub fn resolve_wordnet_lex(q42_path: &Path) -> Option<PathBuf> {
179    if qualia_core_db::q42_volume::is_unified_volume(q42_path).ok() == Some(true) {
180        return Some(q42_path.to_path_buf());
181    }
182    let lex = q42_path.with_extension("q42.lex");
183    if lex.is_file() {
184        return Some(lex);
185    }
186    let alt = PathBuf::from(format!("{}.lex", q42_path.display()));
187    if alt.is_file() {
188        return Some(alt);
189    }
190    let sibling = q42_path.with_file_name(format!(
191        "{}.q42.lex",
192        q42_path.file_stem()?.to_string_lossy()
193    ));
194    if sibling.is_file() {
195        return Some(sibling);
196    }
197    None
198}
199
200fn wordnet_lexicon() -> Option<&'static qualia_core_db::q42_lex::Q42Lexicon> {
201    WORDNET_LEX
202        .get_or_init(|| {
203            let state = crate::state::APP_STATE.get()?;
204            let storage = state.config.lock().ok()?.storage_path.clone();
205            let q42 = resolve_wordnet_q42(Path::new(&storage))?;
206            qualia_core_db::q42_lex::Q42Lexicon::load_for_q42(&q42)
207                .ok()
208                .or_else(|| {
209                    resolve_wordnet_lex(&q42).and_then(|lex_path| {
210                        qualia_core_db::q42_lex::Q42Lexicon::load(&lex_path).ok()
211                    })
212                })
213        })
214        .as_ref()
215}
216
217pub fn wordnet_available(storage: &Path) -> bool {
218    resolve_wordnet_q42(storage).is_some()
219}
220
221pub fn list_branch_types(storage: &Path) -> Vec<ChatBranchType> {
222    let lex = wordnet_lexicon();
223    DISCOURSE_TYPES
224        .iter()
225        .map(|(id, label, emoji, desc, lemmas, keywords)| {
226            let mut branch = ChatBranchType {
227                id: (*id).to_string(),
228                label: (*label).to_string(),
229                emoji: (*emoji).to_string(),
230                description: (*desc).to_string(),
231                wordnet_lemmas: lemmas.iter().map(|s| (*s).to_string()).collect(),
232                keywords: keywords.iter().map(|s| (*s).to_string()).collect(),
233                wordnet_grounding_hash: None,
234                wordnet_gloss: None,
235            };
236            if let Some(lex) = lex {
237                for lemma in *lemmas {
238                    if let Some(hash) = lex.find_literal(lemma) {
239                        branch.wordnet_grounding_hash = Some(format!("0x{hash:016x}"));
240                        branch.wordnet_gloss = lex.lookup(hash).map(|s| s.to_string());
241                        break;
242                    }
243                }
244            }
245            let _ = storage;
246            branch
247        })
248        .collect()
249}
250
251pub fn classify_branch(
252    storage: &Path,
253    anchor_text: &str,
254    reply_text: &str,
255) -> BranchClassification {
256    let types = list_branch_types(storage);
257    let combined = format!("{anchor_text}\n{reply_text}").to_lowercase();
258    let lex = wordnet_lexicon();
259
260    let mut best_id = "comment";
261    let mut best_score = 0f32;
262
263    for t in &types {
264        let mut score = 0f32;
265        for kw in &t.keywords {
266            if combined.contains(&kw.to_lowercase()) {
267                score += 1.5;
268            }
269        }
270        for lemma in &t.wordnet_lemmas {
271            if combined.contains(lemma) {
272                score += 1.0;
273            }
274            if let Some(lex) = lex {
275                for (_, gloss) in lex.search_contains(lemma, 3) {
276                    if combined.contains(&gloss.to_lowercase()) {
277                        score += 0.5;
278                    }
279                }
280            }
281        }
282        if score > best_score {
283            best_score = score;
284            best_id = &t.id;
285        }
286    }
287
288    let chosen = types
289        .iter()
290        .find(|t| t.id == best_id)
291        .or_else(|| types.first());
292
293    if let Some(t) = chosen {
294        BranchClassification {
295            branch_type_id: t.id.clone(),
296            label: t.label.clone(),
297            emoji: t.emoji.clone(),
298            confidence: (best_score / 3.0).min(1.0),
299            wordnet_grounding_hash: t.wordnet_grounding_hash.clone(),
300        }
301    } else {
302        BranchClassification {
303            branch_type_id: "comment".to_string(),
304            label: "Comment".to_string(),
305            emoji: "💬".to_string(),
306            confidence: 0.1,
307            wordnet_grounding_hash: None,
308        }
309    }
310}
311
312pub fn build_chat_ontology_briefing(storage: &Path) -> String {
313    let q42 = resolve_wordnet_q42(storage);
314    let types = list_branch_types(storage);
315    let mut lines = vec!["[Chat discourse ontology]".to_string()];
316    if let Some(path) = q42 {
317        lines.push(format!(
318            "wordnet_grounding: {} (lexicon-backed branch labels)",
319            path.display()
320        ));
321    } else {
322        lines.push(
323            "wordnet_grounding: not installed — place artefacts under Local_LIbraries/wordnet/, import via Ontology Hub, or run scripts/fetch_wordnet.sh"
324                .to_string(),
325        );
326    }
327    lines.push("branch_types:".to_string());
328    for t in types {
329        let wn = t.wordnet_grounding_hash.as_deref().unwrap_or("builtin");
330        lines.push(format!(
331            "  - {} {} ({}) wordnet={}",
332            t.emoji, t.label, t.id, wn
333        ));
334    }
335    lines.push(
336        "instructions: Label each graph branch with the best-matching branch_type when replying to a fragment."
337            .to_string(),
338    );
339    lines.join("\n")
340}
341
342fn reactions_path(storage_root: &Path, session_id: &str) -> PathBuf {
343    storage_root
344        .join("Chats")
345        .join(session_id)
346        .join("reactions.jsonl")
347}
348
349fn unix_now() -> u64 {
350    std::time::SystemTime::now()
351        .duration_since(std::time::UNIX_EPOCH)
352        .unwrap_or_default()
353        .as_secs()
354}
355
356pub fn add_reaction(
357    storage_root: &Path,
358    session_id: &str,
359    message_lamport: u64,
360    emoji: &str,
361) -> Result<Vec<ChatReaction>, String> {
362    if emoji.is_empty() {
363        return Err("Emoji required".to_string());
364    }
365    // Basic validation: allow printable unicode including emoji sequences
366    if emoji.chars().count() > 8 {
367        return Err("Emoji reaction too long".to_string());
368    }
369
370    let profile = crate::user_profile::load_profile();
371    let path = reactions_path(storage_root, session_id);
372    if let Some(parent) = path.parent() {
373        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
374    }
375
376    let mut reactions = list_reactions(storage_root, session_id)?;
377    if reactions.iter().any(|r| {
378        r.message_lamport == message_lamport
379            && r.author_did == profile.public_did
380            && r.emoji == emoji
381    }) {
382        return Ok(reactions);
383    }
384
385    let reaction = ChatReaction {
386        message_lamport,
387        emoji: emoji.to_string(),
388        author_did: profile.public_did.clone(),
389        author_name: profile
390            .sharing
391            .share_display_name
392            .then(|| profile.display_name.clone()),
393        created_at: unix_now(),
394    };
395    reactions.push(reaction);
396
397    let mut file = std::fs::OpenOptions::new()
398        .create(true)
399        .write(true)
400        .truncate(true)
401        .open(&path)
402        .map_err(|e| e.to_string())?;
403    for r in &reactions {
404        use std::io::Write;
405        writeln!(
406            file,
407            "{}",
408            serde_json::to_string(r).map_err(|e| e.to_string())?
409        )
410        .map_err(|e| e.to_string())?;
411    }
412
413    // WAL quin: chat:hasReaction
414    let wal_path = storage_root.join("Chats").join(session_id).join("chat.wal");
415    if wal_path.is_file() {
416        let subject = q_hash(&format!("chat:session:{session_id}"));
417        let predicate = q_hash("chat:hasReaction");
418        let object = q_hash(&format!("react:{message_lamport}:{emoji}")) & 0x0FFF_FFFF_FFFF_FFFF;
419        let context = q_hash(&profile.public_did);
420        let metadata = (message_lamport & 0x1FFF_FFFF) << 32;
421        let parity = subject ^ predicate ^ object ^ context ^ metadata;
422        let quin = qualia_core_db::NQuin {
423            subject,
424            predicate,
425            object,
426            context,
427            metadata,
428            parity,
429        };
430        if let Ok(mut wal) = qualia_core_db::wal::WriteAheadLog::open(&wal_path) {
431            let _ = wal.append_mutation(&quin);
432        }
433    }
434
435    Ok(reactions)
436}
437
438pub fn toggle_reaction(
439    storage_root: &Path,
440    session_id: &str,
441    message_lamport: u64,
442    emoji: &str,
443) -> Result<Vec<ChatReaction>, String> {
444    let profile = crate::user_profile::load_profile();
445    let existing: Vec<_> = list_reactions_for_message(storage_root, session_id, message_lamport)?;
446    let already = existing
447        .iter()
448        .any(|r| r.author_did == profile.public_did && r.emoji == emoji);
449    if already {
450        remove_reaction(storage_root, session_id, message_lamport, emoji)
451    } else {
452        add_reaction(storage_root, session_id, message_lamport, emoji)
453    }
454}
455
456fn remove_reaction(
457    storage_root: &Path,
458    session_id: &str,
459    message_lamport: u64,
460    emoji: &str,
461) -> Result<Vec<ChatReaction>, String> {
462    let profile = crate::user_profile::load_profile();
463    let mut reactions = list_reactions(storage_root, session_id)?;
464    reactions.retain(|r| {
465        !(r.message_lamport == message_lamport
466            && r.author_did == profile.public_did
467            && r.emoji == emoji)
468    });
469    let path = reactions_path(storage_root, session_id);
470    let mut file = std::fs::OpenOptions::new()
471        .create(true)
472        .write(true)
473        .truncate(true)
474        .open(&path)
475        .map_err(|e| e.to_string())?;
476    for r in &reactions {
477        use std::io::Write;
478        writeln!(
479            file,
480            "{}",
481            serde_json::to_string(r).map_err(|e| e.to_string())?
482        )
483        .map_err(|e| e.to_string())?;
484    }
485    Ok(reactions)
486}
487
488pub fn list_reactions(storage_root: &Path, session_id: &str) -> Result<Vec<ChatReaction>, String> {
489    let path = reactions_path(storage_root, session_id);
490    if !path.is_file() {
491        return Ok(Vec::new());
492    }
493    let text = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
494    let mut out = Vec::new();
495    for line in text.lines() {
496        if line.trim().is_empty() {
497            continue;
498        }
499        if let Ok(r) = serde_json::from_str::<ChatReaction>(line) {
500            out.push(r);
501        }
502    }
503    Ok(out)
504}
505
506pub fn list_reactions_for_message(
507    storage_root: &Path,
508    session_id: &str,
509    message_lamport: u64,
510) -> Result<Vec<ChatReaction>, String> {
511    Ok(list_reactions(storage_root, session_id)?
512        .into_iter()
513        .filter(|r| r.message_lamport == message_lamport)
514        .collect())
515}