tbrugger commited on
Commit
a52b2f7
·
verified ·
1 Parent(s): b9b52f6

Deploy grading app

Browse files
.env.example ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Local grading data root (skips Hugging Face for templates + user answers).
2
+ # Layout matches the HF dataset layout:
3
+ # {GRADING_DATA_DIR}/{jurisdiction}/grading_template.parquet
4
+ # {GRADING_DATA_DIR}/{jurisdiction}/users/users.json
5
+ # {GRADING_DATA_DIR}/{jurisdiction}/users/{username}_answers.parquet
6
+ GRADING_DATA_DIR=./data
7
+
8
+ # Optional: single-template shortcut when you only have one parquet file.
9
+ # Takes precedence over the jurisdiction file under GRADING_DATA_DIR for template load.
10
+ # Jurisdiction list still comes from GRADING_DATA_DIR subfolders, or from GRADING_JURISDICTION.
11
+ # GRADING_TEMPLATE_PATH=./data/en-us/grading_template.parquet
12
+ # GRADING_JURISDICTION=en-us
13
+
14
+ # Hugging Face mode (used when GRADING_DATA_DIR / GRADING_TEMPLATE_PATH are unset)
15
+ # HF_DATASET_REPO=TransLegal/grading-answers
16
+ # HF_TOKEN=hf_...
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ data/_downloads/
README.md CHANGED
@@ -80,13 +80,14 @@ To add a new jurisdiction to the repository:
80
  - `concept` (string) - The concept class (drives which questions apply)
81
  - `section` (string) - The section a question belongs to (one page per section)
82
  - `section_index` (integer) - Display order of sections within a result
83
- - `question_id` (string) - Unique question id, e.g. `A2-Q13a`
84
  - `group` (string) - Groups split questions that share a source field, e.g. `A2-Q13`
85
  - `part` (string) - Split marker (`a`, `b`, …) or empty
86
  - `question` (string) - The PASS/FAIL check text
87
  - `question_index` (integer) - Order of questions within a section for a result
88
- - `answer` (string) - Full model output for the Analytical Plane block this check covers
89
- - `source_fields` (string) - Comma-joined structured-analysis fields in that block
 
90
  - **Display Order:** swipe deck is **concept → section (`section_index`) → term → questions (`question_index`)**. Within a section, all checks for one term are asked before the next term.
91
 
92
  3. **Create users directory:**
 
80
  - `concept` (string) - The concept class (drives which questions apply)
81
  - `section` (string) - The section a question belongs to (one page per section)
82
  - `section_index` (integer) - Display order of sections within a result
83
+ - `question_id` (string) - Unique question id, e.g. `A2-Q01`
84
  - `group` (string) - Groups split questions that share a source field, e.g. `A2-Q13`
85
  - `part` (string) - Split marker (`a`, `b`, …) or empty
86
  - `question` (string) - The PASS/FAIL check text
87
  - `question_index` (integer) - Order of questions within a section for a result
88
+ - `answer` (string) - Generated text for the AP prompt(s) this check covers, and nothing else from their block
89
+ - `source_fields` (string) - Comma-joined structured-analysis fields for those prompts
90
+ - `source_prompts` (string) - Semicolon-joined AP prompt ids (e.g. `A1.3-P01`) supplying the evidence
91
  - **Display Order:** swipe deck is **concept → section (`section_index`) → term → questions (`question_index`)**. Within a section, all checks for one term are asked before the next term.
92
 
93
  3. **Create users directory:**
