Under the hood
Case reports
press releases, dockets, PDFs
Public case reports from 76 agencies and task forces
ICAC task force and police releases, state attorneys general, DOJ and NCMEC reports, and federal court records from PACER. Everything was public before CaseLinker read it.
- 76 sources. Each listing is fetched, every URL probed, and only public documents are downloaded
- Each case keeps its source, URL and fetch time, so any value can be traced back to a document
Layer 1
Ingestion
collected documents become text, one case per release
Turns collected documents into cases
# PDF and HTML → plain text → normalise → split
text = extract_text(document) # pdfplumber for PDFs
cases = case_batching(text, org="azicac") # one case per release
# ids: {source}_{year}_{seq} · nj_ag_2017_001, wy_dci_2023_004
- PDFs and web releases become one plain-text format, so every later step reads the same thing
- Boilerplate and links are stripped, then each source's date and heading pattern splits the text into one case per release
- A content fingerprint stops the same release entering the corpus twice
- Person names are masked in everything public; only their character offsets are kept
Layer 2
Extraction
four readers, one decision, a span for every value
Reads each case into structured fields, with a span for every value
# readers run side by side on the same text
regex · spaCy NER · Stanza NER · LLM residue pass
# each emits (field, value, start, end, confidence)
resolution → one value per field, disagreements go to review
- Ages, dates, sentences, platforms, agencies, locations, investigation type and charges are deterministically extracted
- Agencies resolve to a law-enforcement registry and places to the Census gazetteer, so the same name means the same thing across cases
- Four readers vote on every field; where they disagree, the case goes to review instead of a guess
- Audit shows every value beside the passage it came from
Layer 3
Storage
case table, provenance, graphs on disk, SPARQL store
Keeps the cases, the evidence and the graphs
PostgreSQL (Railway) · SQLite locally # cases, assertions, resolutions
ontology/graph_output/*.ttl | *.jsonld # one CAC graph + one provenance graph per case
Oxigraph behind /sparql # 10,802 case graphs, 10,802 provenance graphs, PACER
- Every extractor's answer is stored, not only the winning value, so a resolution can be re-run or audited
- Redis caches the heavy API responses, so the facet tree and cluster pages answer in milliseconds
- Every graph passes SHACL validation before it enters the store, so a bad mapping never reaches a query
Layer 4
Knowledge graphs and analysis
CASE / UCO / CAC, w3id IRIs, state machines
Maps each case to CASE/UCO/CAC, with its provenance beside it
# deterministic mapping, no custom predicates
features → CAC entities → Turtle + JSON-LD → SHACL → Oxigraph
https://w3id.org/caselinker/resource/case/{id} # the case graph
https://w3id.org/caselinker/resource/case/{id}/provenance # oa:Annotation per value
- Every case, step, agency, platform and location has a persistent w3id IRI, so a result can be cited and dereferenced; Explore walks them
- 30 PACER cases are modelled as CAC phase state machines, so a court record can be read as phases and the affordance that enabled each step
- Facet cohorts, clustering and triage run over the case table and come back through the API
CAC OntologyUCOCASEWeb AnnotationPROV
Layer 5
Interface
web app, REST API, SPARQL endpoint, MCP server
Serves the same corpus to people, scripts and agents
/analysis /patterns /explore /lifecycle /visualization /search /audit /sources
/api/* # REST, OpenAPI at /docs
/sparql # GET | POST, read-only
/mcp /mcp-http # 55 tools for agents: corpus, facets, graphs, SPARQL, triage
- Production runs on Railway with PostgreSQL; a local clone runs on SQLite against the live SPARQL store
- Explore in the browser, query the public APIs from a script, or use an agent with the MCP server