1use 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 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 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}