Spaces:
Running
Running
Skip old-schema grading templates so Stats cannot crash
Browse files- README.md +2 -2
- src/stats.py +20 -0
- src/streamlit_app.py +68 -53
- tests/test_stats.py +35 -0
README.md
CHANGED
|
@@ -174,7 +174,7 @@ See [`WORKFLOW.md`](WORKFLOW.md) for the full round-setup flow.
|
|
| 174 |
| What | Where | Safe? |
|
| 175 |
|------|--------|-------|
|
| 176 |
| **en-us human scores (old 55-Q schema)** | `definitions/gradings/{MK,ML,RS}/*.json` in git — 80 terms × 3 graders (~4.8 MB) | **Yes — in repo** |
|
| 177 |
-
| **sv-se / hr-hr human scores** |
|
| 178 |
| **Streamlit app answers (any jurisdiction)** | HF **bucket** `TransLegal/grading-answers` when deployed (`*_answers.csv` plus `user_progress.csv`); hourly copy on the HF **dataset**; local mode under `{GRADING_DATA_DIR}/{jurisdiction}/users/` | Local only if you graded locally |
|
| 179 |
| **AI template rows** | `grading_template.parquet` — regeneratable from `structured_analysis` | Not precious |
|
| 180 |
|
|
@@ -227,7 +227,7 @@ Hourly dataset versioning runs from **this GitHub repo**, not from the Space. Ad
|
|
| 227 |
1. **Jurisdiction Discovery:** Local mode scans `GRADING_DATA_DIR`; HF mode scans the dataset for `grading_template.parquet`
|
| 228 |
2. **User Accounts:** Each jurisdiction has separate user accounts (same username can exist in different jurisdictions)
|
| 229 |
3. **Data Storage:** Local mode writes parquet and `user_progress.csv` under `GRADING_DATA_DIR/{jurisdiction}/users/`; HF mode writes sparse CSV grades and the navigation table to the storage bucket and keeps templates / `users.json` on the dataset
|
| 230 |
-
4. **Stats:** After sign-in, **Stats** in the sidebar opens a read-only dashboard of Pass / fail / ungraded cards for you, everyone, and each other grader, across every jurisdiction
|
| 231 |
|
| 232 |
## Deployment
|
| 233 |
|
|
|
|
| 174 |
| What | Where | Safe? |
|
| 175 |
|------|--------|-------|
|
| 176 |
| **en-us human scores (old 55-Q schema)** | `definitions/gradings/{MK,ML,RS}/*.json` in git — 80 terms × 3 graders (~4.8 MB) | **Yes — in repo** |
|
| 177 |
+
| **sv-se / hr-hr human scores** | Historical files on HF dataset `TransLegal/grading-answers` (old category/subcategory schema). Removed from the live tree; recover from dataset commit `4ba3d14f048c1b76aefb93e7051a42a9eeff0a32`. Reports in `hf-spaces/gradings-analysis/` | **Git history on the dataset** |
|
| 178 |
| **Streamlit app answers (any jurisdiction)** | HF **bucket** `TransLegal/grading-answers` when deployed (`*_answers.csv` plus `user_progress.csv`); hourly copy on the HF **dataset**; local mode under `{GRADING_DATA_DIR}/{jurisdiction}/users/` | Local only if you graded locally |
|
| 179 |
| **AI template rows** | `grading_template.parquet` — regeneratable from `structured_analysis` | Not precious |
|
| 180 |
|
|
|
|
| 227 |
1. **Jurisdiction Discovery:** Local mode scans `GRADING_DATA_DIR`; HF mode scans the dataset for `grading_template.parquet`
|
| 228 |
2. **User Accounts:** Each jurisdiction has separate user accounts (same username can exist in different jurisdictions)
|
| 229 |
3. **Data Storage:** Local mode writes parquet and `user_progress.csv` under `GRADING_DATA_DIR/{jurisdiction}/users/`; HF mode writes sparse CSV grades and the navigation table to the storage bucket and keeps templates / `users.json` on the dataset
|
| 230 |
+
4. **Stats:** After sign-in, **Stats** in the sidebar opens a read-only dashboard of Pass / fail / ungraded cards for you, everyone, and each other grader, across every jurisdiction whose template matches the current swipe-deck schema. Older leftover templates are skipped (they do not crash the page). Opening Stats does not change last-viewed / furthest-viewed. Local demo data: `uv run python seed_stats_demo.py` from this directory (new accounts use password `1234`; existing nonempty verdicts are kept).
|
| 231 |
|
| 232 |
## Deployment
|
| 233 |
|
src/stats.py
CHANGED
|
@@ -17,6 +17,26 @@ DEFAULT_FAIL_REASONS: tuple[str, ...] = (
|
|
| 17 |
)
|
| 18 |
OTHER_REASON = "Other"
|
| 19 |
_ANSWERS_SUFFIXES = ("_answers.parquet", "_answers.csv")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
@dataclass(frozen=True)
|
|
|
|
| 17 |
)
|
| 18 |
OTHER_REASON = "Other"
|
| 19 |
_ANSWERS_SUFFIXES = ("_answers.parquet", "_answers.csv")
|
| 20 |
+
SWIPE_TEMPLATE_COLUMNS: tuple[str, ...] = (
|
| 21 |
+
"term",
|
| 22 |
+
"field_of_law",
|
| 23 |
+
"concept",
|
| 24 |
+
"section",
|
| 25 |
+
"section_index",
|
| 26 |
+
"question_id",
|
| 27 |
+
"group",
|
| 28 |
+
"part",
|
| 29 |
+
"question",
|
| 30 |
+
"question_index",
|
| 31 |
+
"answer",
|
| 32 |
+
"source_fields",
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def missing_template_columns(columns: object) -> tuple[str, ...]:
|
| 37 |
+
"""Return swipe-deck columns absent from a template frame."""
|
| 38 |
+
present = {str(name) for name in columns}
|
| 39 |
+
return tuple(name for name in SWIPE_TEMPLATE_COLUMNS if name not in present)
|
| 40 |
|
| 41 |
|
| 42 |
@dataclass(frozen=True)
|
src/streamlit_app.py
CHANGED
|
@@ -40,6 +40,7 @@ from stats import (
|
|
| 40 |
counts_from_verdicts,
|
| 41 |
dashboard_html,
|
| 42 |
grades_to_verdicts,
|
|
|
|
| 43 |
overlay_verdicts,
|
| 44 |
sum_counts,
|
| 45 |
username_from_answers_filename,
|
|
@@ -290,65 +291,66 @@ def get_jurisdiction_display_mapping(jurisdiction_codes):
|
|
| 290 |
# ---------------------------------------------------------------------------
|
| 291 |
# Template + user data I/O
|
| 292 |
# ---------------------------------------------------------------------------
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
"
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
|
| 309 |
@st.cache_data
|
| 310 |
def load_grading_template(jurisdiction):
|
| 311 |
"""Load the PASS/FAIL question grading template (local path or HF)."""
|
| 312 |
-
|
| 313 |
-
if not jurisdiction or jurisdiction not in
|
|
|
|
| 314 |
st.error("❌ **Error**: Jurisdiction is not set or invalid.")
|
| 315 |
-
if
|
| 316 |
-
st.error(f"Available jurisdictions: {', '.join(
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
if LOCAL_MODE:
|
| 320 |
-
template_path = _local_template_path(jurisdiction)
|
| 321 |
-
try:
|
| 322 |
-
if not template_path.is_file():
|
| 323 |
-
raise FileNotFoundError(f"Template not found: {template_path}")
|
| 324 |
-
df = pd.read_parquet(template_path)
|
| 325 |
-
except Exception as e:
|
| 326 |
-
st.error(f"❌ **Error loading local grading template**: {str(e)}")
|
| 327 |
-
st.error(f"Expected file at `{template_path}`")
|
| 328 |
-
st.stop()
|
| 329 |
-
else:
|
| 330 |
-
try:
|
| 331 |
-
file_path = hf_hub_download(
|
| 332 |
-
repo_id=HF_DATASET_REPO,
|
| 333 |
-
filename=f"{jurisdiction}/grading_template.parquet",
|
| 334 |
-
repo_type="dataset",
|
| 335 |
-
token=HF_TOKEN,
|
| 336 |
-
)
|
| 337 |
-
df = pd.read_parquet(file_path)
|
| 338 |
-
except Exception as e:
|
| 339 |
st.error(
|
| 340 |
-
|
|
|
|
| 341 |
)
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
|
|
|
|
|
|
| 346 |
|
| 347 |
-
missing =
|
| 348 |
if missing:
|
| 349 |
st.error(
|
| 350 |
"❌ **Error**: grading template is missing columns "
|
| 351 |
-
f"{missing}. Rebuild it with `build_grading_template.py`."
|
| 352 |
)
|
| 353 |
st.stop()
|
| 354 |
return df
|
|
@@ -593,8 +595,13 @@ def _build_stats_snapshot(
|
|
| 593 |
]:
|
| 594 |
per_user: dict[str, GradeCounts] = {}
|
| 595 |
per_jurisdiction: dict[str, GradeCounts] = {}
|
|
|
|
| 596 |
for code in jurisdictions:
|
| 597 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
total_cards = len(cards)
|
| 599 |
for grader in list_grader_usernames(code):
|
| 600 |
counts = _counts_for_user(
|
|
@@ -620,10 +627,10 @@ def _build_stats_snapshot(
|
|
| 620 |
((name, counts) for name, counts in per_user.items() if name != username),
|
| 621 |
key=lambda item: (-item[1].graded, item[0]),
|
| 622 |
)
|
| 623 |
-
names = get_jurisdiction_display_mapping(tuple(
|
| 624 |
jurisdiction_rows = [
|
| 625 |
(code, names.get(code, code), per_jurisdiction.get(code, sum_counts()))
|
| 626 |
-
for code in
|
| 627 |
]
|
| 628 |
return everyone, you, others, jurisdiction_rows
|
| 629 |
|
|
@@ -657,6 +664,8 @@ def _render_stats_page(
|
|
| 657 |
username: str, jurisdiction: str, jurisdictions: list[str]
|
| 658 |
) -> None:
|
| 659 |
_render_stats_fabs()
|
|
|
|
|
|
|
| 660 |
everyone, you, others, jurisdiction_rows = _build_stats_snapshot(
|
| 661 |
username,
|
| 662 |
jurisdiction,
|
|
@@ -674,6 +683,12 @@ def _render_stats_page(
|
|
| 674 |
),
|
| 675 |
unsafe_allow_html=True,
|
| 676 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
|
| 678 |
|
| 679 |
def _progress_file_missing(exc: BaseException) -> bool:
|
|
@@ -1551,7 +1566,7 @@ def _try_register(username: str, password: str, jurisdiction: str) -> None:
|
|
| 1551 |
# Login page
|
| 1552 |
# ---------------------------------------------------------------------------
|
| 1553 |
if not st.session_state.logged_in:
|
| 1554 |
-
available_jurisdictions =
|
| 1555 |
auto_user = os.getenv("GRADING_AUTO_LOGIN") if LOCAL_MODE else None
|
| 1556 |
if auto_user and available_jurisdictions:
|
| 1557 |
jurisdiction = st.session_state.jurisdiction or available_jurisdictions[0]
|
|
@@ -1621,7 +1636,7 @@ elif notice == "register":
|
|
| 1621 |
username = st.session_state.username
|
| 1622 |
jurisdiction = st.session_state.jurisdiction
|
| 1623 |
|
| 1624 |
-
available_jurisdictions =
|
| 1625 |
if not jurisdiction or jurisdiction not in available_jurisdictions:
|
| 1626 |
st.error("❌ **Error**: Jurisdiction is not set. Please sign out and in again.")
|
| 1627 |
if st.button("Sign out"):
|
|
@@ -1629,7 +1644,7 @@ if not jurisdiction or jurisdiction not in available_jurisdictions:
|
|
| 1629 |
st.stop()
|
| 1630 |
|
| 1631 |
if st.session_state.view == "stats":
|
| 1632 |
-
_render_stats_page(username, jurisdiction,
|
| 1633 |
st.stop()
|
| 1634 |
|
| 1635 |
st.markdown('<div class="grading-top-align"></div>', unsafe_allow_html=True)
|
|
|
|
| 40 |
counts_from_verdicts,
|
| 41 |
dashboard_html,
|
| 42 |
grades_to_verdicts,
|
| 43 |
+
missing_template_columns,
|
| 44 |
overlay_verdicts,
|
| 45 |
sum_counts,
|
| 46 |
username_from_answers_filename,
|
|
|
|
| 291 |
# ---------------------------------------------------------------------------
|
| 292 |
# Template + user data I/O
|
| 293 |
# ---------------------------------------------------------------------------
|
| 294 |
+
@st.cache_data
|
| 295 |
+
def _read_template_df(jurisdiction: str) -> pd.DataFrame | None:
|
| 296 |
+
"""Load a jurisdiction parquet with no schema check and no Streamlit stop."""
|
| 297 |
+
try:
|
| 298 |
+
if LOCAL_MODE:
|
| 299 |
+
template_path = _local_template_path(jurisdiction)
|
| 300 |
+
if not template_path.is_file():
|
| 301 |
+
return None
|
| 302 |
+
return pd.read_parquet(template_path)
|
| 303 |
+
file_path = hf_hub_download(
|
| 304 |
+
repo_id=HF_DATASET_REPO,
|
| 305 |
+
filename=f"{jurisdiction}/grading_template.parquet",
|
| 306 |
+
repo_type="dataset",
|
| 307 |
+
token=HF_TOKEN,
|
| 308 |
+
)
|
| 309 |
+
return pd.read_parquet(file_path)
|
| 310 |
+
except Exception:
|
| 311 |
+
return None
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
@st.cache_data
|
| 315 |
+
def discover_compatible_jurisdictions() -> tuple[str, ...]:
|
| 316 |
+
"""Jurisdictions whose template matches the current swipe-deck columns."""
|
| 317 |
+
compatible: list[str] = []
|
| 318 |
+
for code in discover_available_jurisdictions():
|
| 319 |
+
frame = _read_template_df(code)
|
| 320 |
+
if frame is None:
|
| 321 |
+
continue
|
| 322 |
+
if missing_template_columns(frame.columns):
|
| 323 |
+
continue
|
| 324 |
+
compatible.append(code)
|
| 325 |
+
return tuple(compatible)
|
| 326 |
|
| 327 |
|
| 328 |
@st.cache_data
|
| 329 |
def load_grading_template(jurisdiction):
|
| 330 |
"""Load the PASS/FAIL question grading template (local path or HF)."""
|
| 331 |
+
compatible = discover_compatible_jurisdictions()
|
| 332 |
+
if not jurisdiction or jurisdiction not in compatible:
|
| 333 |
+
available = discover_available_jurisdictions()
|
| 334 |
st.error("❌ **Error**: Jurisdiction is not set or invalid.")
|
| 335 |
+
if compatible:
|
| 336 |
+
st.error(f"Available jurisdictions: {', '.join(compatible)}")
|
| 337 |
+
elif available:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 338 |
st.error(
|
| 339 |
+
"Found template files, but none match the current grading schema. "
|
| 340 |
+
"Rebuild them with `build_grading_template.py`."
|
| 341 |
)
|
| 342 |
+
st.stop()
|
| 343 |
+
|
| 344 |
+
df = _read_template_df(jurisdiction)
|
| 345 |
+
if df is None:
|
| 346 |
+
st.error("❌ **Error**: Could not load the grading template.")
|
| 347 |
+
st.stop()
|
| 348 |
|
| 349 |
+
missing = missing_template_columns(df.columns)
|
| 350 |
if missing:
|
| 351 |
st.error(
|
| 352 |
"❌ **Error**: grading template is missing columns "
|
| 353 |
+
f"{list(missing)}. Rebuild it with `build_grading_template.py`."
|
| 354 |
)
|
| 355 |
st.stop()
|
| 356 |
return df
|
|
|
|
| 595 |
]:
|
| 596 |
per_user: dict[str, GradeCounts] = {}
|
| 597 |
per_jurisdiction: dict[str, GradeCounts] = {}
|
| 598 |
+
used: list[str] = []
|
| 599 |
for code in jurisdictions:
|
| 600 |
+
frame = _read_template_df(code)
|
| 601 |
+
if frame is None or missing_template_columns(frame.columns):
|
| 602 |
+
continue
|
| 603 |
+
used.append(code)
|
| 604 |
+
cards = get_cards(frame)
|
| 605 |
total_cards = len(cards)
|
| 606 |
for grader in list_grader_usernames(code):
|
| 607 |
counts = _counts_for_user(
|
|
|
|
| 627 |
((name, counts) for name, counts in per_user.items() if name != username),
|
| 628 |
key=lambda item: (-item[1].graded, item[0]),
|
| 629 |
)
|
| 630 |
+
names = get_jurisdiction_display_mapping(tuple(used))
|
| 631 |
jurisdiction_rows = [
|
| 632 |
(code, names.get(code, code), per_jurisdiction.get(code, sum_counts()))
|
| 633 |
+
for code in used
|
| 634 |
]
|
| 635 |
return everyone, you, others, jurisdiction_rows
|
| 636 |
|
|
|
|
| 664 |
username: str, jurisdiction: str, jurisdictions: list[str]
|
| 665 |
) -> None:
|
| 666 |
_render_stats_fabs()
|
| 667 |
+
compatible = set(discover_compatible_jurisdictions())
|
| 668 |
+
skipped = [code for code in jurisdictions if code not in compatible]
|
| 669 |
everyone, you, others, jurisdiction_rows = _build_stats_snapshot(
|
| 670 |
username,
|
| 671 |
jurisdiction,
|
|
|
|
| 683 |
),
|
| 684 |
unsafe_allow_html=True,
|
| 685 |
)
|
| 686 |
+
if skipped:
|
| 687 |
+
st.caption(
|
| 688 |
+
"Skipped templates that are not the current grading schema: "
|
| 689 |
+
+ ", ".join(skipped)
|
| 690 |
+
+ "."
|
| 691 |
+
)
|
| 692 |
|
| 693 |
|
| 694 |
def _progress_file_missing(exc: BaseException) -> bool:
|
|
|
|
| 1566 |
# Login page
|
| 1567 |
# ---------------------------------------------------------------------------
|
| 1568 |
if not st.session_state.logged_in:
|
| 1569 |
+
available_jurisdictions = list(discover_compatible_jurisdictions())
|
| 1570 |
auto_user = os.getenv("GRADING_AUTO_LOGIN") if LOCAL_MODE else None
|
| 1571 |
if auto_user and available_jurisdictions:
|
| 1572 |
jurisdiction = st.session_state.jurisdiction or available_jurisdictions[0]
|
|
|
|
| 1636 |
username = st.session_state.username
|
| 1637 |
jurisdiction = st.session_state.jurisdiction
|
| 1638 |
|
| 1639 |
+
available_jurisdictions = list(discover_compatible_jurisdictions())
|
| 1640 |
if not jurisdiction or jurisdiction not in available_jurisdictions:
|
| 1641 |
st.error("❌ **Error**: Jurisdiction is not set. Please sign out and in again.")
|
| 1642 |
if st.button("Sign out"):
|
|
|
|
| 1644 |
st.stop()
|
| 1645 |
|
| 1646 |
if st.session_state.view == "stats":
|
| 1647 |
+
_render_stats_page(username, jurisdiction, list(discover_available_jurisdictions()))
|
| 1648 |
st.stop()
|
| 1649 |
|
| 1650 |
st.markdown('<div class="grading-top-align"></div>', unsafe_allow_html=True)
|
tests/test_stats.py
CHANGED
|
@@ -9,6 +9,7 @@ from stats import (
|
|
| 9 |
dashboard_html,
|
| 10 |
empty_counts,
|
| 11 |
grades_to_verdicts,
|
|
|
|
| 12 |
overlay_verdicts,
|
| 13 |
percent,
|
| 14 |
segment_widths,
|
|
@@ -17,6 +18,40 @@ from stats import (
|
|
| 17 |
)
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
def test_empty_user_is_all_ungraded() -> None:
|
| 21 |
counts = counts_from_verdicts({}, 10)
|
| 22 |
assert counts.passed == 0
|
|
|
|
| 9 |
dashboard_html,
|
| 10 |
empty_counts,
|
| 11 |
grades_to_verdicts,
|
| 12 |
+
missing_template_columns,
|
| 13 |
overlay_verdicts,
|
| 14 |
percent,
|
| 15 |
segment_widths,
|
|
|
|
| 18 |
)
|
| 19 |
|
| 20 |
|
| 21 |
+
def test_missing_template_columns_detects_old_schema() -> None:
|
| 22 |
+
old = pd.DataFrame(
|
| 23 |
+
columns=[
|
| 24 |
+
"term",
|
| 25 |
+
"category",
|
| 26 |
+
"category_index",
|
| 27 |
+
"subcategory",
|
| 28 |
+
"subcategory_index",
|
| 29 |
+
"question",
|
| 30 |
+
"answer",
|
| 31 |
+
]
|
| 32 |
+
)
|
| 33 |
+
missing = missing_template_columns(old.columns)
|
| 34 |
+
assert "field_of_law" in missing
|
| 35 |
+
assert "question_id" in missing
|
| 36 |
+
current = pd.DataFrame(
|
| 37 |
+
columns=[
|
| 38 |
+
"term",
|
| 39 |
+
"field_of_law",
|
| 40 |
+
"concept",
|
| 41 |
+
"section",
|
| 42 |
+
"section_index",
|
| 43 |
+
"question_id",
|
| 44 |
+
"group",
|
| 45 |
+
"part",
|
| 46 |
+
"question",
|
| 47 |
+
"question_index",
|
| 48 |
+
"answer",
|
| 49 |
+
"source_fields",
|
| 50 |
+
]
|
| 51 |
+
)
|
| 52 |
+
assert missing_template_columns(current.columns) == ()
|
| 53 |
+
|
| 54 |
+
|
| 55 |
def test_empty_user_is_all_ungraded() -> None:
|
| 56 |
counts = counts_from_verdicts({}, 10)
|
| 57 |
assert counts.passed == 0
|