1use qualia_core_db::modalities::logic::deontic::{
4 compile_norm_quin, OP_FORBID, OP_OBLIGATE, OP_PERMIT,
5};
6use qualia_core_db::{q_hash, NQuin};
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9
10use super::extract::{
11 classify_deontic, extract_cross_refs, extract_privacy_signals, extract_rights_signals,
12 extract_temporal_signals, DeonticClass, SignalHit,
13};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ContextUnit {
18 pub frag: String,
19 pub kind: String,
20 pub label: String,
21 pub text: String,
22 pub page: Option<u32>,
23 pub parent: Option<String>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct CmlConcept {
29 pub id: String,
30 pub label: String,
31 pub kind: String,
32 pub deontic: String,
33 pub deontic_confidence: u8,
34 pub privacy_signals: Vec<String>,
35 pub rights_signals: Vec<String>,
36 pub temporal_signals: Vec<String>,
37 pub cross_refs: Vec<String>,
38 pub summary: String,
39 pub curation: String,
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct CmlContextGraph {
45 pub document_uri: String,
46 pub title: String,
47 pub concepts: Vec<CmlConcept>,
48 pub signal_tags: Vec<String>,
50 pub topics: Vec<String>,
51 pub purposes: Vec<String>,
52 pub n3: String,
54 #[serde(skip)]
56 pub quins: Vec<NQuin>,
57 pub deontic_norms: usize,
58 pub privacy_hits: usize,
59 pub rights_hits: usize,
60}
61
62fn lit(s: &str) -> String {
63 s.replace('\\', "\\\\")
64 .replace('"', "\\\"")
65 .replace('\n', "\\n")
66 .replace('\r', "")
67}
68
69fn fnv60_str(s: &str) -> u64 {
70 q_hash(s) & 0x0FFF_FFFF_FFFF_FFFF
71}
72
73pub fn units_from_headings(text: &str) -> Vec<ContextUnit> {
75 let mut units = Vec::new();
76 let mut cur_label = "body".to_string();
77 let mut cur_frag = "body".to_string();
78 let mut buf: Vec<&str> = Vec::new();
79 let mut n = 0u32;
80
81 let flush = |label: &str, frag: &str, buf: &mut Vec<&str>, units: &mut Vec<ContextUnit>| {
82 let body = buf.join("\n").trim().to_string();
83 buf.clear();
84 if body.is_empty() && label == "body" {
85 return;
86 }
87 units.push(ContextUnit {
88 frag: frag.to_string(),
89 kind: "section".into(),
90 label: label.to_string(),
91 text: body,
92 page: None,
93 parent: None,
94 });
95 };
96
97 for line in text.lines() {
98 let t = line.trim();
99 let is_md = t.starts_with('#');
100 let is_caps = t.len() >= 4
101 && t.len() <= 80
102 && t.chars().filter(|c| c.is_alphabetic()).count() >= 3
103 && t.chars()
104 .filter(|c| c.is_alphabetic())
105 .all(|c| c.is_uppercase());
106 if is_md || is_caps {
107 flush(&cur_label, &cur_frag, &mut buf, &mut units);
108 n += 1;
109 cur_label = t.trim_start_matches('#').trim().to_string();
110 cur_frag = format!("h-{n}");
111 continue;
112 }
113 buf.push(line);
114 }
115 flush(&cur_label, &cur_frag, &mut buf, &mut units);
116 if units.is_empty() {
117 units_from_paragraphs(text)
118 } else {
119 units
120 }
121}
122
123pub fn units_from_paragraphs(text: &str) -> Vec<ContextUnit> {
125 let mut units = Vec::new();
126 for (i, block) in text.split("\n\n").enumerate() {
127 let body = block.trim();
128 if body.is_empty() {
129 continue;
130 }
131 let label = body
132 .lines()
133 .next()
134 .unwrap_or("¶")
135 .chars()
136 .take(80)
137 .collect();
138 units.push(ContextUnit {
139 frag: format!("p-{}", i + 1),
140 kind: "paragraph".into(),
141 label,
142 text: body.to_string(),
143 page: None,
144 parent: None,
145 });
146 if units.len() >= 64 {
147 break;
148 }
149 }
150 if units.is_empty() && !text.trim().is_empty() {
151 units.push(ContextUnit {
152 frag: "body".into(),
153 kind: "document".into(),
154 label: "Document".into(),
155 text: text.to_string(),
156 page: None,
157 parent: None,
158 });
159 }
160 units
161}
162
163fn deontic_opcode(class: DeonticClass) -> Option<u8> {
164 match class {
165 DeonticClass::Obligation => Some(OP_OBLIGATE),
166 DeonticClass::Permission => Some(OP_PERMIT),
167 DeonticClass::Prohibition => Some(OP_FORBID),
168 DeonticClass::Right => Some(OP_PERMIT), DeonticClass::Undertaking => None,
170 }
171}
172
173fn summarise(text: &str, max: usize) -> String {
174 let one = Regex::new(r"\s+").unwrap().replace_all(text.trim(), " ");
175 if one.chars().count() <= max {
176 one.into_owned()
177 } else {
178 let s: String = one.chars().take(max).collect();
179 format!("{s}…")
180 }
181}
182
183pub fn build_unit_context(doc_uri: &str, unit: &ContextUnit) -> CmlContextGraph {
185 build_document_context(doc_uri, &unit.label, std::slice::from_ref(unit))
186}
187
188pub fn build_document_context(
190 doc_uri: &str,
191 title: &str,
192 units: &[ContextUnit],
193) -> CmlContextGraph {
194 let ctx_hash = fnv60_str(doc_uri);
195 let party = fnv60_str(&format!("{doc_uri}#party:addressee"));
196 let mut graph = CmlContextGraph {
197 document_uri: doc_uri.into(),
198 title: title.into(),
199 ..Default::default()
200 };
201
202 let mut n3 = String::new();
203 n3.push_str("@prefix cml: <https://ns.webcivics.net/cml/> .\n");
204 n3.push_str("@prefix values: <https://ns.webcivics.net/values/> .\n");
205 n3.push_str("@prefix skos: <http://www.w3.org/2004/02/skos/core#> .\n");
206 n3.push_str("@prefix dc: <http://purl.org/dc/terms/> .\n");
207 n3.push_str("@prefix prov: <http://www.w3.org/ns/prov#> .\n");
208 n3.push_str("@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n");
209 n3.push_str(&format!(
210 "\n<{doc_uri}> a values:Document, cml:SourceDocument ;\n dc:title \"{}\" ;\n cml:curationStatus cml:Proposed ;\n cml:proposedBy <urn:qualia:cml-context:rust> .\n\n",
211 lit(title)
212 ));
213
214 let mut topics: Vec<String> = vec!["cml".into(), "context-graph".into()];
215 let mut purposes: Vec<String> = vec!["semantic".into()];
216 let mut signal_tags: Vec<String> = Vec::new();
217
218 for unit in units {
219 let body = unit.text.trim();
220 if body.is_empty()
221 && unit.kind != "part"
222 && unit.kind != "division"
223 && unit.kind != "schedule"
224 {
225 continue;
226 }
227 let (deontic, dconf) = if body.is_empty() {
228 (DeonticClass::Undertaking, 0)
229 } else {
230 classify_deontic(body)
231 };
232 let privacy = extract_privacy_signals(body);
233 let rights = extract_rights_signals(body);
234 let temporal = extract_temporal_signals(body);
235 let xrefs = extract_cross_refs(body);
236
237 let concept_id = format!("{doc_uri}#{}", unit.frag);
238 let label = if unit.label.is_empty() {
239 unit.frag.clone()
240 } else {
241 unit.label.clone()
242 };
243
244 let mut concept = CmlConcept {
245 id: concept_id.clone(),
246 label: label.clone(),
247 kind: unit.kind.clone(),
248 deontic: deontic.as_str().into(),
249 deontic_confidence: dconf,
250 privacy_signals: privacy.iter().map(|s| s.signal.clone()).collect(),
251 rights_signals: rights.iter().map(|s| s.signal.clone()).collect(),
252 temporal_signals: temporal.iter().map(|s| s.signal.clone()).collect(),
253 cross_refs: xrefs.clone(),
254 summary: summarise(body, 280),
255 curation: "cml:Proposed".into(),
256 };
257
258 signal_tags.push(format!("deontic:{}", deontic.as_str()));
260 topics.push(format!("deontic:{}", deontic.as_str()));
261 for s in &privacy {
262 let tag = format!("privacy:{}", s.signal);
263 signal_tags.push(tag.clone());
264 topics.push(tag);
265 graph.privacy_hits += 1;
266 }
267 for s in &rights {
268 let tag = format!("rights:{}", s.signal);
269 signal_tags.push(tag.clone());
270 topics.push(tag);
271 graph.rights_hits += 1;
272 }
273 for s in &temporal {
274 signal_tags.push(format!("temporal:{}", s.signal));
275 topics.push(format!("temporal:{}", s.signal));
276 }
277 if !privacy.is_empty() {
278 purposes.push("privacy".into());
279 purposes.push("data-protection".into());
280 }
281 if matches!(
282 deontic,
283 DeonticClass::Obligation | DeonticClass::Prohibition | DeonticClass::Right
284 ) {
285 purposes.push("compliance".into());
286 }
287
288 n3.push_str(&format!(
290 "<{concept_id}> a cml:Concept ;\n skos:prefLabel \"{}\" ;\n cml:curationStatus cml:Proposed ;\n cml:proposedBy <urn:qualia:cml-context:rust> ;\n values:kind \"{}\" ;\n values:deonticClass \"{}\" ;\n cml:confidence \"{dconf}\"^^xsd:integer ;\n",
291 lit(&label),
292 lit(&unit.kind),
293 deontic.as_str(),
294 ));
295 if !body.is_empty() {
296 let body_for_n3 = if body.chars().count() > 8000 {
297 let s: String = body.chars().take(8000).collect();
298 format!("{s}…")
299 } else {
300 body.to_string()
301 };
302 n3.push_str(&format!(
303 " values:originalText \"{}\" ;\n",
304 lit(&body_for_n3)
305 ));
306 }
307 for s in &privacy {
308 n3.push_str(&format!(
309 " cml:hasSignal <urn:signal:privacy:{}> ;\n",
310 s.signal
311 ));
312 }
313 for s in &rights {
314 n3.push_str(&format!(
315 " cml:hasSignal <urn:signal:rights:{}> ;\n",
316 s.signal
317 ));
318 }
319 for r in &xrefs {
320 n3.push_str(&format!(" dc:references \"{}\" ;\n", lit(r)));
321 }
322 n3.push_str(&format!(
323 " skos:note \"{}\" ;\n values:partOf <{doc_uri}> .\n\n",
324 lit(&concept.summary)
325 ));
326 n3.push_str(&format!(
327 "<{concept_id}-norm> a {} ;\n cml:modality cml:Deontic ;\n values:partOf <{concept_id}> ;\n values:deonticStatus values:HeuristicDerived ;\n cml:curationStatus cml:Proposed .\n\n",
328 deontic.cml_type()
329 ));
330
331 if let Some(op) = deontic_opcode(deontic) {
333 let action = fnv60_str(&format!("{concept_id}#action"));
334 let path = fnv60_str(&format!("q42:cml:{}", deontic.as_str()));
335 let quin = compile_norm_quin(party, op, path, action, ctx_hash, 0, false);
336 graph.quins.push(quin);
337 graph.deontic_norms += 1;
338 }
339
340 for s in privacy.iter().chain(rights.iter()).chain(temporal.iter()) {
342 graph.quins.push(signal_quin(doc_uri, unit, s));
343 }
344
345 if body.is_empty() {
347 concept.summary = format!("{} {}", unit.kind, label);
348 }
349 graph.concepts.push(concept);
350 }
351
352 if graph.privacy_hits > 0 {
354 topics.push("gdpr-family".into());
355 topics.push("privacy".into());
356 }
357 if graph.deontic_norms > 0 {
358 topics.push("deontic".into());
359 topics.push("normative".into());
360 }
361
362 topics.sort();
363 topics.dedup();
364 purposes.sort();
365 purposes.dedup();
366 signal_tags.sort();
367 signal_tags.dedup();
368
369 graph.topics = topics;
370 graph.purposes = purposes;
371 graph.signal_tags = signal_tags;
372 graph.n3 = n3;
373 graph
374}
375
376fn signal_quin(doc_uri: &str, unit: &ContextUnit, hit: &SignalHit) -> NQuin {
377 let subject = fnv60_str(&format!("{doc_uri}#{}", unit.frag));
379 let predicate = fnv60_str(&format!("urn:qualia:cml:signal:{}", hit.family));
380 let object = fnv60_str(&format!(
381 "urn:qualia:cml:signal:{}:{}",
382 hit.family, hit.signal
383 ));
384 let context = fnv60_str(doc_uri);
385 let metadata = hit.confidence as u64;
386 NQuin {
387 subject,
388 predicate,
389 object,
390 context,
391 metadata,
392 parity: subject ^ predicate ^ object ^ context,
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn builds_graph_with_privacy_and_deontic() {
402 let units = vec![ContextUnit {
403 frag: "sec-1".into(),
404 kind: "section".into(),
405 label: "1 Processing".into(),
406 text: "The controller shall not process personal data without consent. \
407 The data subject has a right to erasure."
408 .into(),
409 page: Some(1),
410 parent: None,
411 }];
412 let g = build_document_context("urn:doc:privacy-act", "Privacy Act Demo", &units);
413 assert_eq!(g.concepts.len(), 1);
414 assert!(g.deontic_norms >= 1);
415 assert!(g.privacy_hits >= 2);
416 assert!(g.n3.contains("values:originalText"));
417 assert!(g.n3.contains("cml:Proposed"));
418 assert!(g.signal_tags.iter().any(|t| t.starts_with("privacy:")));
419 assert!(g
420 .topics
421 .iter()
422 .any(|t| t == "gdpr-family" || t.starts_with("privacy:")));
423 assert!(!g.quins.is_empty());
424 }
425
426 #[test]
427 fn heading_split_produces_units() {
428 let text = "# Title\nIntro line.\n\n# Duties\nA person must comply.\n";
429 let u = units_from_headings(text);
430 assert!(u.len() >= 2);
431 assert!(u.iter().any(|x| x.text.contains("must comply")));
432 }
433}