WORKFLOW.md ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Grading app — end-to-end workflow
2
+
3
+ How to stand up a grading round from scratch: author the questions, run the
4
+ pipeline, pull the results, build the template, and launch the swipe app.
5
+ Follow this when the **real questions** and **real results** are ready.
6
+
7
+ All commands run from the **TransLegal repo root** unless noted, using `uv`.
8
+
9
+ ---
10
+
11
+ ## 0. Mental model
12
+
13
+ ```
14
+ question_specs.iter_question_specs structured_analysis run (HF)
15
+ (PASS/FAIL checks per concept class) (term × field_of_law × answers)
16
+ \ /
17
+ \ /
18
+ build_grading_template.py ──► data/{jurisdiction}/grading_template.parquet
19
+ (one row per result × question, answer attached)
20
+ │
21
+ ▼
22
+ Streamlit swipe app (Pass/Fail)
23
+ │
24
+ ▼
25
+ data/{jurisdiction}/users/{username}_answers.parquet
26
+ (the expensive human verdicts)
27
+ ```
28
+
29
+ Key idea: **questions are concept-specific.** Every result only ever shows the
30
+ questions for *its* concept class. This is enforced by the builder once
31
+ `iter_question_specs` is wired.
32
+
33
+ ---
34
+
35
+ ## 1. Author / update the questions
36
+
37
+ **Analysis prompts** (Stage A) live in `concepts/analytical_plane/blocks/*.py`
38
+ and `bundles.py`. Those are independent of grading.
39
+
40
+ **PASS/FAIL grading questions** live in `pipeline/llm_judge/questions.tsv` and
41
+ are exposed via `pipeline.llm_judge.question_specs.iter_question_specs`.
42
+
43
+ ---
44
+
45
+ ## 2. Produce the results (pipeline run)
46
+
47
+ Grading needs a `structured_analysis` parquet: one row per
48
+ `(term, field_of_law)` with `concept` and `structured_answers_json`.
49
+
50
+ - These are produced by the Dagster `structured_analysis` asset and published to
51
+ the private HF dataset **`TransLegal/structured_analysis`**, under
52
+ `{experiment}/{variant}/{jurisdiction}/{timestamp}.parquet` on a **branch**
53
+ (e.g. `main`, `showcase`).
54
+ - For the real round, run the pipeline for your chosen term set (e.g. 160 terms,
55
+ 20 per concept class) and note the branch + path it wrote to.
56
+
57
+ You need a HF token that is a **member of the `TransLegal` org** (read access).
58
+ Set it in `.env` as `HUGGING_FACE_HUB_TOKEN_TRANSLEGAL`. Verify:
59
+
60
+ ```bash
61
+ uv run hf auth whoami # 'TransLegal' must appear under orgs
62
+ ```
63
+
64
+ ---
65
+
66
+ ## 3. Ingest results into the grading app
67
+
68
+ Preferred path: **`ingest_structured_analysis.py`** downloads the latest
69
+ `structured_analysis` parquet from HF and builds
70
+ `data/<jurisdiction>/grading_template.parquet` in one step.
71
+
72
+ ### Tiny development run
73
+
74
+ Config:
75
+ `orchestration/translegal_dagster/run_configs/definition_pipeline/new-llm-judge-tiny-example.yaml`
76
+ → publishes to branch **`dev`**, path
77
+ `new-llm-judge/tiny-example/en_us/<timestamp>.parquet`.
78
+
79
+ Once the run has published (or while it is still running):
80
+
81
+ ```bash
82
+ # One-shot after publish:
83
+ uv run python hf-spaces/grading-answers/ingest_structured_analysis.py
84
+
85
+ # Or poll until the parquet appears (checks every 30s, up to 1h):
86
+ uv run python hf-spaces/grading-answers/ingest_structured_analysis.py --poll
87
+
88
+ # Or build from a local parquet you already have:
89
+ uv run python hf-spaces/grading-answers/ingest_structured_analysis.py \
90
+ --input /path/to/structured_analysis.parquet
91
+ ```
92
+
93
+ Defaults: `--branch dev --experiment new-llm-judge --variant tiny-example
94
+ --jurisdiction en-us`, output
95
+ `hf-spaces/grading-answers/data/en-us/grading_template.parquet`.
96
+
97
+ Restart the Streamlit app after ingest so `@st.cache_data` reloads the template.
98
+
99
+ ### Manual download + build (any run)
100
+
101
+ List a branch to find the exact path (paths use `en_us`, not `en-us`):
102
+
103
+ ```bash
104
+ uv run python -c "from huggingface_hub import HfApi; \
105
+ print('\n'.join(HfApi().list_repo_files('TransLegal/structured_analysis', repo_type='dataset', revision='<branch>')))"
106
+
107
+ uv run hf download TransLegal/structured_analysis \
108
+ "<experiment>/<variant>/<jurisdiction>/<timestamp>.parquet" \
109
+ --repo-type dataset --revision <branch> --local-dir /tmp/run
110
+ ```
111
+
112
+ Then:
113
+
114
+ ```bash
115
+ uv run python hf-spaces/grading-answers/build_grading_template.py \
116
+ --input /tmp/run/<...>.parquet \
117
+ --output hf-spaces/grading-answers/data/<jurisdiction>/grading_template.parquet
118
+ ```
119
+
120
+ Selection flags (same on `ingest_structured_analysis.py` and
121
+ `build_grading_template.py`):
122
+
123
+ | Flag | Effect |
124
+ |------|--------|
125
+ | *(default)* | **One representative field of law per term** (first alphabetically). Use for the real "one field of law per term" round. |
126
+ | `--field-of-law "Contract Law"` | Pin a single field of law for every term. |
127
+ | `--all-fields-of-law` | Keep **every** `(term, field_of_law)` result. Use for the showcase set (same term across many fields). |
128
+ | `--max-terms N` | Cap number of terms (smoke tests). |
129
+ | `--include-failed` | Include rows whose `generation_status != ok` (off by default). |
130
+
131
+ Output columns: `term, field_of_law, concept, section, section_index,
132
+ question_id, group, part, question, question_index, answer, source_fields,
133
+ source_prompts`.
134
+
135
+ > Note the app data folder uses the hyphenated jurisdiction (`en-us`), even
136
+ > though the pipeline path uses `en_us`.
137
+
138
+ ---
139
+
140
+ ## 4. Run the app (local mode)
141
+
142
+ ```bash
143
+ cd hf-spaces/grading-answers
144
+ GRADING_DATA_DIR=./data \
145
+ uv run --with streamlit --with streamlit-shortcuts --with pandas --with pyarrow \
146
+ --with huggingface-hub --with pycountry --with babel --with python-dotenv \
147
+ streamlit run src/streamlit_app.py --server.port 8502
148
+ ```
149
+
150
+ Or put `GRADING_DATA_DIR=./data` in `hf-spaces/grading-answers/.env` and just run
151
+ `streamlit run src/streamlit_app.py`.
152
+
153
+ In the app: pick the jurisdiction → register a user → grade. Swipe deck controls:
154
+ **→** pass, **←** fail, **↓** skip, **↑** undo (or the buttons). Verdicts autosave
155
+ and resume where you left off.
156
+
157
+ Verdicts land in `data/<jurisdiction>/users/<username>_answers.parquet`
158
+ (template columns + `verdict` + `time_stamp`). **These are the expensive human
159
+ outputs — never overwrite this folder when you refresh a template.**
160
+
161
+ ---
162
+
163
+ ## 5. Deploy to Hugging Face (shared graders)
164
+
165
+ The app is a Docker Space (`TransLegal/grading-answers`). In HF mode it reads/writes
166
+ the private dataset `TransLegal/grading-answers`:
167
+
168
+ 1. Upload only the built `grading_template.parquet` (+ `grading_template.meta.yaml`)
169
+ to `<jurisdiction>/grading_template.parquet` — **leave `<jurisdiction>/users/`
170
+ untouched.**
171
+ 2. Ensure the Space has `HF_TOKEN` (write) and `HF_DATASET_REPO` set (see README).
172
+ 3. `git push` the Space repo to redeploy.
173
+
174
+ Graders then register/sign in per jurisdiction; their verdicts are written to
175
+ `<jurisdiction>/users/<username>_answers.parquet` in the dataset.
176
+
177
+ ---
178
+
179
+ ## 6. Refreshing a round (real questions / new results)
180
+
181
+ 1. Update questions in `pipeline/llm_judge/questions.tsv` (step 1).
182
+ 2. Run the pipeline for the real term set → note branch/path (step 2).
183
+ 3. Re-ingest with `ingest_structured_analysis.py` (or download + build) (step 3).
184
+ 4. Replace `grading_template.parquet` (+ meta) in `data/<jurisdiction>/` (local) or
185
+ in the HF dataset (step 5). **Do not touch `users/`.**
186
+ 5. Launch/redeploy (steps 4–5).
187
+
188
+ ---
189
+
190
+ ## File map
191
+
192
+ | Path | Role |
193
+ |------|------|
194
+ | `concepts/analytical_plane/blocks/*.py` | Stage A prompt blocks (docstrings) |
195
+ | `pipeline/llm_judge/questions.tsv` | PASS/FAIL question bank |
196
+ | `pipeline/llm_judge/question_specs.py` | `iter_question_specs()` |
197
+ | `hf-spaces/grading-answers/build_grading_template.py` | Results × questions → app template (+ meta sidecar) |
198
+ | `hf-spaces/grading-answers/ingest_structured_analysis.py` | HF download + template build (tiny-example defaults) |
199
+ | `hf-spaces/grading-answers/data/<j>/grading_template.parquet` | Local template the app loads |
200
+ | `hf-spaces/grading-answers/data/<j>/users/<user>_answers.parquet` | Human verdicts (precious) |
201
+ | `hf-spaces/grading-answers/src/streamlit_app.py` | The swipe grading app |
202
+ | `TransLegal/structured_analysis` (HF) | Source pipeline results |
203
+ | `TransLegal/grading-answers` (HF) | Deployed app storage (templates + verdicts) |
__pycache__/build_grading_template.cpython-312.pyc ADDED
Binary file (17.3 kB). View file
 
