1use std::collections::BTreeMap;
12use std::path::Path;
13
14use regex::Regex;
15use serde::{Deserialize, Serialize};
16
17use super::cml_context::{
18 build_cof_package, build_document_context, CofStyle, ContextUnit, DEFAULT_SEGMENT_MAX_CHARS,
19 MEDIA_TYPE_COF,
20};
21use super::hypermedia_store::{CommonsVisibility, HypermediaStore, LibraryEntry, LibrarySection};
22
23pub const LEGISLATION_MEDIA_TYPE: &str = "text/x-legislation-provision";
25pub const LEGISLATION_INSTRUMENT_MEDIA: &str = "text/x-legislation-instrument";
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Provision {
29 pub frag: String,
30 pub kind: String,
32 pub number: String,
33 pub heading: String,
34 pub text: String,
35 pub full_text: String,
37 pub start_page: u32,
38 pub parent: Option<String>,
39}
40
41impl Provision {
42 pub fn source_text(&self) -> &str {
43 if !self.full_text.trim().is_empty() {
44 &self.full_text
45 } else {
46 &self.text
47 }
48 }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct LegislationInstrument {
53 pub title: String,
54 pub slug: String,
55 pub jurisdiction: String,
56 pub register_id: Option<String>,
57 pub provisions: Vec<Provision>,
58 pub pages: usize,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct LegislationIngestReport {
63 pub title: String,
64 pub slug: String,
65 pub sections: usize,
66 pub subsections: usize,
67 pub structural: usize,
68 pub concepts_with_text: usize,
69 pub empty_text: usize,
70 pub library_entries_written: usize,
71 pub coverage_ok: bool,
72 pub cml_concepts: usize,
74 pub cml_deontic_norms: usize,
75 pub cml_privacy_hits: usize,
76 pub cml_rights_hits: usize,
77 pub cof_segments: usize,
79 pub cof_approx_tokens: usize,
80 pub cof_profile: String,
81}
82
83fn slugify(s: &str) -> String {
84 let lower = s.to_ascii_lowercase();
85 let mut out = String::with_capacity(lower.len());
86 let mut prev_dash = false;
87 for c in lower.chars() {
88 if c.is_ascii_alphanumeric() {
89 out.push(c);
90 prev_dash = false;
91 } else if !prev_dash {
92 out.push('-');
93 prev_dash = true;
94 }
95 }
96 out.trim_matches('-').to_string()
97}
98
99fn looks_like_section_heading(title: &str) -> bool {
100 let t = title.trim();
101 if t.len() < 2 {
102 return false;
103 }
104 let first = t.chars().next().unwrap_or(' ');
105 if first.is_lowercase() {
106 return false;
107 }
108 let lower = t.to_ascii_lowercase();
109 for bad in [
110 "of ",
111 "or ",
112 "and ",
113 "to ",
114 "in ",
115 "for ",
116 "as ",
117 "under ",
118 "made by",
119 "has no effect",
120 ] {
121 if lower.starts_with(bad) {
122 return false;
123 }
124 }
125 if Regex::new(r"^\d{4}$").unwrap().is_match(t) {
126 return false;
127 }
128 let words: Vec<_> = t.split_whitespace().collect();
129 if words.len() > 12 {
130 return false;
131 }
132 if t.ends_with('.') && words.len() > 4 {
133 return false;
134 }
135 if Regex::new(r"^(If|Subject|This|When|Where|Unless|Despite|For the purposes)\b")
136 .unwrap()
137 .is_match(t)
138 {
139 return false;
140 }
141 true
142}
143
144fn clean_page(raw: &str) -> String {
146 raw.replace('\r', "\n")
147 .replace('\u{00a0}', " ")
148 .lines()
149 .map(|l| l.trim_end())
150 .collect::<Vec<_>>()
151 .join("\n")
152}
153
154pub fn parse_pages(pages: &[(u32, String)], title_hint: Option<&str>) -> LegislationInstrument {
156 let re_part = Regex::new(r"^(Part|PART)\s+([0-9IVXLC]+[A-Za-z]?)\b[\s—\-:.]*(.*)$").unwrap();
157 let re_div = Regex::new(r"^(Division|DIVISION)\s+([0-9]+[A-Za-z]?)\b[\s—\-:.]*(.*)$").unwrap();
158 let re_schedule =
159 Regex::new(r"^(Schedule|SCHEDULE)\s+([0-9]+[A-Za-z]?)\s*[—–]\s*(.+)$").unwrap();
160 let re_section = Regex::new(r"^(\d+[A-Z]{0,2})\s+([A-Z0-9][^\n]{1,160})$").unwrap();
161 let re_hist = Regex::new(r"^(\d+[A-Z]{0,2})\.\s*[—–\-]?\s*([A-Z].{0,100})$").unwrap();
162 let re_eu_chapter = Regex::new(r"(?i)^CHAPTER\s+([IVXLC]+)$").unwrap();
163 let re_eu_article = Regex::new(r"(?i)^Article\s+(\d+[A-Z]?)$").unwrap();
164 let re_enacting = Regex::new(r"(?i)Parliament of Australia enacts|BE IT ENACTED").unwrap();
165
166 let mut frag_counts: BTreeMap<String, u32> = BTreeMap::new();
167 let mut unique_frag = |base: String| -> String {
168 let n = frag_counts.entry(base.clone()).or_insert(0);
169 *n += 1;
170 if *n == 1 {
171 base
172 } else {
173 format!("{base}-{n}")
174 }
175 };
176
177 let cleaned_all: Vec<String> = pages
178 .iter()
179 .flat_map(|(_, raw)| {
180 clean_page(raw)
181 .lines()
182 .map(|s| s.trim().to_string())
183 .collect::<Vec<_>>()
184 })
185 .collect();
186 let has_enacting = cleaned_all.iter().any(|ln| re_enacting.is_match(ln));
187 let has_eu_articles = cleaned_all.iter().any(|ln| re_eu_article.is_match(ln));
188 let mut in_body = !(has_enacting || has_eu_articles);
189
190 let mut title = title_hint.unwrap_or("").to_string();
191 if title.is_empty() {
192 title = infer_title(pages).unwrap_or_else(|| "Untitled Legislative Instrument".into());
193 }
194
195 let mut provisions: Vec<Provision> = Vec::new();
196 let mut cur: Option<Provision> = None;
197 let mut buf: Vec<String> = Vec::new();
198 let mut current_schedule: Option<String> = None;
199
200 let flush =
201 |cur: &mut Option<Provision>, buf: &mut Vec<String>, provisions: &mut Vec<Provision>| {
202 if let Some(mut p) = cur.take() {
203 p.text = buf.join("\n").trim().to_string();
204 p.full_text = p.text.clone();
205 provisions.push(p);
206 }
207 buf.clear();
208 };
209
210 let is_heading_title = |head: &str| {
211 let h = head.trim();
212 h.is_empty() || !h.chars().next().map(|c| c.is_lowercase()).unwrap_or(false)
213 };
214
215 for (page_no, raw) in pages {
216 for ln in clean_page(raw).lines() {
217 let s = ln.trim();
218 if title.is_empty()
219 && !Regex::new(r"^[\d(]").unwrap().is_match(s)
220 && Regex::new(r"(?i)\bAct\s+(No\.\s*\d+\s+of\s+)?\d{4}\b")
221 .unwrap()
222 .is_match(s)
223 {
224 title = s.to_string();
225 }
226 if !in_body {
227 if re_enacting.is_match(s) {
228 in_body = true;
229 continue;
230 }
231 if re_eu_chapter.is_match(s) || re_eu_article.is_match(s) {
232 in_body = true;
233 } else {
234 continue;
235 }
236 }
237 if s.is_empty() {
238 if cur.is_some() {
239 buf.push(String::new());
240 }
241 continue;
242 }
243
244 if let Some(m) = re_eu_chapter.captures(s) {
245 flush(&mut cur, &mut buf, &mut provisions);
246 current_schedule = None;
247 let number = m.get(1).unwrap().as_str().to_ascii_uppercase();
248 provisions.push(Provision {
249 frag: unique_frag(format!("chapter-{}", slugify(&number))),
250 kind: "part".into(),
251 number: number.clone(),
252 heading: format!("Chapter {number}"),
253 text: String::new(),
254 full_text: String::new(),
255 start_page: *page_no,
256 parent: None,
257 });
258 continue;
259 }
260 if let Some(m) = re_eu_article.captures(s) {
261 flush(&mut cur, &mut buf, &mut provisions);
262 let number = m.get(1).unwrap().as_str().to_ascii_uppercase();
263 cur = Some(Provision {
264 frag: unique_frag(format!("article-{}", slugify(&number))),
265 kind: "section".into(),
266 number,
267 heading: String::new(),
268 text: String::new(),
269 full_text: String::new(),
270 start_page: *page_no,
271 parent: None,
272 });
273 continue;
275 }
276 if let Some(m) = re_schedule.captures(s) {
277 let sched = slugify(m.get(2).unwrap().as_str());
278 if current_schedule.as_deref() != Some(&sched) {
279 flush(&mut cur, &mut buf, &mut provisions);
280 current_schedule = Some(sched.clone());
281 provisions.push(Provision {
282 frag: unique_frag(format!("sch-{sched}")),
283 kind: "schedule".into(),
284 number: m.get(2).unwrap().as_str().into(),
285 heading: m.get(3).unwrap().as_str().trim().into(),
286 text: String::new(),
287 full_text: String::new(),
288 start_page: *page_no,
289 parent: None,
290 });
291 }
292 continue;
293 }
294 if let Some(m) = re_part.captures(s) {
295 if is_heading_title(m.get(3).map(|x| x.as_str()).unwrap_or("")) {
296 flush(&mut cur, &mut buf, &mut provisions);
297 let num = m.get(2).unwrap().as_str();
298 let head = m.get(3).unwrap().as_str().trim();
299 let base = if let Some(sch) = ¤t_schedule {
300 format!("sch-{sch}-part-{}", slugify(num))
301 } else {
302 format!("part-{}", slugify(num))
303 };
304 provisions.push(Provision {
305 frag: unique_frag(base),
306 kind: "part".into(),
307 number: num.into(),
308 heading: if head.is_empty() {
309 format!("Part {num}")
310 } else {
311 head.into()
312 },
313 text: String::new(),
314 full_text: String::new(),
315 start_page: *page_no,
316 parent: None,
317 });
318 continue;
319 }
320 }
321 if let Some(m) = re_div.captures(s) {
322 if is_heading_title(m.get(3).map(|x| x.as_str()).unwrap_or("")) {
323 flush(&mut cur, &mut buf, &mut provisions);
324 let num = m.get(2).unwrap().as_str();
325 let head = m.get(3).unwrap().as_str().trim();
326 let base = if let Some(sch) = ¤t_schedule {
327 format!("sch-{sch}-div-{}", slugify(num))
328 } else {
329 format!("div-{}", slugify(num))
330 };
331 provisions.push(Provision {
332 frag: unique_frag(base),
333 kind: "division".into(),
334 number: num.into(),
335 heading: if head.is_empty() {
336 format!("Division {num}")
337 } else {
338 head.into()
339 },
340 text: String::new(),
341 full_text: String::new(),
342 start_page: *page_no,
343 parent: None,
344 });
345 continue;
346 }
347 }
348 if let Some(m) = re_section.captures(s) {
349 let head = m.get(2).unwrap().as_str();
350 if !s.starts_with('(') && looks_like_section_heading(head) {
351 flush(&mut cur, &mut buf, &mut provisions);
352 let num = m.get(1).unwrap().as_str();
353 let base = if let Some(sch) = ¤t_schedule {
354 format!("sch-{sch}-sec-{}", slugify(num))
355 } else {
356 format!("sec-{}", slugify(num))
357 };
358 cur = Some(Provision {
359 frag: unique_frag(base),
360 kind: "section".into(),
361 number: num.into(),
362 heading: head.trim().into(),
363 text: String::new(),
364 full_text: String::new(),
365 start_page: *page_no,
366 parent: None,
367 });
368 continue;
369 }
370 }
371 if !has_eu_articles {
372 if let Some(m) = re_hist.captures(s) {
373 let head = m.get(2).unwrap().as_str();
374 if !s.starts_with('(')
375 && looks_like_section_heading(head)
376 && re_section.captures(s).is_none()
377 {
378 flush(&mut cur, &mut buf, &mut provisions);
379 let num = m.get(1).unwrap().as_str();
380 let base = if let Some(sch) = ¤t_schedule {
381 format!("sch-{sch}-sec-{}", slugify(num))
382 } else {
383 format!("sec-{}", slugify(num))
384 };
385 let heading = head.trim();
386 if heading.len() > 90 || heading.starts_with(|c: char| c.is_lowercase()) {
387 cur = Some(Provision {
388 frag: unique_frag(base),
389 kind: "section".into(),
390 number: num.into(),
391 heading: format!("Section {num}"),
392 text: String::new(),
393 full_text: String::new(),
394 start_page: *page_no,
395 parent: None,
396 });
397 buf.push(heading.into());
398 } else {
399 cur = Some(Provision {
400 frag: unique_frag(base),
401 kind: "section".into(),
402 number: num.into(),
403 heading: heading.into(),
404 text: String::new(),
405 full_text: String::new(),
406 start_page: *page_no,
407 parent: None,
408 });
409 }
410 continue;
411 }
412 }
413 }
414 if let Some(c) = cur.as_mut() {
415 if c.heading.is_empty()
417 && s.len() < 120
418 && !s.chars().next().unwrap_or(' ').is_ascii_digit()
419 {
420 c.heading = s.to_string();
421 } else {
422 buf.push(s.to_string());
423 }
424 }
425 }
426 }
427 flush(&mut cur, &mut buf, &mut provisions);
428
429 provisions = decompose_provisions(provisions);
431
432 let slug = {
433 let mut s = slugify(
434 &Regex::new(r"(?i)\s*No\.\s*\d+.*$")
435 .unwrap()
436 .replace(&title, ""),
437 );
438 let parts: Vec<_> = s.split('-').take(12).collect();
439 s = parts.join("-");
440 if s.len() < 3 || s.len() > 90 {
441 s = slugify(title_hint.unwrap_or("instrument"));
442 }
443 s
444 };
445
446 LegislationInstrument {
447 title,
448 slug,
449 jurisdiction: "AU".into(),
450 register_id: None,
451 provisions,
452 pages: pages.len(),
453 }
454}
455
456fn decompose_provisions(mut provisions: Vec<Provision>) -> Vec<Provision> {
457 let re_sub = Regex::new(r"^\((\d+[A-Za-z]?)\)\s+(.+)$").unwrap();
458 let mut out = Vec::new();
459 for mut section in provisions.drain(..) {
460 if section.kind == "section" {
461 if section.full_text.is_empty() {
462 section.full_text = section.text.clone();
463 }
464 let lines: Vec<&str> = section.text.lines().collect();
465 let starts: Vec<usize> = lines
466 .iter()
467 .enumerate()
468 .filter_map(|(i, ln)| re_sub.is_match(ln).then_some(i))
469 .collect();
470 if starts.len() >= 2 {
471 let mut subs = Vec::new();
472 for (k, &start) in starts.iter().enumerate() {
473 let end = starts.get(k + 1).copied().unwrap_or(lines.len());
474 let block = lines[start..end].join("\n").trim().to_string();
475 let num = re_sub
476 .captures(lines[start])
477 .and_then(|c| c.get(1).map(|m| m.as_str().to_string()))
478 .unwrap_or_else(|| format!("{k}"));
479 let frag = format!("{}-ss-{}", section.frag, slugify(&num));
480 subs.push(Provision {
481 frag,
482 kind: "subsection".into(),
483 number: format!("{}({num})", section.number),
484 heading: section.heading.clone(),
485 text: block.clone(),
486 full_text: block,
487 start_page: section.start_page,
488 parent: Some(section.frag.clone()),
489 });
490 }
491 let lead = if starts[0] > 0 {
492 lines[..starts[0]].join("\n").trim().to_string()
493 } else {
494 String::new()
495 };
496 section.text = lead;
497 out.push(section);
498 out.extend(subs);
499 continue;
500 }
501 }
502 out.push(section);
503 }
504 out
505}
506
507fn infer_title(pages: &[(u32, String)]) -> Option<String> {
508 let joined = pages
509 .iter()
510 .take(5)
511 .map(|(_, r)| r.as_str())
512 .collect::<Vec<_>>()
513 .join(" ");
514 let joined = Regex::new(r"\s+").unwrap().replace_all(&joined, " ");
515 if let Some(c) = Regex::new(r"(?i)may be cited as the\s+(.{2,120}?\bAct\b[^.]{0,40}?\d{4})\b")
516 .unwrap()
517 .captures(&joined)
518 {
519 return Some(c.get(1).unwrap().as_str().trim().to_string());
520 }
521 None
522}
523
524pub fn extract_pdf_pages(path: &Path) -> Result<Vec<(u32, String)>, String> {
526 let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
527 extract_pdf_pages_from_bytes(&bytes)
528}
529
530pub fn extract_pdf_pages_from_bytes(bytes: &[u8]) -> Result<Vec<(u32, String)>, String> {
531 let text = pdf_extract::extract_text_from_mem(bytes).map_err(|e| e.to_string())?;
533 let mut pages = Vec::new();
534 if text.contains('\u{c}') {
535 for (i, part) in text.split('\u{c}').enumerate() {
536 pages.push((i as u32 + 1, part.to_string()));
537 }
538 } else {
539 let chunk = 3500usize;
541 if text.len() <= chunk {
542 pages.push((1, text));
543 } else {
544 let mut i = 0usize;
545 let mut page = 1u32;
546 while i < text.len() {
547 let end = (i + chunk).min(text.len());
548 let mut cut = end;
549 if end < text.len() {
550 if let Some(rel) = text[i..end].rfind('\n') {
551 cut = i + rel + 1;
552 }
553 }
554 if cut <= i {
555 cut = end;
556 }
557 pages.push((page, text[i..cut].to_string()));
558 i = cut;
559 page += 1;
560 }
561 }
562 }
563 if pages.is_empty() {
564 pages.push((1, String::new()));
565 }
566 Ok(pages)
567}
568
569fn fnv60(bytes: &[u8]) -> u64 {
570 const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
571 const FNV_PRIME: u64 = 0x100_0000_01b3;
572 let mut h = FNV_OFFSET;
573 for b in bytes {
574 h ^= u64::from(*b);
575 h = h.wrapping_mul(FNV_PRIME);
576 }
577 h & 0x0FFF_FFFF_FFFF_FFFF
578}
579
580pub fn seed_instrument_into_library(
582 store: &HypermediaStore,
583 inst: &LegislationInstrument,
584 now: u64,
585) -> std::io::Result<LegislationIngestReport> {
586 let mut entries = store.load()?;
587 let mut by_uri: std::collections::HashMap<String, usize> = entries
588 .iter()
589 .enumerate()
590 .map(|(i, e)| (e.asset_uri.clone(), i))
591 .collect();
592
593 let base = format!(
594 "legislation://{}/{}",
595 inst.jurisdiction.to_ascii_lowercase(),
596 inst.register_id.as_deref().unwrap_or(inst.slug.as_str())
597 );
598
599 let mut written = 0usize;
600 let mut upsert = |entry: LibraryEntry| {
601 if let Some(&idx) = by_uri.get(&entry.asset_uri) {
602 let mut e = entry;
603 e.ingested_unix = entries[idx].ingested_unix;
604 entries[idx] = e;
605 } else {
606 by_uri.insert(entry.asset_uri.clone(), entries.len());
607 entries.push(entry);
608 }
609 written += 1;
610 };
611
612 let cml_units: Vec<ContextUnit> = inst
614 .provisions
615 .iter()
616 .map(|p| ContextUnit {
617 frag: p.frag.clone(),
618 kind: p.kind.clone(),
619 label: format!("{} {}", p.number, p.heading).trim().to_string(),
620 text: p.source_text().to_string(),
621 page: Some(p.start_page),
622 parent: p.parent.clone(),
623 })
624 .collect();
625 let instrument_graph = build_document_context(&base, &inst.title, &cml_units);
626 let cof_pkg = build_cof_package(
628 &base,
629 &inst.title,
630 &cml_units,
631 DEFAULT_SEGMENT_MAX_CHARS,
632 CofStyle::AgentLean,
633 );
634
635 let root_uri = base.clone();
637 let root_excerpt = format!(
638 "{} — {} provision(s), {} page(s). CML (cml:Proposed): {} concepts, {} deontic, {} privacy. COF: {} segment(s), ~{} tokens total (load index + one body for agent turns).",
639 inst.title,
640 inst.provisions.len(),
641 inst.pages,
642 instrument_graph.concepts.len(),
643 instrument_graph.deontic_norms,
644 instrument_graph.privacy_hits,
645 cof_pkg.segments.len(),
646 cof_pkg.total_approx_tokens,
647 );
648 let mut root_topics = vec![
649 "legislation".into(),
650 "statute".into(),
651 "cml".into(),
652 inst.jurisdiction.to_ascii_lowercase(),
653 inst.slug.clone(),
654 ];
655 root_topics.extend(instrument_graph.topics.iter().cloned());
656 root_topics.sort();
657 root_topics.dedup();
658 let mut root_purposes = vec!["legislation".into(), "legal".into(), "work".into()];
659 root_purposes.extend(instrument_graph.purposes.iter().cloned());
660 root_purposes.sort();
661 root_purposes.dedup();
662 let root_n3 = if instrument_graph.n3.len() > 64_000 {
663 format!(
664 "{}…\n# [instrument cml_n3 truncated — per-provision entries hold local graphs]",
665 &instrument_graph.n3[..64_000]
666 )
667 } else {
668 instrument_graph.n3.clone()
669 };
670 let mut root = LibraryEntry {
671 asset_uri: root_uri.clone(),
672 primary_subject: fnv60(root_uri.as_bytes()),
673 media_type: LEGISLATION_INSTRUMENT_MEDIA.into(),
674 quins: instrument_graph.quins.clone(),
675 topics: root_topics,
676 projects: vec![format!("legislation:{}", inst.slug)],
677 purposes: root_purposes,
678 place: None,
679 occurred_at: None,
680 lat: None,
681 lon: None,
682 flags: Vec::new(),
683 ingested_unix: now,
684 excerpt: root_excerpt.chars().take(400).collect(),
685 sensitivity: "public".into(),
686 section: LibrarySection::Work.as_str().into(),
687 commons_visibility: CommonsVisibility::None,
688 cml_signals: instrument_graph.signal_tags.clone(),
689 cml_concept_count: instrument_graph.concepts.len() as u32,
690 cml_n3: root_n3,
691 cof_html: cof_pkg
692 .segments
693 .iter()
694 .find(|s| s.is_index)
695 .map(|s| s.html.clone())
696 .unwrap_or_default(),
697 cof_segment_count: cof_pkg.segments.len() as u32,
698 cof_segment_index: 0,
699 cof_profile: cof_pkg.profile.clone(),
700 };
701 root.recompute_section();
702 upsert(root);
703
704 for seg in cof_pkg.segments.iter().filter(|s| !s.is_index) {
706 let seg_uri = format!("{base}#cof-seg-{}", seg.index);
707 let mut se = LibraryEntry {
708 asset_uri: seg_uri.clone(),
709 primary_subject: fnv60(seg_uri.as_bytes()),
710 media_type: MEDIA_TYPE_COF.into(),
711 quins: Vec::new(),
712 topics: vec![
713 "legislation".into(),
714 "cof".into(),
715 "cml".into(),
716 inst.slug.clone(),
717 format!("cof-seg-{}", seg.index),
718 ],
719 projects: vec![format!("legislation:{}", inst.slug)],
720 purposes: vec!["legislation".into(), "legal".into(), "work".into()],
721 place: None,
722 occurred_at: None,
723 lat: None,
724 lon: None,
725 flags: Vec::new(),
726 ingested_unix: now,
727 excerpt: format!(
728 "COF body segment {}/{} · ~{} tokens · frags: {}",
729 seg.index + 1,
730 seg.total,
731 seg.approx_tokens,
732 seg.unit_frags
733 .iter()
734 .take(12)
735 .cloned()
736 .collect::<Vec<_>>()
737 .join(", ")
738 ),
739 sensitivity: "public".into(),
740 section: LibrarySection::Work.as_str().into(),
741 commons_visibility: CommonsVisibility::None,
742 cml_signals: Vec::new(),
743 cml_concept_count: seg.unit_frags.len() as u32,
744 cml_n3: String::new(),
745 cof_html: seg.html.clone(),
746 cof_segment_count: cof_pkg.segments.len() as u32,
747 cof_segment_index: seg.index,
748 cof_profile: cof_pkg.profile.clone(),
749 };
750 se.recompute_section();
751 upsert(se);
752 }
753
754 let mut sections = 0usize;
755 let mut subsections = 0usize;
756 let mut structural = 0usize;
757 let mut with_text = 0usize;
758 let mut empty = 0usize;
759 let mut total_cml_concepts = instrument_graph.concepts.len();
760 let mut total_deontic = instrument_graph.deontic_norms;
761 let mut total_privacy = instrument_graph.privacy_hits;
762 let mut total_rights = instrument_graph.rights_hits;
763
764 for p in &inst.provisions {
765 match p.kind.as_str() {
766 "section" => sections += 1,
767 "subsection" => subsections += 1,
768 "part" | "division" | "schedule" => structural += 1,
769 _ => {}
770 }
771 let body = p.source_text();
772 if body.trim().is_empty() {
773 empty += 1;
774 } else {
775 with_text += 1;
776 }
777 if matches!(
778 p.kind.as_str(),
779 "section" | "subsection" | "part" | "division" | "schedule"
780 ) {
781 let uri = format!("{base}#{}", p.frag);
782 let label = format!("{} {}", p.number, p.heading).trim().to_string();
783 let unit = ContextUnit {
784 frag: p.frag.clone(),
785 kind: p.kind.clone(),
786 label: label.clone(),
787 text: body.to_string(),
788 page: Some(p.start_page),
789 parent: p.parent.clone(),
790 };
791 let g = build_document_context(&uri, &label, &[unit]);
792 total_cml_concepts += g.concepts.len();
793 total_deontic += g.deontic_norms;
794 total_privacy += g.privacy_hits;
795 total_rights += g.rights_hits;
796
797 let mut topics = vec![
798 "legislation".into(),
799 "cml".into(),
800 p.kind.clone(),
801 inst.slug.clone(),
802 format!("s{}", p.number),
803 ];
804 if let Some(parent) = &p.parent {
805 topics.push(parent.clone());
806 }
807 topics.extend(g.topics.iter().cloned());
808 topics.sort();
809 topics.dedup();
810
811 let mut purposes = vec!["legislation".into(), "legal".into(), "work".into()];
812 purposes.extend(g.purposes.iter().cloned());
813 purposes.sort();
814 purposes.dedup();
815
816 let mut excerpt = if body.trim().is_empty() {
817 format!("{label} (no body text extracted)")
818 } else {
819 format!("{label}\n\n{body}")
820 };
821 if excerpt.len() > 12_000 {
822 excerpt = excerpt.chars().take(12_000).collect();
823 excerpt.push_str("\n…[truncated]");
824 }
825 if !g.signal_tags.is_empty() {
827 let chips: String = g
828 .signal_tags
829 .iter()
830 .take(8)
831 .cloned()
832 .collect::<Vec<_>>()
833 .join(" · ");
834 excerpt = format!("[{chips}]\n{excerpt}");
835 }
836
837 let cml_n3 = if g.n3.len() > 24_000 {
838 format!("{}…\n# [truncated]", &g.n3[..24_000])
839 } else {
840 g.n3
841 };
842
843 let mut entry = LibraryEntry {
844 asset_uri: uri.clone(),
845 primary_subject: fnv60(uri.as_bytes()),
846 media_type: LEGISLATION_MEDIA_TYPE.into(),
847 quins: g.quins,
848 topics,
849 projects: vec![format!("legislation:{}", inst.slug)],
850 purposes,
851 place: None,
852 occurred_at: None,
853 lat: None,
854 lon: None,
855 flags: Vec::new(),
856 ingested_unix: now,
857 excerpt,
858 sensitivity: "public".into(),
859 section: LibrarySection::Work.as_str().into(),
860 commons_visibility: CommonsVisibility::None,
861 cml_signals: g.signal_tags,
862 cml_concept_count: g.concepts.len() as u32,
863 cml_n3,
864 cof_html: String::new(),
865 cof_segment_count: cof_pkg.segments.len() as u32,
866 cof_segment_index: 0,
867 cof_profile: cof_pkg.profile.clone(),
868 };
869 entry.recompute_section();
870 upsert(entry);
871 }
872 }
873
874 store.replace_all(&entries)?;
875
876 let concepts = sections + subsections;
877 let coverage_ok = empty == 0 || (with_text as f64 / concepts.max(1) as f64) >= 0.85;
878
879 Ok(LegislationIngestReport {
880 title: inst.title.clone(),
881 slug: inst.slug.clone(),
882 sections,
883 subsections,
884 structural,
885 concepts_with_text: with_text,
886 empty_text: empty,
887 library_entries_written: written,
888 coverage_ok,
889 cml_concepts: total_cml_concepts,
890 cml_deontic_norms: total_deontic,
891 cml_privacy_hits: total_privacy,
892 cml_rights_hits: total_rights,
893 cof_segments: cof_pkg.segments.len(),
894 cof_approx_tokens: cof_pkg.total_approx_tokens,
895 cof_profile: cof_pkg.profile,
896 })
897}
898
899pub fn ingest_legislation_pdf_bytes(
901 store: &HypermediaStore,
902 bytes: &[u8],
903 register_id: Option<&str>,
904 jurisdiction: &str,
905 title_hint: Option<&str>,
906) -> Result<LegislationIngestReport, String> {
907 let pages = extract_pdf_pages_from_bytes(bytes)?;
908 let mut inst = parse_pages(&pages, title_hint);
909 inst.jurisdiction = jurisdiction.to_string();
910 if let Some(id) = register_id {
911 inst.register_id = Some(id.to_string());
912 if inst.slug.len() < 3 {
913 inst.slug = slugify(id);
914 }
915 }
916 let now = std::time::SystemTime::now()
917 .duration_since(std::time::UNIX_EPOCH)
918 .map(|d| d.as_secs())
919 .unwrap_or(0);
920 seed_instrument_into_library(store, &inst, now).map_err(|e| e.to_string())
921}
922
923pub fn ingest_legislation_text(
925 store: &HypermediaStore,
926 text: &str,
927 register_id: Option<&str>,
928 jurisdiction: &str,
929 title_hint: Option<&str>,
930) -> Result<LegislationIngestReport, String> {
931 let pages = vec![(1u32, text.to_string())];
932 let mut inst = parse_pages(&pages, title_hint);
933 inst.jurisdiction = jurisdiction.to_string();
934 if let Some(id) = register_id {
935 inst.register_id = Some(id.to_string());
936 }
937 let now = std::time::SystemTime::now()
938 .duration_since(std::time::UNIX_EPOCH)
939 .map(|d| d.as_secs())
940 .unwrap_or(0);
941 seed_instrument_into_library(store, &inst, now).map_err(|e| e.to_string())
942}
943
944#[cfg(test)]
945mod tests {
946 use super::*;
947
948 #[test]
949 fn enacts_skips_contents_and_keeps_body_text() {
950 let pages = vec![(
951 1u32,
952 "Example Amendment Act 2004\nNo. 5, 2004\n\
953 Contents\n1 Short title\n2 Commencement\nPart 2—Widgets\n3 Widget duty\n\
954 The Parliament of Australia enacts:\n\
955 1 Short title\nThis Act may be cited as the Example Amendment Act 2004.\n\
956 2 Commencement\nThis Act commences on Royal Assent.\n\
957 3 Widget duty\nA person must not widget.\n"
958 .into(),
959 )];
960 let inst = parse_pages(&pages, Some("Example Amendment Act 2004"));
961 let sections: Vec<_> = inst
962 .provisions
963 .iter()
964 .filter(|p| p.kind == "section")
965 .collect();
966 assert_eq!(sections.len(), 3);
967 assert!(sections[0].source_text().contains("may be cited"));
968 assert!(sections[1].source_text().contains("commences"));
969 assert!(sections[2].source_text().contains("must not widget"));
970 assert!(inst.provisions.iter().all(|p| p.kind != "part"));
972 }
973
974 #[test]
975 fn seed_writes_library_entries_with_text() {
976 let dir = tempfile::tempdir().unwrap();
977 let store = HypermediaStore::open(dir.path()).unwrap();
978 let text = "\
979The Parliament of Australia enacts:\n\
9801 Short title\nThis Act may be cited as the Demo Act 2020.\n\
9812 Commencement\nThis Act commences on the day after Royal Assent.\n";
982 let report = ingest_legislation_text(
983 &store,
984 text,
985 Some("C2020A00001"),
986 "AU",
987 Some("Demo Act 2020"),
988 )
989 .unwrap();
990 assert!(report.sections >= 2);
991 assert!(report.concepts_with_text >= 2);
992 assert!(report.coverage_ok);
993 let work = store.by_section(LibrarySection::Work).unwrap();
994 assert!(work.len() >= 3); assert!(work.iter().any(|e| e.excerpt.contains("may be cited")));
996 assert!(work
997 .iter()
998 .any(|e| e.purposes.iter().any(|p| p == "legislation")));
999 assert!(report.cml_concepts > 0);
1001 assert!(work
1002 .iter()
1003 .any(|e| e.cml_concept_count > 0 || !e.cml_n3.is_empty()));
1004 }
1005
1006 #[test]
1007 fn subsections_preserve_full_text_on_parent() {
1008 let pages = vec![(
1009 1u32,
1010 "The Parliament of Australia enacts:\n\
1011 5 Offence\n\
1012 A person commits an offence if:\n\
1013 (1) the person does X; and\n\
1014 (2) the person does Y.\n"
1015 .into(),
1016 )];
1017 let inst = parse_pages(&pages, Some("Offence Act"));
1018 let sec = inst
1019 .provisions
1020 .iter()
1021 .find(|p| p.kind == "section")
1022 .unwrap();
1023 let subs: Vec<_> = inst
1024 .provisions
1025 .iter()
1026 .filter(|p| p.kind == "subsection")
1027 .collect();
1028 assert_eq!(subs.len(), 2);
1029 assert!(sec.full_text.contains("(1)"));
1030 assert!(sec.full_text.contains("(2)"));
1031 assert!(subs[0].source_text().contains("does X"));
1032 }
1033}