1use regex::Regex;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum DeonticClass {
12 Obligation,
13 Permission,
14 Prohibition,
15 Right,
16 Undertaking,
18}
19
20impl DeonticClass {
21 pub fn as_str(self) -> &'static str {
22 match self {
23 Self::Obligation => "obligation",
24 Self::Permission => "permission",
25 Self::Prohibition => "prohibition",
26 Self::Right => "right",
27 Self::Undertaking => "undertaking",
28 }
29 }
30
31 pub fn cml_type(self) -> &'static str {
32 match self {
33 Self::Obligation => "values:Obligation",
34 Self::Permission => "values:Permission",
35 Self::Prohibition => "values:Prohibition",
36 Self::Right => "values:Right",
37 Self::Undertaking => "values:Undertaking",
38 }
39 }
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct SignalHit {
45 pub family: String,
46 pub signal: String,
47 pub confidence: u8,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum PrivacySignal {
53 PersonalData,
54 SpecialCategory,
55 DataSubject,
56 Controller,
57 Processor,
58 Consent,
59 LawfulBasis,
60 PurposeLimitation,
61 DataMinimisation,
62 StorageLimitation,
63 IntegrityConfidentiality,
64 Accountability,
65 Erasure,
66 AccessRight,
67 Portability,
68 Objection,
69 Rectification,
70 Restriction,
71 AutomatedDecision,
72 Dpia,
73 CrossBorderTransfer,
74 BreachNotification,
75 Children,
76 Surveillance,
77}
78
79impl PrivacySignal {
80 pub fn as_str(self) -> &'static str {
81 match self {
82 Self::PersonalData => "personal-data",
83 Self::SpecialCategory => "special-category-data",
84 Self::DataSubject => "data-subject",
85 Self::Controller => "controller",
86 Self::Processor => "processor",
87 Self::Consent => "consent",
88 Self::LawfulBasis => "lawful-basis",
89 Self::PurposeLimitation => "purpose-limitation",
90 Self::DataMinimisation => "data-minimisation",
91 Self::StorageLimitation => "storage-limitation",
92 Self::IntegrityConfidentiality => "integrity-confidentiality",
93 Self::Accountability => "accountability",
94 Self::Erasure => "erasure-right-to-be-forgotten",
95 Self::AccessRight => "access-right",
96 Self::Portability => "data-portability",
97 Self::Objection => "right-to-object",
98 Self::Rectification => "rectification",
99 Self::Restriction => "restriction-of-processing",
100 Self::AutomatedDecision => "automated-decision-making",
101 Self::Dpia => "dpia",
102 Self::CrossBorderTransfer => "cross-border-transfer",
103 Self::BreachNotification => "breach-notification",
104 Self::Children => "children-data",
105 Self::Surveillance => "surveillance",
106 }
107 }
108}
109
110pub fn classify_deontic(text: &str) -> (DeonticClass, u8) {
112 let t = text.to_ascii_lowercase();
113 let forbid = [
115 "must not",
116 "shall not",
117 "may not",
118 "is prohibited",
119 "are prohibited",
120 "it is an offence",
121 "commits an offence",
122 "is guilty of an offence",
123 "must never",
124 "shall never",
125 "is forbidden",
126 "not permitted to",
127 "unlawful to",
128 ];
129 if forbid.iter().any(|p| t.contains(p)) {
130 return (DeonticClass::Prohibition, 88);
131 }
132 let right = [
133 "has a right to",
134 "have a right to",
135 "is entitled to",
136 "are entitled to",
137 "right of access",
138 "right to erasure",
139 "right to be forgotten",
140 "right to object",
141 "right to data portability",
142 "right to rectification",
143 "right to restriction",
144 "fundamental right",
145 "human right",
146 ];
147 if right.iter().any(|p| t.contains(p)) {
148 return (DeonticClass::Right, 86);
149 }
150 let oblig = [
151 " must ",
152 " shall ",
153 "is required to",
154 "are required to",
155 "is obliged to",
156 "duty to",
157 "obligation to",
158 "must ensure",
159 "shall ensure",
160 "must provide",
161 "shall provide",
162 "must notify",
163 "shall notify",
164 ];
165 if t.starts_with("must ") || t.starts_with("shall ") {
167 return (DeonticClass::Obligation, 84);
168 }
169 if oblig.iter().any(|p| t.contains(p)) {
170 return (DeonticClass::Obligation, 82);
171 }
172 let permit = [
173 " may ",
174 "is permitted to",
175 "are permitted to",
176 "is authorised to",
177 "is authorized to",
178 "has power to",
179 "may elect",
180 "at the discretion",
181 ];
182 if t.starts_with("may ") {
183 return (DeonticClass::Permission, 78);
184 }
185 if permit.iter().any(|p| t.contains(p)) {
186 return (DeonticClass::Permission, 76);
187 }
188 (DeonticClass::Undertaking, 40)
189}
190
191pub fn extract_privacy_signals(text: &str) -> Vec<SignalHit> {
193 let t = text.to_ascii_lowercase();
194 let mut hits = Vec::new();
195 let mut push = |sig: PrivacySignal, conf: u8, needles: &[&str]| {
196 if needles.iter().any(|n| t.contains(n)) {
197 hits.push(SignalHit {
198 family: "privacy".into(),
199 signal: sig.as_str().into(),
200 confidence: conf,
201 });
202 }
203 };
204 push(
205 PrivacySignal::PersonalData,
206 90,
207 &[
208 "personal data",
209 "personal information",
210 "personally identifiable",
211 "pii",
212 "information about an individual",
213 "identifiable natural person",
214 ],
215 );
216 push(
217 PrivacySignal::SpecialCategory,
218 92,
219 &[
220 "special category",
221 "sensitive personal",
222 "racial or ethnic",
223 "political opinion",
224 "religious belief",
225 "trade union membership",
226 "genetic data",
227 "biometric data",
228 "health data",
229 "sex life",
230 "sexual orientation",
231 ],
232 );
233 push(
234 PrivacySignal::DataSubject,
235 88,
236 &["data subject", "individual concerned", "person to whom"],
237 );
238 push(
239 PrivacySignal::Controller,
240 88,
241 &[
242 "data controller",
243 "controller shall",
244 "controller must",
245 "as controller",
246 ],
247 );
248 push(
249 PrivacySignal::Processor,
250 88,
251 &[
252 "data processor",
253 "processor shall",
254 "processor must",
255 "as processor",
256 ],
257 );
258 push(
259 PrivacySignal::Consent,
260 85,
261 &[
262 "consent",
263 "freely given",
264 "informed consent",
265 "withdraw consent",
266 ],
267 );
268 push(
269 PrivacySignal::LawfulBasis,
270 87,
271 &[
272 "lawful basis",
273 "lawful ground",
274 "legitimate interest",
275 "legal obligation",
276 "vital interest",
277 "public task",
278 "contractual necessity",
279 ],
280 );
281 push(
282 PrivacySignal::PurposeLimitation,
283 84,
284 &[
285 "purpose limitation",
286 "specified purpose",
287 "compatible purpose",
288 "further processing",
289 ],
290 );
291 push(
292 PrivacySignal::DataMinimisation,
293 84,
294 &[
295 "data minimisation",
296 "data minimization",
297 "not excessive",
298 "adequate, relevant",
299 ],
300 );
301 push(
302 PrivacySignal::StorageLimitation,
303 84,
304 &[
305 "storage limitation",
306 "no longer than necessary",
307 "retention period",
308 "kept no longer",
309 ],
310 );
311 push(
312 PrivacySignal::IntegrityConfidentiality,
313 83,
314 &[
315 "integrity and confidentiality",
316 "appropriate security",
317 "technical and organisational",
318 "technical and organizational",
319 "security of processing",
320 "encryption",
321 "pseudonymisation",
322 "pseudonymization",
323 ],
324 );
325 push(
326 PrivacySignal::Accountability,
327 82,
328 &[
329 "accountability",
330 "demonstrate compliance",
331 "records of processing",
332 ],
333 );
334 push(
335 PrivacySignal::Erasure,
336 90,
337 &[
338 "right to erasure",
339 "right to be forgotten",
340 "erase personal data",
341 "delete the personal",
342 "destruction of personal",
343 ],
344 );
345 push(
346 PrivacySignal::AccessRight,
347 88,
348 &[
349 "right of access",
350 "subject access",
351 "access to personal data",
352 "copy of the personal",
353 ],
354 );
355 push(
356 PrivacySignal::Portability,
357 88,
358 &[
359 "data portability",
360 "structured, commonly used",
361 "machine-readable format",
362 ],
363 );
364 push(
365 PrivacySignal::Objection,
366 86,
367 &["right to object", "object to processing", "opt out"],
368 );
369 push(
370 PrivacySignal::Rectification,
371 86,
372 &[
373 "right to rectification",
374 "rectify",
375 "inaccurate personal data",
376 ],
377 );
378 push(
379 PrivacySignal::Restriction,
380 85,
381 &["restriction of processing", "restrict processing"],
382 );
383 push(
384 PrivacySignal::AutomatedDecision,
385 90,
386 &[
387 "automated decision",
388 "automated processing",
389 "profiling",
390 "solely automated",
391 "algorithmic decision",
392 ],
393 );
394 push(
395 PrivacySignal::Dpia,
396 91,
397 &[
398 "data protection impact assessment",
399 "dpia",
400 "privacy impact assessment",
401 "pia ",
402 ],
403 );
404 push(
405 PrivacySignal::CrossBorderTransfer,
406 89,
407 &[
408 "transfer to a third country",
409 "cross-border",
410 "cross border",
411 "adequacy decision",
412 "standard contractual clauses",
413 "binding corporate rules",
414 "overseas disclosure",
415 "overseas recipient",
416 ],
417 );
418 push(
419 PrivacySignal::BreachNotification,
420 90,
421 &[
422 "personal data breach",
423 "data breach",
424 "notify the supervisory",
425 "notify the commissioner",
426 "breach notification",
427 "security incident",
428 ],
429 );
430 push(
431 PrivacySignal::Children,
432 87,
433 &[
434 "child's personal data",
435 "children's data",
436 "under the age of 16",
437 "parental consent",
438 ],
439 );
440 push(
441 PrivacySignal::Surveillance,
442 80,
443 &[
444 "surveillance",
445 "intercept",
446 "tracking",
447 "cctv",
448 "location data",
449 "metadata retention",
450 ],
451 );
452 hits.sort_by(|a, b| a.signal.cmp(&b.signal));
454 hits.dedup_by(|a, b| {
455 if a.signal == b.signal {
456 if a.confidence < b.confidence {
457 *a = b.clone();
458 }
459 true
460 } else {
461 false
462 }
463 });
464 hits
465}
466
467pub fn extract_rights_signals(text: &str) -> Vec<SignalHit> {
469 let t = text.to_ascii_lowercase();
470 let mut hits = Vec::new();
471 let pairs: &[(&str, &str, u8)] = &[
472 ("human-rights", "human rights", 88),
473 ("human-rights", "charter of rights", 90),
474 ("human-rights", "bill of rights", 88),
475 ("non-discrimination", "discrimination", 80),
476 ("non-discrimination", "equal treatment", 82),
477 ("due-process", "due process", 85),
478 ("due-process", "natural justice", 84),
479 ("freedom-of-expression", "freedom of expression", 88),
480 ("freedom-of-expression", "freedom of speech", 86),
481 ("privacy-as-right", "right to privacy", 90),
482 ("privacy-as-right", "respect for private life", 88),
483 ("liberty", "personal liberty", 82),
484 ("fair-trial", "fair trial", 88),
485 ("refugees", "non-refoulement", 90),
486 ("indigenous", "indigenous", 75),
487 ("indigenous", "aboriginal", 75),
488 ("disability", "disability rights", 85),
489 ("labour", "workplace right", 80),
490 ("labour", "industrial relations", 78),
491 ];
492 for (sig, needle, conf) in pairs {
493 if t.contains(needle) {
494 hits.push(SignalHit {
495 family: "rights".into(),
496 signal: (*sig).into(),
497 confidence: *conf,
498 });
499 }
500 }
501 hits.sort_by(|a, b| a.signal.cmp(&b.signal));
502 hits.dedup_by(|a, b| a.signal == b.signal);
503 hits
504}
505
506pub fn extract_temporal_signals(text: &str) -> Vec<SignalHit> {
508 let t = text.to_ascii_lowercase();
509 let mut hits = Vec::new();
510 let pairs: &[(&str, &str, u8)] = &[
511 ("commencement", "commences", 80),
512 ("commencement", "comes into force", 85),
513 ("commencement", "comes into operation", 85),
514 ("royal-assent", "royal assent", 88),
515 ("proclamation", "proclamation", 82),
516 ("deadline-days", "within ", 70),
517 ("deadline-days", " not later than ", 78),
518 ("deadline-days", "no later than", 80),
519 ("sunset", "ceases to have effect", 85),
520 ("sunset", "sunsets", 80),
521 ("retrospective", "is taken to have", 82),
522 ("retrospective", "deemed to have commenced", 85),
523 ];
524 for (sig, needle, conf) in pairs {
525 if t.contains(needle) {
526 hits.push(SignalHit {
527 family: "temporal".into(),
528 signal: (*sig).into(),
529 confidence: *conf,
530 });
531 }
532 }
533 if Regex::new(r"(?i)within\s+\d+\s+days")
535 .unwrap()
536 .is_match(text)
537 {
538 hits.push(SignalHit {
539 family: "temporal".into(),
540 signal: "within-n-days".into(),
541 confidence: 86,
542 });
543 }
544 hits.sort_by(|a, b| a.signal.cmp(&b.signal));
545 hits.dedup_by(|a, b| a.signal == b.signal);
546 hits
547}
548
549pub fn extract_cross_refs(text: &str) -> Vec<String> {
551 let mut out = Vec::new();
552 let patterns = [
553 r"(?i)\bsection\s+(\d+[A-Za-z]{0,2}(?:\(\d+[A-Za-z]?\))?)",
554 r"(?i)\bsubsection\s+\((\d+[A-Za-z]?)\)",
555 r"(?i)\barticle\s+(\d+[A-Za-z]?)",
556 r"(?i)\bschedule\s+(\d+[A-Za-z]?)",
557 r"(?i)\bpart\s+([0-9IVXLC]+[A-Za-z]?)",
558 r"(?i)\bdivision\s+(\d+[A-Za-z]?)",
559 r"(?i)\bregulation\s+(\d+[A-Za-z]?)",
560 ];
561 for pat in patterns {
562 let re = Regex::new(pat).unwrap();
563 for cap in re.captures_iter(text) {
564 if let Some(m) = cap.get(0) {
565 let s = m.as_str().trim().to_string();
566 if !out.contains(&s) {
567 out.push(s);
568 }
569 }
570 }
571 }
572 out.truncate(32);
573 out
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 #[test]
581 fn deontic_prohibition_and_obligation() {
582 assert_eq!(
583 classify_deontic("A person must not disclose personal data.").0,
584 DeonticClass::Prohibition
585 );
586 assert_eq!(
587 classify_deontic("The controller shall ensure appropriate security.").0,
588 DeonticClass::Obligation
589 );
590 assert_eq!(
591 classify_deontic("The individual has a right to access their data.").0,
592 DeonticClass::Right
593 );
594 assert_eq!(
595 classify_deontic("The Commissioner may issue guidelines.").0,
596 DeonticClass::Permission
597 );
598 }
599
600 #[test]
601 fn privacy_gdpr_family_hits() {
602 let text = "The data controller must obtain consent before processing personal data \
603 and honour the right to erasure. A DPIA is required for profiling.";
604 let hits = extract_privacy_signals(text);
605 let sigs: Vec<_> = hits.iter().map(|h| h.signal.as_str()).collect();
606 assert!(sigs.contains(&"controller"));
607 assert!(sigs.contains(&"consent"));
608 assert!(sigs.contains(&"personal-data"));
609 assert!(sigs.contains(&"erasure-right-to-be-forgotten"));
610 assert!(sigs.contains(&"dpia") || sigs.contains(&"automated-decision-making"));
611 }
612
613 #[test]
614 fn cross_refs_captured() {
615 let refs = extract_cross_refs("Subject to section 12(1) and Schedule 2, see Article 6.");
616 assert!(refs
617 .iter()
618 .any(|r| r.to_ascii_lowercase().contains("section")));
619 assert!(refs
620 .iter()
621 .any(|r| r.to_ascii_lowercase().contains("schedule")));
622 }
623}