Skip to main content

qualia_client_core/wellfair/
legislation_ingest.rs

1//! Native legislation structure parse → hypermedia Library ingest.
2//!
3//! Ports the structural (no-LLM) path of `tools/legislation-etl/legis2cml.py`:
4//! Part / Division / Schedule / Section / Subsection decomposition, then seeds
5//! each provision as a findable Library entry under **Work** with purpose
6//! `legislation`. Full section body text is always stored (the Python ETL used
7//! to drop bodies from N3/JSON-LD — this path does not).
8//!
9//! PDF → text uses `pdf_extract` (already a client-core dependency).
10
11use 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
23/// Media type for native legislation provision entries.
24pub 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    /// part | division | schedule | section | subsection
31    pub kind: String,
32    pub number: String,
33    pub heading: String,
34    pub text: String,
35    /// Pre-subsection-split body (containers keep full text).
36    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    /// Proposed CML concepts across all provisions.
73    pub cml_concepts: usize,
74    pub cml_deontic_norms: usize,
75    pub cml_privacy_hits: usize,
76    pub cml_rights_hits: usize,
77    /// COF HTML+RDFa segments (index + body packs for token budgets).
78    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
144/// Clean PDF page text into lines (mirrors legis2cml.clean lightly).
145fn 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
154/// Parse legislation pages into structured provisions (AU-oriented; EU Article/Chapter supported).
155pub 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                // next non-empty line becomes heading if short — append as body for simplicity
274                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) = &current_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) = &current_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) = &current_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) = &current_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                // EU article: first line after "Article N" is often the short title.
416                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    // Subsection split
430    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
524/// Extract text pages from a PDF file.
525pub 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    // pdf_extract gives whole-doc text; split on form feeds if present, else one page.
532    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        // Heuristic: split every ~3500 chars for page-ish markers when no form feed.
540        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
580/// Seed a parsed instrument into the hypermedia library (instrument + every provision).
581pub 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    // Build instrument-wide CML context from all provisions (structure + deontic/privacy/rights).
613    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    // Agent-lean COF package: index + section-aligned body segments under a char budget.
627    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    // Instrument root.
636    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    // Persist each COF body segment as its own library row (token-selective load).
705    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            // Prefix with signal chips for UI glance.
826            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
899/// Parse PDF bytes and seed the library.
900pub 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
923/// Parse plain text (already extracted) as a single-page instrument and seed.
924pub 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        // No phantom Part from contents.
971        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); // instrument + 2 sections
995        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        // CML context graph attached (deontic/privacy cues from body text when present).
1000        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}