Skip to main content

qualia_client_core/
solid_chat.rs

1//! **Solid Chat interop** — bidirectional mapping between the native chat model and the SolidOS
2//! "long chat" data model (<https://solid.github.io/chat/>), **without degrading the native format**.
3//!
4//! The native chat (threaded [`ChatSession`]/[`ChatMessage`] with Lamport clocks, roles, agent sub-DIDs,
5//! provenance) is unchanged; this is an **adapter at the boundary**. The no-degradation mechanism is
6//! **additive fidelity**: every exported message carries BOTH
7//!   - the standard Solid subset every long-chat client understands — `meeting:LongChat` +
8//!     `meeting:message`, and per message `dct:created` / `sioc:content` / `foaf:maker`; AND
9//!   - native-only fields as extra `qc:` (`https://ns.webcivics.net/qualia-chat/`) triples on the SAME
10//!     resource (`qc:lamport`, `qc:role`, `qc:contentHash`, `qc:agentDid`, `qc:modelId`,
11//!     `qc:replyToFragment`, …).
12//!
13//! A vanilla Solid client reads the `sioc:`/`meeting:` subset and ignores `qc:`; a Qualia peer reads the
14//! full graph. So **Qualia → Solid → Qualia round-trips losslessly** (proven by the tests), while a Solid
15//! user still sees a normal chat. Messages are serialised with **single-line, escaped string literals**
16//! (one predicate per line) so the round-trip parser is small and robust; hardening for arbitrary Turtle
17//! reuses the engine `N3Parser` (a follow-on). Layout matches the spec: `index.ttl` (the channel) +
18//! date-partitioned `YYYY/MM/DD/chat.ttl` (the day's messages).
19
20use std::collections::BTreeMap;
21use std::fmt::Write as _;
22
23use crate::chat_session::{ChatMessage, ChatSession, Role, SessionKind, SessionMeta};
24
25const NS_QC: &str = "https://ns.webcivics.net/qualia-chat/";
26
27/// The rendered Solid long-chat resources for a session: the channel index plus each day's message file.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct SolidChatExport {
30    /// `index.ttl` — the `meeting:LongChat` channel resource.
31    pub index_ttl: String,
32    /// `YYYY/MM/DD` → the day's `chat.ttl` content.
33    pub day_files: BTreeMap<String, String>,
34}
35
36impl SolidChatExport {
37    /// Flatten to `(relative_path, content)` pairs ready to PUT/PATCH onto a Pod (or write to disk):
38    /// `index.ttl` + `YYYY/MM/DD/chat.ttl`.
39    pub fn files(&self) -> Vec<(String, String)> {
40        let mut out = vec![("index.ttl".to_string(), self.index_ttl.clone())];
41        for (day, body) in &self.day_files {
42            out.push((format!("{day}/chat.ttl"), body.clone()));
43        }
44        out
45    }
46}
47
48fn iso_utc(unix_secs: u64) -> String {
49    chrono::DateTime::from_timestamp(unix_secs as i64, 0)
50        .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
51        .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string())
52}
53
54fn day_path(unix_secs: u64) -> String {
55    chrono::DateTime::from_timestamp(unix_secs as i64, 0)
56        .map(|dt| dt.format("%Y/%m/%d").to_string())
57        .unwrap_or_else(|| "1970/01/01".to_string())
58}
59
60fn parse_iso(s: &str) -> u64 {
61    chrono::DateTime::parse_from_rfc3339(s)
62        .map(|dt| dt.timestamp().max(0) as u64)
63        .unwrap_or(0)
64}
65
66/// Escape a string for a single-line Turtle short-string literal (`"..."`).
67fn esc(s: &str) -> String {
68    let mut out = String::with_capacity(s.len() + 2);
69    for c in s.chars() {
70        match c {
71            '\\' => out.push_str("\\\\"),
72            '"' => out.push_str("\\\""),
73            '\n' => out.push_str("\\n"),
74            '\r' => out.push_str("\\r"),
75            '\t' => out.push_str("\\t"),
76            _ => out.push(c),
77        }
78    }
79    out
80}
81
82fn unesc(s: &str) -> String {
83    let mut out = String::with_capacity(s.len());
84    let mut chars = s.chars();
85    while let Some(c) = chars.next() {
86        if c == '\\' {
87            match chars.next() {
88                Some('n') => out.push('\n'),
89                Some('r') => out.push('\r'),
90                Some('t') => out.push('\t'),
91                Some('"') => out.push('"'),
92                Some('\\') => out.push('\\'),
93                Some(other) => out.push(other),
94                None => {}
95            }
96        } else {
97            out.push(c);
98        }
99    }
100    out
101}
102
103fn maker_uri(msg: &ChatMessage, owner_did: &str) -> String {
104    match &msg.author_did {
105        Some(d) if !d.is_empty() => d.clone(),
106        _ => owner_did.to_string(),
107    }
108}
109
110fn message_id(msg: &ChatMessage) -> String {
111    format!("msg-{}", msg.lamport)
112}
113
114/// Export a native session to the Solid long-chat resources (`index.ttl` + date-partitioned day files).
115pub fn export_session(session: &ChatSession) -> SolidChatExport {
116    export_parts(&session.meta, &session.messages)
117}
118
119/// Export from a session's metadata + messages (decoupled from the heavy `ChatEnvironment`).
120pub fn export_parts(meta: &SessionMeta, messages: &[ChatMessage]) -> SolidChatExport {
121    let owner = &meta.owner_did;
122    let kind = match meta.session_kind {
123        SessionKind::Solo => "solo",
124        SessionKind::Group => "group",
125    };
126
127    // --- index.ttl (the channel) ---
128    let mut index = String::new();
129    let _ = writeln!(
130        index,
131        "@prefix meeting: <http://www.w3.org/ns/pim/meeting#> ."
132    );
133    let _ = writeln!(index, "@prefix dct: <http://purl.org/dc/terms/> .");
134    let _ = writeln!(index, "@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .");
135    let _ = writeln!(index, "@prefix qc: <{NS_QC}> .");
136    let _ = writeln!(index);
137    let _ = writeln!(index, "<#this> a meeting:LongChat ;");
138    let _ = writeln!(index, "    dct:title \"{}\" ;", esc(&meta.title));
139    if !owner.is_empty() {
140        let _ = writeln!(index, "    dct:author <{owner}> ;");
141    }
142    let _ = writeln!(
143        index,
144        "    dct:created \"{}\"^^xsd:dateTime ;",
145        iso_utc(meta.created_at)
146    );
147    if !meta.session_did.is_empty() {
148        let _ = writeln!(index, "    qc:sessionDid \"{}\" ;", esc(&meta.session_did));
149    }
150    let _ = write!(index, "    qc:sessionKind \"{kind}\"");
151    // meeting:message links (day-relative URIs into the message resources).
152    if messages.is_empty() {
153        let _ = writeln!(index, " .");
154    } else {
155        let _ = writeln!(index, " ;");
156        let n = messages.len();
157        for (i, msg) in messages.iter().enumerate() {
158            let uri = format!("{}/chat.ttl#{}", day_path(msg.timestamp), message_id(msg));
159            let sep = if i + 1 == n { " ." } else { " ," };
160            let lead = if i == 0 {
161                "    meeting:message "
162            } else {
163                "        "
164            };
165            let _ = writeln!(index, "{lead}<{uri}>{sep}");
166        }
167    }
168
169    // --- YYYY/MM/DD/chat.ttl (the messages) ---
170    let mut day_files: BTreeMap<String, String> = BTreeMap::new();
171    for msg in messages {
172        let day = day_path(msg.timestamp);
173        let body = day_files.entry(day).or_insert_with(day_header);
174        write_message(body, msg, owner);
175    }
176
177    SolidChatExport {
178        index_ttl: index,
179        day_files,
180    }
181}
182
183fn day_header() -> String {
184    let mut h = String::new();
185    let _ = writeln!(h, "@prefix sioc: <http://rdfs.org/sioc/ns#> .");
186    let _ = writeln!(h, "@prefix dct: <http://purl.org/dc/terms/> .");
187    let _ = writeln!(h, "@prefix foaf: <http://xmlns.com/foaf/0.1/> .");
188    let _ = writeln!(h, "@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .");
189    let _ = writeln!(h, "@prefix qc: <{NS_QC}> .");
190    let _ = writeln!(h);
191    h
192}
193
194fn write_message(out: &mut String, msg: &ChatMessage, owner_did: &str) {
195    let id = message_id(msg);
196    // Standard Solid subset (every long-chat client reads these three).
197    let _ = writeln!(
198        out,
199        "<#{id}> dct:created \"{}\"^^xsd:dateTime ;",
200        iso_utc(msg.timestamp)
201    );
202    let _ = writeln!(out, "    sioc:content \"{}\" ;", esc(&msg.content));
203    let _ = writeln!(out, "    foaf:maker <{}> ;", maker_uri(msg, owner_did));
204    // Additive native fidelity (a vanilla Solid client ignores the qc: namespace).
205    let _ = writeln!(out, "    qc:lamport {} ;", msg.lamport);
206    let _ = writeln!(out, "    qc:role \"{}\" ;", msg.role.as_str());
207    let _ = write!(out, "    qc:contentHash \"{:016x}\"", msg.content_hash);
208    let opt = |out: &mut String, pred: &str, v: &Option<String>, is_uri: bool| {
209        if let Some(x) = v {
210            if !x.is_empty() {
211                if is_uri {
212                    let _ = write!(out, " ;\n    {pred} <{x}>");
213                } else {
214                    let _ = write!(out, " ;\n    {pred} \"{}\"", esc(x));
215                }
216            }
217        }
218    };
219    opt(out, "qc:authorName", &msg.author_name, false);
220    opt(out, "qc:replyToFragment", &msg.reply_to_fragment, false);
221    opt(out, "qc:source", &msg.source, false);
222    opt(out, "qc:subAgentOf", &msg.sub_agent_of, true);
223    opt(out, "qc:agentDid", &msg.agent_did, true);
224    opt(out, "qc:modelId", &msg.model_id, false);
225    opt(out, "qc:agentBackend", &msg.agent_backend, false);
226    let _ = writeln!(out, " .");
227    let _ = writeln!(out);
228}
229
230/// A message parsed back from a Solid long-chat day file. Fields absent in a pure-Solid file (no `qc:`)
231/// are defaulted honestly (role = User, Lamport derived from order by the caller if needed).
232#[derive(Debug, Clone, PartialEq, Eq, Default)]
233pub struct ImportedMessage {
234    pub id: String,
235    pub content: String,
236    pub maker: String,
237    pub created_unix: u64,
238    pub lamport: Option<u64>,
239    pub role: Option<String>,
240    pub content_hash: Option<u64>,
241    pub author_name: Option<String>,
242    pub reply_to_fragment: Option<String>,
243    pub source: Option<String>,
244    pub sub_agent_of: Option<String>,
245    pub agent_did: Option<String>,
246    pub model_id: Option<String>,
247    pub agent_backend: Option<String>,
248}
249
250impl ImportedMessage {
251    /// Reconstruct a native [`ChatMessage`]. `fallback_lamport` is used only when the file carried no
252    /// `qc:lamport` (a message that originated in a non-Qualia Solid client).
253    pub fn to_chat_message(&self, fallback_lamport: u64) -> ChatMessage {
254        let content_hash = self
255            .content_hash
256            .unwrap_or_else(|| crate::chat_session::content_hash_u64(&self.content));
257        ChatMessage {
258            lamport: self.lamport.unwrap_or(fallback_lamport),
259            role: self
260                .role
261                .as_deref()
262                .and_then(|r| Role::from_str(r).ok())
263                .unwrap_or(Role::User),
264            content: self.content.clone(),
265            timestamp: self.created_unix,
266            content_hash,
267            author_did: (!self.maker.is_empty()).then(|| self.maker.clone()),
268            author_name: self.author_name.clone(),
269            reply_to_fragment: self.reply_to_fragment.clone(),
270            source: self.source.clone().or_else(|| Some("solid".to_string())),
271            sub_agent_of: self.sub_agent_of.clone(),
272            agent_did: self.agent_did.clone(),
273            model_id: self.model_id.clone(),
274            agent_backend: self.agent_backend.clone(),
275            outcome_sharing: None,
276        }
277    }
278}
279
280/// Parse a Solid long-chat day file (`chat.ttl`) into messages. Handles our own additive `qc:` output
281/// and the standard `sioc:`/`foaf:`/`dct:` subset produced by any long-chat client (single-line literals).
282pub fn parse_day_ttl(ttl: &str) -> Vec<ImportedMessage> {
283    let mut msgs = Vec::new();
284    // Statements are terminated by " ." at line end; accumulate a subject's lines until then.
285    let mut stmt = String::new();
286    for raw in ttl.lines() {
287        let line = raw.trim();
288        if line.is_empty() || line.starts_with("@prefix") || line.starts_with('#') {
289            continue;
290        }
291        if !stmt.is_empty() {
292            stmt.push(' ');
293        }
294        stmt.push_str(line);
295        if line.ends_with('.') && !line.ends_with("\\.") {
296            if let Some(m) = parse_statement(stmt.trim_end_matches('.').trim()) {
297                msgs.push(m);
298            }
299            stmt.clear();
300        }
301    }
302    msgs
303}
304
305fn parse_statement(stmt: &str) -> Option<ImportedMessage> {
306    // subject predicate obj ; predicate obj ; ...
307    let (subject, rest) = split_first_token(stmt);
308    if !subject.starts_with("<#") {
309        return None;
310    }
311    let mut m = ImportedMessage {
312        id: subject
313            .trim_start_matches("<#")
314            .trim_end_matches('>')
315            .to_string(),
316        ..Default::default()
317    };
318    for clause in split_top_level_semicolons(rest) {
319        let clause = clause.trim();
320        if clause.is_empty() {
321            continue;
322        }
323        let (pred, obj) = split_first_token(clause);
324        let obj = obj.trim();
325        match pred {
326            "a" => {}
327            "dct:created" => m.created_unix = parse_iso(&literal(obj)),
328            "sioc:content" => m.content = literal(obj),
329            "foaf:maker" => m.maker = uri(obj),
330            "qc:lamport" => m.lamport = obj.trim().parse().ok(),
331            "qc:role" => m.role = Some(literal(obj)),
332            "qc:contentHash" => m.content_hash = u64::from_str_radix(&literal(obj), 16).ok(),
333            "qc:authorName" => m.author_name = Some(literal(obj)),
334            "qc:replyToFragment" => m.reply_to_fragment = Some(literal(obj)),
335            "qc:source" => m.source = Some(literal(obj)),
336            "qc:subAgentOf" => m.sub_agent_of = Some(uri(obj)),
337            "qc:agentDid" => m.agent_did = Some(uri(obj)),
338            "qc:modelId" => m.model_id = Some(literal(obj)),
339            "qc:agentBackend" => m.agent_backend = Some(literal(obj)),
340            _ => {}
341        }
342    }
343    (!m.id.is_empty()).then_some(m)
344}
345
346fn split_first_token(s: &str) -> (&str, &str) {
347    let s = s.trim_start();
348    match s.find(char::is_whitespace) {
349        Some(i) => (&s[..i], s[i..].trim_start()),
350        None => (s, ""),
351    }
352}
353
354/// Split on `;` that are not inside a `"..."` literal.
355fn split_top_level_semicolons(s: &str) -> Vec<String> {
356    let mut out = Vec::new();
357    let mut cur = String::new();
358    let mut in_str = false;
359    let mut escaped = false;
360    for c in s.chars() {
361        match c {
362            '\\' if in_str => {
363                escaped = !escaped;
364                cur.push(c);
365            }
366            '"' if !escaped => {
367                in_str = !in_str;
368                cur.push(c);
369            }
370            ';' if !in_str => {
371                out.push(std::mem::take(&mut cur));
372            }
373            _ => {
374                escaped = false;
375                cur.push(c);
376            }
377        }
378    }
379    if !cur.trim().is_empty() {
380        out.push(cur);
381    }
382    out
383}
384
385/// Extract a Turtle literal's text: `"..."` possibly followed by `^^xsd:...`.
386fn literal(obj: &str) -> String {
387    let obj = obj.trim();
388    if let Some(rest) = obj.strip_prefix('"') {
389        // find closing unescaped quote
390        let mut end = None;
391        let mut escaped = false;
392        for (i, c) in rest.char_indices() {
393            if c == '\\' && !escaped {
394                escaped = true;
395            } else if c == '"' && !escaped {
396                end = Some(i);
397                break;
398            } else {
399                escaped = false;
400            }
401        }
402        if let Some(i) = end {
403            return unesc(&rest[..i]);
404        }
405    }
406    obj.to_string()
407}
408
409fn uri(obj: &str) -> String {
410    obj.trim()
411        .trim_start_matches('<')
412        .trim_end_matches('>')
413        .to_string()
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::chat_session::{ChatMessage, Role, SessionKind, SessionMeta};
420
421    fn msg(lamport: u64, role: Role, content: &str, ts: u64) -> ChatMessage {
422        ChatMessage {
423            lamport,
424            role,
425            content: content.into(),
426            timestamp: ts,
427            content_hash: crate::chat_session::content_hash_u64(content),
428            author_did: Some("did:wf:alice".into()),
429            author_name: Some("Alice".into()),
430            reply_to_fragment: None,
431            source: None,
432            sub_agent_of: None,
433            agent_did: None,
434            model_id: None,
435            agent_backend: None,
436            outcome_sharing: None,
437        }
438    }
439
440    fn meta() -> SessionMeta {
441        SessionMeta {
442            id: "s1".into(),
443            title: "Care chat".into(),
444            created_at: 1_700_000_000,
445            updated_at: 1_700_000_100,
446            message_count: 0,
447            next_lamport: 99,
448            environment_ref: String::new(),
449            session_kind: SessionKind::Group,
450            participants: vec![],
451            owner_did: "did:wf:owner".into(),
452            session_did: "did:qualia:chat:group:abcd".into(),
453        }
454    }
455
456    #[test]
457    fn index_declares_a_long_chat() {
458        let export = export_parts(&meta(), &[msg(1, Role::User, "hi", 1_700_000_050)]);
459        assert!(export.index_ttl.contains("a meeting:LongChat"));
460        assert!(export.index_ttl.contains("dct:title \"Care chat\""));
461        assert!(export.index_ttl.contains("dct:author <did:wf:owner>"));
462        assert!(export.index_ttl.contains("meeting:message"));
463        // Day-partitioned message URI.
464        assert!(
465            export.index_ttl.contains("2023/11/14/chat.ttl#msg-1")
466                || export.index_ttl.contains("/chat.ttl#msg-1")
467        );
468    }
469
470    #[test]
471    fn message_carries_solid_subset_and_native_fidelity() {
472        let mut m = msg(7, Role::Agent, "grounded answer", 1_700_000_050);
473        m.agent_did = Some("did:wf:agent7".into());
474        m.model_id = Some("qwen2-1_5b".into());
475        m.sub_agent_of = Some("did:wf:owner".into());
476        let export = export_parts(&meta(), &[m]);
477        let day = export.day_files.values().next().unwrap();
478        // Standard Solid subset:
479        assert!(day.contains("sioc:content \"grounded answer\""));
480        assert!(day.contains("foaf:maker <did:wf:alice>"));
481        assert!(day.contains("dct:created"));
482        // Additive native fidelity (no degradation):
483        assert!(day.contains("qc:lamport 7"));
484        assert!(day.contains("qc:role \"agent\""));
485        assert!(day.contains("qc:agentDid <did:wf:agent7>"));
486        assert!(day.contains("qc:modelId \"qwen2-1_5b\""));
487        assert!(day.contains("qc:subAgentOf <did:wf:owner>"));
488    }
489
490    #[test]
491    fn roundtrip_is_lossless() {
492        let mut agent = msg(2, Role::Agent, "line one\nline \"two\"", 1_700_000_060);
493        agent.agent_did = Some("did:wf:agent".into());
494        agent.model_id = Some("m1".into());
495        agent.reply_to_fragment = Some("frag-1".into());
496        agent.author_did = Some("did:wf:agent".into());
497        let originals = vec![msg(1, Role::User, "hello", 1_700_000_050), agent];
498        let export = export_parts(&meta(), &originals);
499
500        // Parse every day file back.
501        let mut parsed: Vec<ImportedMessage> = export
502            .day_files
503            .values()
504            .flat_map(|b| parse_day_ttl(b))
505            .collect();
506        parsed.sort_by_key(|m| m.lamport.unwrap_or(0));
507        assert_eq!(parsed.len(), 2);
508
509        for (orig, back) in originals.iter().zip(parsed.iter()) {
510            let rebuilt = back.to_chat_message(0);
511            assert_eq!(
512                rebuilt.content, orig.content,
513                "content (incl. newlines/quotes) preserved"
514            );
515            assert_eq!(rebuilt.lamport, orig.lamport);
516            assert_eq!(rebuilt.role, orig.role);
517            assert_eq!(rebuilt.content_hash, orig.content_hash);
518            assert_eq!(rebuilt.author_did, orig.author_did);
519            assert_eq!(rebuilt.reply_to_fragment, orig.reply_to_fragment);
520            assert_eq!(rebuilt.agent_did, orig.agent_did);
521            assert_eq!(rebuilt.model_id, orig.model_id);
522        }
523    }
524
525    #[test]
526    fn pure_solid_message_without_qc_still_imports() {
527        // A message from a non-Qualia client: only the sioc/foaf/dct subset.
528        let ttl = r#"@prefix sioc: <http://rdfs.org/sioc/ns#> .
529@prefix dct: <http://purl.org/dc/terms/> .
530@prefix foaf: <http://xmlns.com/foaf/0.1/> .
531@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
532
533<#msgX> dct:created "2023-11-14T20:54:10Z"^^xsd:dateTime ;
534    sioc:content "hi from solid" ;
535    foaf:maker <https://bob.example/profile/card#me> .
536"#;
537        let parsed = parse_day_ttl(ttl);
538        assert_eq!(parsed.len(), 1);
539        let m = parsed[0].to_chat_message(42);
540        assert_eq!(m.content, "hi from solid");
541        assert_eq!(
542            m.author_did.as_deref(),
543            Some("https://bob.example/profile/card#me")
544        );
545        assert_eq!(m.lamport, 42, "no qc:lamport → caller's fallback");
546        assert_eq!(m.role, Role::User, "default role");
547        assert_eq!(m.source.as_deref(), Some("solid"));
548    }
549}