The EDPB's consultation drew 132 written submissions — universities, hospitals, industry associations, national data protection authorities, and individuals — responding in 6 languages (English, Italian, Danish, French, Portuguese, Spanish) to a single question: how should the GDPR apply to scientific research? The goal of this pipeline was to surface the arguments that recur across that corpus without flattening the disagreements inside them.
Ingestion and metadata
Each submission arrives as a PDF whose filename already carries the EDPB portal's own submission ID (10744-comments-on-edpb-draft-guidelines...pdf) — reused directly rather than inventing a new numbering scheme. Text is pulled with PyMuPDF, which preserves reading order well enough that no OCR step was needed; every submission in this corpus was a text PDF, not a scan.
A separate pass sends each document's first two pages to Claude with a structured-output schema to extract who submitted it: organisation name and type, country, named authors, and a one-sentence topic summary. This runs once per document (132 calls, not 132 × page count) and is cached to disk so re-running the pipeline never re-spends on unchanged input.
The resulting metadata.xlsx is what lets every downstream count be sliced by organisation type or country — "72 submissions raised legal fragmentation across member states" is only useful once you can also ask "which 72."
From pages to paragraphs
The first design decision was the unit of analysis. A sentence is too fine: consultation responses write in long, compound legal sentences, and splitting them fragments a single argument across several fragments that lose their shared subject. A whole submission is too coarse: a six-page letter routinely raises a dozen unrelated points. The unit chosen was the paragraph — specifically, PyMuPDF's own text-block segmentation, which groups lines by layout rather than by blank-line heuristics that break on inconsistent PDF export formatting.
def extract_text_blocks(path: Path) -> list[TextBlock]:
"""Text blocks per page in reading order, as given by PyMuPDF's
block layout analysis — a much better paragraph-boundary signal
than blank-line splitting."""
with fitz.open(path) as doc:
for page in doc:
for b in page.get_text("blocks"):
if b[6] != 0: # skip image blocks
continue
yield TextBlock(text=b[4].strip(), bbox=tuple(b[:4]))
scripts/pdf_utils.py — abbreviated
A short set of regex heuristics drops obvious boilerplate before it ever reaches an LLM: salutations, page-number footers ("1 (6)"-style markers), signature blocks. This first pass is deliberately conservative — it removes only what pattern-matches with high confidence, leaving the harder cases (letterheads that don't match a fixed pattern, author-title blocks) to be caught later, semantically, in the coding pass itself.
Two-pass concept coding
The fix was to change what gets clustered. Rather than embedding whole paragraphs, an LLM performs a first pass of open coding — naming the individual issues each paragraph raises — and only those short, atomic descriptions are embedded and clustered. This borrows directly from qualitative content analysis: open coding is the standard first move in grounded-theory-style analysis of interview or survey text, where a researcher reads material line by line and assigns short descriptive labels before any categorisation happens. Here an LLM performs that first read at a scale — 7,116 paragraphs — no team of human coders could cover in the time available, with the tradeoff made explicit in the limitations below.
Pass 1 — open coding
Paragraphs are sent to Claude Sonnet 5 in small batches with a structured-output schema. Two choices in that schema do most of the work:
- Multiple codes per paragraph. A paragraph gets 0–5 short codes, not one. The paragraph that reads "we support secondary use for research, but access should occur in secure environments and the 30-day deadline is unrealistic" yields three separate codes, each tied back to the same source text.
- Stance is a separate field from the code. The issue ("eligibility of commercial researchers") is coded independently of the position taken on it (support / oppose / request_clarification / propose_change / concern / other). This is precisely the distinction raw embedding similarity cannot make.
A third field, is_substantive, replaces the earlier regex boilerplate filter with a semantic one — false for letterheads, salutations, and courtesy framing, so a document's opening pleasantries never masquerade as an argument about the Guidelines.
class Code(BaseModel):
code: str # short noun phrase, English, issue not position
stance: Literal[STANCES] # support | oppose | request_clarification | ...
claim: str # one sentence, the submitter's actual position
target: Optional[str] # e.g. "Article 89(1)", or null
class ParagraphCoding(BaseModel):
index: int
is_substantive: bool # false for letterheads, salutations, etc.
codes: list[Code] # 0–5 atomic issues, not forced to exactly one
scripts/extract_codes.py
Across the corpus, 66.8% of paragraphs were judged substantive and produced 6,330 codes from 5,095 distinct raw phrasings. Long paragraphs (over 1,000 characters) average 2.6 codes each — a submission's argument is routinely more than one issue, which is exactly why the coding step assigns 0–5 codes rather than forcing every paragraph into a single label.
Pass 2 — codebook consolidation
Open coding is deliberately generous: the same underlying issue gets phrased differently every time ("fees for data access," "charging for access," "cost recovery for data access"). A second pass merges these. The 5,095 raw phrasings are embedded with a multilingual model, connected in a k-nearest-neighbour graph, and partitioned with the Leiden community-detection algorithm at a resolution tuned to merge only near-duplicates — then an LLM writes one canonical label per merged group.
def group_cache_key(phrases: list[str]) -> str:
"""Content-derived cache key. Leiden's group ids are reassigned
from scratch on every run — caching a name under the bare integer
id let a name from one run silently leak onto an unrelated group
with the same id in the next run. Hashing the group's own member
phrases ties the cached name to content, not position."""
digest = hashlib.sha256("\n".join(sorted(phrases)).encode()).hexdigest()
return digest[:16]
scripts/build_codebook.py — the fix for a real caching bug hit while building this, kept here as documentation
The result is 565 canonical codes, each an average of 8 merged phrasings (up to 27 for the largest), used as the atomic unit for everything downstream.
Building the theme hierarchy
The 565 canonical codes are still too many to browse flat. The same Leiden clustering used for consolidation is run again, this time on the canonical codes' own embeddings, at two resolutions in one pass: a low resolution groups codes into broad themes, a higher resolution groups the same codes into finer subthemes, and each subtheme is nested under whichever theme holds the majority of its members. The canonical codes themselves form the third and final level.
level1 = leiden_partition(embeddings, k=15, resolution=2) # 17 themes
level2 = leiden_partition(embeddings, k=15, resolution=6) # 97 subthemes
sub_to_theme = nest_by_containment(level2, level1) # majority containment
scripts/build_themes.py
Both resolutions were, again, swept and content-checked rather than assumed: the values first tried (carried over from a small synthetic test) collapsed 565 codes into just two themes, one of which — "Scientific Research Definition and Compliance" — silently absorbed 130 submissions' worth of unrelated argument. The final values (2 and 6) were chosen only after inspecting the actual member codes of candidate groupings at several resolutions and confirming each read as one coherent subject.
An LLM names every theme and subtheme from its member codes, using the same content-hashed caching discipline as the codebook pass. The result: 17 themes, 97 subthemes, 565 codes — small enough to read in one sitting, granular enough that "broad consent" and "consent as legal basis" stay distinct subjects rather than collapsing into one undifferentiated "consent" bucket.
Across the whole corpus, the stance distribution skews toward propose_change (2,200 codes) and concern (1,539) over flat support (545) or oppose (447) — consistent with what a public consultation should produce: stakeholders mostly suggest edits or flag risks rather than simply approving or rejecting a draft outright.
Rendering the explorer
The theme → subtheme → code tree, with every leaf carrying its stance breakdown, contributing organisations, countries, cited provisions, and representative quotes linked back to source paragraphs, is serialised once to a single JSON tree and embedded directly in a static HTML page. The page itself does no computation at load time — no server, no client-side clustering or layout — which is what keeps it fast to open regardless of corpus size. Clicking a node in the left-hand tree simply looks up and renders its pre-computed slice of the same JSON.
⚠Limitations and required human quality control
This is a machine-generated annotation of the corpus, produced to make it easier to navigate through the submissions. It is not a substitute for reading the original submissions, this is why it is important to click on the original PDF links and read the source text and ... judge by yourself.
Limitations of clustering
Every Leiden resolution value in this pipeline was chosen by trying candidate values against this specific corpus's embeddings and reading the resulting groups, so it did not use a formula and most likely it is not generalizable to different corpuses. The goal is to make it easier for the reader to explore the submissions, rather than doing actual synthesis.
A handful of canonical codes share an identical label
The LLM naming pass occasionally converges on the same short label for two genuinely distinct Leiden groups (three separate facets of "broad consent," for instance, each legitimately named "broad consent" on independent review). This is important to remember when browsing the codes.
Stances and stance counts are not a vote tally
The stances and stance counts are not quantitative in the sense that they can provide evidence for actual positions taken. The goal is exploration, the reader should judge by themselves if a certain paragraph was proposing a change or opposing the original guidelines.
Translation and coding quality on non-English text is not independently verified
7 of 132 submissions are written mainly in a language other than English (Italian, Danish, French, Portuguese, Spanish); every code is written in English regardless of source language, by instruction to the coding model, to keep the taxonomy unified. That step's accuracy on non-English submissions has not been checked against a human bilingual reviewer. (An earlier version of this page put the language count at 21: that number came from counting every distinct langdetect code seen across individual paragraphs, most of which were single-paragraph misdetections — short citations, signature blocks, names — rather than submissions actually written in that language. Counting each submission's dominant language by character count gives 6.)
Not fully deterministic
LLM outputs are not guaranteed identical across reruns even at the same prompt and temperature. Cached intermediates (raw codes, canonical names, theme names) make this specific run reproducible as published, but regenerating from scratch may shift individual code phrasings or group boundaries at the margins.