build_grading_template.py ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Build a PASS/FAIL question grading template from structured_analysis output.
3
+
4
+ Reads Dagster ``structured_analysis`` output (term, field_of_law,
5
+ concept_class / concept, structured_answers_json) and, for each graded
6
+ result, emits one row per PASS/FAIL question from
7
+ ``pipeline.llm_judge.question_specs``.
8
+
9
+ Question specs are loaded from ``pipeline/llm_judge/questions.tsv``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import logging
17
+ import sys
18
+ from datetime import UTC, datetime
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import pandas as pd
23
+ import yaml
24
+
25
+ # Allow running from repo root; concepts lives at TransLegal/concepts.
26
+ _REPO_ROOT = Path(__file__).resolve().parents[2]
27
+ if str(_REPO_ROOT) not in sys.path:
28
+ sys.path.insert(0, str(_REPO_ROOT))
29
+
30
+ from pipeline.llm_judge.answers import answer_for_spec # noqa: E402
31
+ from pipeline.llm_judge.question_specs import ( # noqa: E402
32
+ QUESTION_BANK_VERSION,
33
+ iter_question_specs,
34
+ )
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ SCHEMA_VERSION = 3
39
+
40
+ REQUIRED_INPUT_COLUMNS = (
41
+ "term",
42
+ "field_of_law",
43
+ "structured_answers_json",
44
+ )
45
+ # Pipeline output uses ``concept_class``; accept ``concept`` as an alias.
46
+ _CONCEPT_COLUMN_CANDIDATES = ("concept_class", "concept")
47
+
48
+ OUTPUT_COLUMNS = (
49
+ "term",
50
+ "field_of_law",
51
+ "concept",
52
+ "section",
53
+ "section_index",
54
+ "question_id",
55
+ "group",
56
+ "part",
57
+ "question",
58
+ "question_index",
59
+ "answer",
60
+ "source_fields",
61
+ "source_prompts",
62
+ )
63
+
64
+
65
+ def flatten_result_row(
66
+ *,
67
+ term: str,
68
+ field_of_law: str,
69
+ concept: str,
70
+ structured_answers: dict[str, Any],
71
+ ) -> list[dict[str, Any]]:
72
+ """Expand one (term, field_of_law) result into per-question grading rows."""
73
+ rows: list[dict[str, Any]] = []
74
+ for spec in iter_question_specs(concept):
75
+ rows.append(
76
+ {
77
+ "term": term,
78
+ "field_of_law": field_of_law,
79
+ "concept": concept,
80
+ "section": spec["section"],
81
+ "section_index": spec["section_index"],
82
+ "question_id": spec["id"],
83
+ "group": spec["group"],
84
+ "part": spec["part"],
85
+ "question": spec["question"],
86
+ "question_index": spec["question_index"],
87
+ "answer": answer_for_spec(spec, structured_answers),
88
+ "source_fields": ",".join(spec["source_fields"]),
89
+ "source_prompts": ";".join(spec["source_prompts"]),
90
+ }
91
+ )
92
+ return rows
93
+
94
+
95
+ def select_results(
96
+ df: pd.DataFrame,
97
+ *,
98
+ field_of_law: str | None,
99
+ one_per_term: bool,
100
+ ) -> pd.DataFrame:
101
+ """Reduce a multi-field_of_law run to the results to grade.
102
+
103
+ - ``field_of_law`` pins a single field of law (kept only where present).
104
+ - ``one_per_term`` keeps one representative result per term (the first
105
+ field of law alphabetically), which yields one result per term.
106
+ - otherwise every (term, field_of_law) result is kept.
107
+ """
108
+ if field_of_law is not None:
109
+ return df[df["field_of_law"].astype(str).str.strip() == field_of_law].copy()
110
+ if not one_per_term:
111
+ return df.copy()
112
+ ordered = df.sort_values(by=["term", "field_of_law"], kind="mergesort")
113
+ return ordered.drop_duplicates(subset=["term"], keep="first").copy()
114
+
115
+
116
+ def _resolve_concept_column(columns: pd.Index) -> str:
117
+ for name in _CONCEPT_COLUMN_CANDIDATES:
118
+ if name in columns:
119
+ return name
120
+ raise ValueError(
121
+ "structured_analysis is missing a concept column "
122
+ f"(expected one of {_CONCEPT_COLUMN_CANDIDATES}). "
123
+ f"Found: {list(columns)}"
124
+ )
125
+
126
+
127
+ def build_grading_template(
128
+ structured_analysis: pd.DataFrame,
129
+ *,
130
+ only_ok: bool = True,
131
+ field_of_law: str | None = None,
132
+ one_per_term: bool = True,
133
+ max_terms: int | None = None,
134
+ ) -> pd.DataFrame:
135
+ """Flatten a structured_analysis DataFrame into per-question grading rows."""
136
+ missing = [
137
+ c for c in REQUIRED_INPUT_COLUMNS if c not in structured_analysis.columns
138
+ ]
139
+ if missing:
140
+ raise ValueError(
141
+ f"structured_analysis is missing required columns: {missing}. "
142
+ f"Found: {list(structured_analysis.columns)}"
143
+ )
144
+ concept_col = _resolve_concept_column(structured_analysis.columns)
145
+
146
+ df = structured_analysis.copy()
147
+ if only_ok and "generation_status" in df.columns:
148
+ before = len(df)
149
+ df = df[df["generation_status"] == "ok"]
150
+ dropped = before - len(df)
151
+ if dropped:
152
+ logger.info("Dropped %d rows with generation_status != ok", dropped)
153
+
154
+ df = select_results(df, field_of_law=field_of_law, one_per_term=one_per_term)
155
+
156
+ if max_terms is not None:
157
+ keep_terms = (
158
+ df.sort_values(by=["term"], kind="mergesort")["term"]
159
+ .drop_duplicates()
160
+ .head(max_terms)
161
+ )
162
+ df = df[df["term"].isin(set(keep_terms))]
163
+
164
+ all_rows: list[dict[str, Any]] = []
165
+ skipped = 0
166
+ for _, row in df.iterrows():
167
+ term = str(row["term"] or "").strip()
168
+ field = str(row["field_of_law"] or "").strip()
169
+ concept = str(row[concept_col] or "").strip()
170
+ raw_json = str(row["structured_answers_json"] or "").strip()
171
+
172
+ if not term or not concept or not raw_json or raw_json == "{}":
173
+ skipped += 1
174
+ logger.warning(
175
+ "Skipping incomplete row term=%r field_of_law=%r concept=%r",
176
+ term,
177
+ field,
178
+ concept,
179
+ )
180
+ continue
181
+
182
+ try:
183
+ parsed = json.loads(raw_json)
184
+ except json.JSONDecodeError:
185
+ skipped += 1
186
+ logger.exception("Invalid JSON for term=%r field_of_law=%r", term, field)
187
+ continue
188
+
189
+ if not isinstance(parsed, dict):
190
+ skipped += 1
191
+ logger.warning(
192
+ "Expected object JSON for term=%r field_of_law=%r, got %s",
193
+ term,
194
+ field,
195
+ type(parsed).__name__,
196
+ )
197
+ continue
198
+
199
+ try:
200
+ all_rows.extend(
201
+ flatten_result_row(
202
+ term=term,
203
+ field_of_law=field,
204
+ concept=concept,
205
+ structured_answers=parsed,
206
+ )
207
+ )
208
+ except (ValueError, KeyError):
209
+ skipped += 1
210
+ logger.exception(
211
+ "Cannot flatten term=%r field_of_law=%r concept=%r",
212
+ term,
213
+ field,
214
+ concept,
215
+ )
216
+
217
+ if skipped:
218
+ logger.info("Skipped %d input rows", skipped)
219
+
220
+ out = pd.DataFrame(all_rows, columns=list(OUTPUT_COLUMNS))
221
+ if out.empty:
222
+ return out
223
+
224
+ return out.sort_values(
225
+ by=["concept", "section_index", "term", "field_of_law", "question_index"],
226
+ kind="mergesort",
227
+ ).reset_index(drop=True)
228
+
229
+
230
+ def _relative_to_repo(path: Path) -> str:
231
+ try:
232
+ return str(path.resolve().relative_to(_REPO_ROOT))
233
+ except ValueError:
234
+ return str(path.resolve())
235
+
236
+
237
+ def _load_dagster_sidecar(input_parquet: Path) -> dict[str, Any]:
238
+ """Load the Dagster tabular sidecar yaml next to a structured_analysis parquet."""
239
+ sidecar = input_parquet.with_suffix(".yaml")
240
+ if not sidecar.is_file():
241
+ return {}
242
+ try:
243
+ with sidecar.open(encoding="utf-8") as f:
244
+ data = yaml.safe_load(f)
245
+ return data if isinstance(data, dict) else {}
246
+ except Exception:
247
+ logger.exception("Could not read Dagster sidecar %s", sidecar)
248
+ return {}
249
+
250
+
251
+ def build_template_metadata(
252
+ *,
253
+ input_parquet: Path,
254
+ output_parquet: Path,
255
+ template: pd.DataFrame,
256
+ structured_input: pd.DataFrame,
257
+ only_ok: bool,
258
+ field_of_law: str | None,
259
+ one_per_term: bool,
260
+ max_terms: int | None,
261
+ ) -> dict[str, Any]:
262
+ """Build provenance metadata for a grading_template.parquet artifact."""
263
+ sidecar = _load_dagster_sidecar(input_parquet)
264
+ op_config = (
265
+ sidecar.get("op_config") if isinstance(sidecar.get("op_config"), dict) else {}
266
+ )
267
+
268
+ results = (
269
+ int(template[["term", "field_of_law"]].drop_duplicates().shape[0])
270
+ if not template.empty
271
+ else 0
272
+ )
273
+
274
+ return {
275
+ "schema_version": SCHEMA_VERSION,
276
+ "question_bank_version": QUESTION_BANK_VERSION,
277
+ "artifact_type": "grading_template",
278
+ "built_at": datetime.now(UTC).replace(microsecond=0).isoformat(),
279
+ "builder": {
280
+ "script": _relative_to_repo(Path(__file__)),
281
+ },
282
+ "output": {
283
+ "parquet": _relative_to_repo(output_parquet),
284
+ "meta": _relative_to_repo(
285
+ output_parquet.with_name("grading_template.meta.yaml")
286
+ ),
287
+ },
288
+ "source": {
289
+ "structured_analysis_parquet": _relative_to_repo(input_parquet),
290
+ "dagster_sidecar": _relative_to_repo(input_parquet.with_suffix(".yaml"))
291
+ if input_parquet.with_suffix(".yaml").is_file()
292
+ else None,
293
+ "run_id": sidecar.get("run_id"),
294
+ "run_ts": sidecar.get("run_ts"),
295
+ "git_sha": sidecar.get("git_sha"),
296
+ "jurisdiction": sidecar.get("jurisdiction"),
297
+ "dataset_branch": sidecar.get("dataset_branch"),
298
+ "experiment_name": sidecar.get("experiment_name"),
299
+ "experiment_variant": sidecar.get("experiment_variant"),
300
+ "run_comment": sidecar.get("run_comment"),
301
+ "llm_provider": op_config.get("llm_provider"),
302
+ "llm_model": op_config.get("llm_model"),
303
+ "publish_to_hf": sidecar.get("publish_to_hf"),
304
+ },
305
+ "build": {
306
+ "only_ok": only_ok,
307
+ "field_of_law": field_of_law,
308
+ "one_per_term": one_per_term,
309
+ "max_terms": max_terms,
310
+ "input_rows": int(len(structured_input)),
311
+ "output_rows": int(len(template)),
312
+ "result_count": results,
313
+ "term_count": int(template["term"].nunique()) if not template.empty else 0,
314
+ "field_of_law_count": (
315
+ int(template["field_of_law"].nunique()) if not template.empty else 0
316
+ ),
317
+ "question_count": (
318
+ int(template["question_id"].nunique()) if not template.empty else 0
319
+ ),
320
+ "concepts": sorted(template["concept"].unique().tolist())
321
+ if not template.empty
322
+ else [],
323
+ "columns": list(template.columns),
324
+ },
325
+ }
326
+
327
+
328
+ def write_template_metadata(metadata: dict[str, Any], output_parquet: Path) -> Path:
329
+ """Write grading_template.meta.yaml beside grading_template.parquet."""
330
+ meta_path = output_parquet.with_name("grading_template.meta.yaml")
331
+ meta_path.parent.mkdir(parents=True, exist_ok=True)
332
+ with meta_path.open("w", encoding="utf-8") as f:
333
+ yaml.safe_dump(metadata, f, sort_keys=False, allow_unicode=True)
334
+ return meta_path
335
+
336
+
337
+ def _parse_args() -> argparse.Namespace:
338
+ parser = argparse.ArgumentParser(
339
+ description=(
340
+ "Build a grading_template.parquet from structured_analysis output "
341
+ "(Analytical Plane bundles)."
342
+ )
343
+ )
344
+ parser.add_argument(
345
+ "--input",
346
+ type=Path,
347
+ required=True,
348
+ help="Path to structured_analysis parquet (Dagster tabular output).",
349
+ )
350
+ parser.add_argument(
351
+ "--output",
352
+ type=Path,
353
+ required=True,
354
+ help="Path to write grading_template.parquet.",
355
+ )
356
+ parser.add_argument(
357
+ "--max-terms",
358
+ type=int,
359
+ default=None,
360
+ help="Optional cap on the number of terms for smoke tests.",
361
+ )
362
+ parser.add_argument(
363
+ "--field-of-law",
364
+ type=str,
365
+ default=None,
366
+ help="Pin a single field of law to grade (exact match).",
367
+ )
368
+ parser.add_argument(
369
+ "--all-fields-of-law",
370
+ action="store_true",
371
+ help=(
372
+ "Grade every (term, field_of_law) result. Default keeps one "
373
+ "representative result per term."
374
+ ),
375
+ )
376
+ parser.add_argument(
377
+ "--include-failed",
378
+ action="store_true",
379
+ help="Include rows whose generation_status is not 'ok'.",
380
+ )
381
+ return parser.parse_args()
382
+
383
+
384
+ def main() -> None:
385
+ logging.basicConfig(
386
+ level=logging.INFO,
387
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
388
+ )
389
+ args = _parse_args()
390
+ if not args.input.exists():
391
+ raise FileNotFoundError(f"Input parquet not found: {args.input}")
392
+
393
+ logger.info("Loading %s", args.input)
394
+ structured = pd.read_parquet(args.input)
395
+ one_per_term = not args.all_fields_of_law and args.field_of_law is None
396
+ template = build_grading_template(
397
+ structured,
398
+ only_ok=not args.include_failed,
399
+ field_of_law=args.field_of_law,
400
+ one_per_term=one_per_term,
401
+ max_terms=args.max_terms,
402
+ )
403
+
404
+ args.output.parent.mkdir(parents=True, exist_ok=True)
405
+ template.to_parquet(args.output, index=False)
406
+
407
+ metadata = build_template_metadata(
408
+ input_parquet=args.input,
409
+ output_parquet=args.output,
410
+ template=template,
411
+ structured_input=structured,
412
+ only_ok=not args.include_failed,
413
+ field_of_law=args.field_of_law,
414
+ one_per_term=one_per_term,
415
+ max_terms=args.max_terms,
416
+ )
417
+ meta_path = write_template_metadata(metadata, args.output)
418
+
419
+ n_results = (
420
+ template[["term", "field_of_law"]].drop_duplicates().shape[0]
421
+ if not template.empty
422
+ else 0
423
+ )
424
+ logger.info(
425
+ "Wrote %d grading rows for %d results (term, field_of_law) → %s",
426
+ len(template),
427
+ n_results,
428
+ args.output,
429
+ )
430
+ logger.info("Wrote metadata sidecar → %s", meta_path)
431
+ if not template.empty:
432
+ logger.info("Concepts: %s", sorted(template["concept"].unique()))
433
+ logger.info(
434
+ "Columns: %s",
435
+ list(template.columns),
436
+ )
437
+
438
+
439
+ if __name__ == "__main__":
440
+ main()
ingest_structured_analysis.py ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Download a structured_analysis parquet and build the local grading template.
3
+
4
+ Default target is the tiny development run::
5
+
6
+ uv run python hf-spaces/grading-answers/ingest_structured_analysis.py
7
+
8
+ That pulls the latest ``new-llm-judge/tiny-example/en_us/*.parquet`` from
9
+ ``TransLegal/structured_analysis`` (branch ``dev``) and writes::
10
+
11
+ hf-spaces/grading-answers/data/en-us/grading_template.parquet
12
+
13
+ Pass ``--input`` to skip Hugging Face and build from a local parquet instead.
14
+ Use ``--poll`` while a Dagster run is still publishing.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import logging
21
+ import sys
22
+ import time
23
+ from pathlib import Path
24
+
25
+ import pandas as pd
26
+ from huggingface_hub import hf_hub_download
27
+
28
+ _REPO_ROOT = Path(__file__).resolve().parents[2]
29
+ if str(_REPO_ROOT) not in sys.path:
30
+ sys.path.insert(0, str(_REPO_ROOT))
31
+
32
+ # Sibling module (same directory).
33
+ from build_grading_template import ( # noqa: E402
34
+ build_grading_template,
35
+ build_template_metadata,
36
+ write_template_metadata,
37
+ )
38
+
39
+ from config import get_settings # noqa: E402
40
+ from config.constants import REPOS # noqa: E402
41
+ from utils.huggingface import get_hf_api_wrapper # noqa: E402
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ _SPACE_ROOT = Path(__file__).resolve().parent
46
+ _DEFAULT_OUTPUT = _SPACE_ROOT / "data" / "en-us" / "grading_template.parquet"
47
+
48
+ _STRUCTURED_ANALYSIS_REPO = REPOS["structured_answers"]
49
+
50
+
51
+ def _hyphen_to_underscore(jurisdiction: str) -> str:
52
+ return jurisdiction.replace("-", "_")
53
+
54
+
55
+ def _list_run_parquets(
56
+ *,
57
+ branch: str,
58
+ experiment: str,
59
+ variant: str,
60
+ jurisdiction: str,
61
+ ) -> list[str]:
62
+ """Return matching parquet paths under experiment/variant/jurisdiction/."""
63
+ prefix = f"{experiment}/{variant}/{_hyphen_to_underscore(jurisdiction)}/"
64
+ hf = get_hf_api_wrapper()
65
+ files = hf.api.list_repo_files(
66
+ repo_id=f"{hf.config.organization}/{_STRUCTURED_ANALYSIS_REPO}",
67
+ repo_type="dataset",
68
+ revision=branch,
69
+ )
70
+ return sorted(f for f in files if f.startswith(prefix) and f.endswith(".parquet"))
71
+
72
+
73
+ def resolve_hf_parquet(
74
+ *,
75
+ branch: str,
76
+ experiment: str,
77
+ variant: str,
78
+ jurisdiction: str,
79
+ path: str | None,
80
+ ) -> str:
81
+ """Return the repo-relative parquet path to download."""
82
+ if path:
83
+ return path
84
+ matches = _list_run_parquets(
85
+ branch=branch,
86
+ experiment=experiment,
87
+ variant=variant,
88
+ jurisdiction=jurisdiction,
89
+ )
90
+ if not matches:
91
+ raise FileNotFoundError(
92
+ f"No structured_analysis parquet yet under "
93
+ f"{experiment}/{variant}/{_hyphen_to_underscore(jurisdiction)}/ "
94
+ f"on branch {branch!r}. Wait for the pipeline publish, or pass --input."
95
+ )
96
+ chosen = matches[-1]
97
+ if len(matches) > 1:
98
+ logger.info("Found %d parquet(s); using latest: %s", len(matches), chosen)
99
+ return chosen
100
+
101
+
102
+ def download_hf_parquet(
103
+ *,
104
+ repo_path: str,
105
+ branch: str,
106
+ local_dir: Path,
107
+ ) -> Path:
108
+ settings = get_settings()
109
+ org = settings.huggingface.organization
110
+ token = settings.huggingface.token
111
+ local_dir.mkdir(parents=True, exist_ok=True)
112
+ downloaded = hf_hub_download(
113
+ repo_id=f"{org}/{_STRUCTURED_ANALYSIS_REPO}",
114
+ filename=repo_path,
115
+ repo_type="dataset",
116
+ revision=branch,
117
+ token=token,
118
+ local_dir=str(local_dir),
119
+ )
120
+ return Path(downloaded)
121
+
122
+
123
+ def poll_until_available(
124
+ *,
125
+ branch: str,
126
+ experiment: str,
127
+ variant: str,
128
+ jurisdiction: str,
129
+ path: str | None,
130
+ interval_s: float,
131
+ timeout_s: float,
132
+ ) -> str:
133
+ """Block until a matching parquet appears on HF (or timeout)."""
134
+ deadline = time.monotonic() + timeout_s
135
+ last_error: Exception | None = None
136
+ while time.monotonic() < deadline:
137
+ try:
138
+ return resolve_hf_parquet(
139
+ branch=branch,
140
+ experiment=experiment,
141
+ variant=variant,
142
+ jurisdiction=jurisdiction,
143
+ path=path,
144
+ )
145
+ except FileNotFoundError as exc:
146
+ last_error = exc
147
+ remaining = max(0, int(deadline - time.monotonic()))
148
+ logger.info(
149
+ "Waiting for HF publish (%ds left)… %s",
150
+ remaining,
151
+ exc,
152
+ )
153
+ time.sleep(interval_s)
154
+ raise TimeoutError(
155
+ f"Timed out after {timeout_s:.0f}s waiting for structured_analysis on "
156
+ f"branch {branch!r}."
157
+ ) from last_error
158
+
159
+
160
+ def build_and_write(
161
+ *,
162
+ input_parquet: Path,
163
+ output_parquet: Path,
164
+ all_fields_of_law: bool,
165
+ field_of_law: str | None,
166
+ max_terms: int | None,
167
+ include_failed: bool,
168
+ ) -> pd.DataFrame:
169
+ logger.info("Loading %s", input_parquet)
170
+ structured = pd.read_parquet(input_parquet)
171
+ one_per_term = not all_fields_of_law and field_of_law is None
172
+ template = build_grading_template(
173
+ structured,
174
+ only_ok=not include_failed,
175
+ field_of_law=field_of_law,
176
+ one_per_term=one_per_term,
177
+ max_terms=max_terms,
178
+ )
179
+ output_parquet.parent.mkdir(parents=True, exist_ok=True)
180
+ template.to_parquet(output_parquet, index=False)
181
+ metadata = build_template_metadata(
182
+ input_parquet=input_parquet,
183
+ output_parquet=output_parquet,
184
+ template=template,
185
+ structured_input=structured,
186
+ only_ok=not include_failed,
187
+ field_of_law=field_of_law,
188
+ one_per_term=one_per_term,
189
+ max_terms=max_terms,
190
+ )
191
+ meta_path = write_template_metadata(metadata, output_parquet)
192
+ n_results = (
193
+ template[["term", "field_of_law"]].drop_duplicates().shape[0]
194
+ if not template.empty
195
+ else 0
196
+ )
197
+ logger.info(
198
+ "Wrote %d grading rows for %d results → %s",
199
+ len(template),
200
+ n_results,
201
+ output_parquet,
202
+ )
203
+ logger.info("Wrote metadata → %s", meta_path)
204
+ if not template.empty:
205
+ logger.info("Concepts: %s", sorted(template["concept"].unique()))
206
+ logger.info(
207
+ "Questions per concept: %s",
208
+ template.groupby("concept")["question_id"].nunique().to_dict(),
209
+ )
210
+ return template
211
+
212
+
213
+ def _parse_args() -> argparse.Namespace:
214
+ parser = argparse.ArgumentParser(
215
+ description=(
216
+ "Ingest structured_analysis into the local grading app "
217
+ "(download from HF and/or build grading_template.parquet)."
218
+ )
219
+ )
220
+ parser.add_argument(
221
+ "--input",
222
+ type=Path,
223
+ default=None,
224
+ help="Local structured_analysis parquet (skips Hugging Face download).",
225
+ )
226
+ parser.add_argument(
227
+ "--output",
228
+ type=Path,
229
+ default=_DEFAULT_OUTPUT,
230
+ help=f"grading_template.parquet path (default: {_DEFAULT_OUTPUT}).",
231
+ )
232
+ parser.add_argument(
233
+ "--branch",
234
+ default="dev",
235
+ help="HF dataset branch (default: dev).",
236
+ )
237
+ parser.add_argument(
238
+ "--experiment",
239
+ default="new-llm-judge",
240
+ help="Experiment folder on HF (default: new-llm-judge).",
241
+ )
242
+ parser.add_argument(
243
+ "--variant",
244
+ default="tiny-example",
245
+ help="Variant folder on HF (default: tiny-example).",
246
+ )
247
+ parser.add_argument(
248
+ "--jurisdiction",
249
+ default="en-us",
250
+ help="Jurisdiction code with hyphen (default: en-us).",
251
+ )
252
+ parser.add_argument(
253
+ "--hf-path",
254
+ default=None,
255
+ help=(
256
+ "Exact repo-relative parquet path. Default: latest under "
257
+ "experiment/variant/jurisdiction/."
258
+ ),
259
+ )
260
+ parser.add_argument(
261
+ "--download-dir",
262
+ type=Path,
263
+ default=_SPACE_ROOT / "data" / "_downloads",
264
+ help="Where to cache the downloaded HF parquet.",
265
+ )
266
+ parser.add_argument(
267
+ "--poll",
268
+ action="store_true",
269
+ help="Keep checking HF until a matching parquet appears.",
270
+ )
271
+ parser.add_argument(
272
+ "--poll-interval",
273
+ type=float,
274
+ default=30.0,
275
+ help="Seconds between HF checks when --poll is set (default: 30).",
276
+ )
277
+ parser.add_argument(
278
+ "--poll-timeout",
279
+ type=float,
280
+ default=3600.0,
281
+ help="Max seconds to wait with --poll (default: 3600).",
282
+ )
283
+ parser.add_argument(
284
+ "--all-fields-of-law",
285
+ action="store_true",
286
+ help="Keep every (term, field_of_law) row (default: one field per term).",
287
+ )
288
+ parser.add_argument(
289
+ "--field-of-law",
290
+ default=None,
291
+ help="Pin a single field of law (exact match).",
292
+ )
293
+ parser.add_argument(
294
+ "--max-terms",
295
+ type=int,
296
+ default=None,
297
+ help="Optional cap on terms.",
298
+ )
299
+ parser.add_argument(
300
+ "--include-failed",
301
+ action="store_true",
302
+ help="Include generation_status != ok rows.",
303
+ )
304
+ return parser.parse_args()
305
+
306
+
307
+ def main() -> None:
308
+ logging.basicConfig(
309
+ level=logging.INFO,
310
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
311
+ )
312
+ args = _parse_args()
313
+
314
+ if args.input is not None:
315
+ input_parquet = args.input.expanduser().resolve()
316
+ if not input_parquet.is_file():
317
+ raise FileNotFoundError(f"Input parquet not found: {input_parquet}")
318
+ else:
319
+ if args.poll:
320
+ repo_path = poll_until_available(
321
+ branch=args.branch,
322
+ experiment=args.experiment,
323
+ variant=args.variant,
324
+ jurisdiction=args.jurisdiction,
325
+ path=args.hf_path,
326
+ interval_s=args.poll_interval,
327
+ timeout_s=args.poll_timeout,
328
+ )
329
+ else:
330
+ repo_path = resolve_hf_parquet(
331
+ branch=args.branch,
332
+ experiment=args.experiment,
333
+ variant=args.variant,
334
+ jurisdiction=args.jurisdiction,
335
+ path=args.hf_path,
336
+ )
337
+ logger.info(
338
+ "Downloading hf://datasets/.../%s@%s → %s",
339
+ repo_path,
340
+ args.branch,
341
+ args.download_dir,
342
+ )
343
+ input_parquet = download_hf_parquet(
344
+ repo_path=repo_path,
345
+ branch=args.branch,
346
+ local_dir=args.download_dir,
347
+ )
348
+
349
+ build_and_write(
350
+ input_parquet=input_parquet,
351
+ output_parquet=args.output.expanduser().resolve(),
352
+ all_fields_of_law=args.all_fields_of_law,
353
+ field_of_law=args.field_of_law,
354
+ max_terms=args.max_terms,
355
+ include_failed=args.include_failed,
356
+ )
357
+ logger.info(
358
+ "App-ready. From hf-spaces/grading-answers run: "
359
+ "uv run --with streamlit --with streamlit-shortcuts --with pandas "
360
+ "--with pyarrow --with huggingface-hub --with pycountry --with babel "
361
+ "--with python-dotenv streamlit run src/streamlit_app.py"
362
+ )
363
+
364
+
365
+ if __name__ == "__main__":
366
+ main()
src/streamlit_app.py CHANGED
@@ -8,9 +8,8 @@ import pandas as pd
8
  import pycountry
9
  import streamlit as st
10
  from babel import Locale
11
- from huggingface_hub import HfApi, hf_hub_download, login
12
-
13
  from glossary import GLOSSARY_TIP_CSS, format_question_html
 
14
 
15
  st.set_page_config(
16
  page_title="Answer grading",
@@ -248,6 +247,7 @@ TEMPLATE_COLUMNS = (
248
  "question_index",
249
  "answer",
250
  "source_fields",
 
251
  )
252
 
253
 
@@ -437,6 +437,7 @@ def get_cards(df):
437
  "concept",
438
  "section",
439
  "question_id",
 
440
  "question",
441
  "answer",
442
  ]
@@ -722,9 +723,9 @@ st.caption(
722
  f":material/gavel: **{card['term']}** · {card['field_of_law']} · {card['concept']}"
723
  )
724
  st.progress((card_index) / total_cards if total_cards else 0.0)
725
- meta = (
726
- f"Card {card_index + 1} of {total_cards} · {card['section']} · {card['question_id']}"
727
- )
728
  if existing:
729
  badge = ":green-badge[Pass]" if existing == VERDICT_PASS else ":red-badge[Fail]"
730
  meta += f" · current: {badge}"
 
8
  import pycountry
9
  import streamlit as st
10
  from babel import Locale
 
 
11
  from glossary import GLOSSARY_TIP_CSS, format_question_html
12
+ from huggingface_hub import HfApi, hf_hub_download, login
13
 
14
  st.set_page_config(
15
  page_title="Answer grading",
 
247
  "question_index",
248
  "answer",
249
  "source_fields",
250
+ "source_prompts",
251
  )
252
 
253
 
 
437
  "concept",
438
  "section",
439
  "question_id",
440
+ "source_prompts",
441
  "question",
442
  "answer",
443
  ]
 
723
  f":material/gavel: **{card['term']}** · {card['field_of_law']} · {card['concept']}"
724
  )
725
  st.progress((card_index) / total_cards if total_cards else 0.0)
726
+ meta = f"Card {card_index + 1} of {total_cards} · {card['section']} · {card['question_id']}"
727
+ if card.get("source_prompts"):
728
+ meta += f" · {card['source_prompts'].replace(';', ', ')}"
729
  if existing:
730
  badge = ":green-badge[Pass]" if existing == VERDICT_PASS else ":red-badge[Fail]"
731
  meta += f" · current: {badge}"