grading-answers / src /glossary.py
Anyffen's picture
Deploy PASS/FAIL grading UI (glossary tips, swipe deck)
4ca84d7 verified
Raw
History Blame Contribute Delete
8.01 kB
"""Glossary tooltips for hard legal-grading terms in PASS/FAIL questions."""
from __future__ import annotations
import html
import re
from typing import Iterable
# Longest-first patterns so nested phrases (e.g. prevailing … containing
# "sub-jurisdictional") get a single, more specific tip.
_GLOSSARY_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
(
re.compile(r"minority or divergent sub-jurisdictional legal treatment", re.I),
(
"A recognized alternative legal treatment followed by one or more "
"sub-jurisdictions that differs meaningfully from the prevailing treatment."
),
),
(
re.compile(r"prevailing sub-jurisdictional legal treatment", re.I),
(
"The treatment that can fairly be described as the dominant, usual, or "
"majority legal position across the relevant sub-jurisdictions. It need "
"not be literally universal."
),
),
(
re.compile(
r"require qualification of an otherwise jurisdiction-wide description",
re.I,
),
(
"A single unqualified jurisdiction-wide statement would risk being "
"inaccurate or misleading, so the answer should signal the important "
"variation."
),
),
(
re.compile(r"ordinary field of legal operation", re.I),
(
"The normal situations in which the concept operates. This is different "
"from exceptional cases, competing rules, or genuine edge cases, which "
"belong in a Boundary Conditions block."
),
),
(
re.compile(r"ordinary field of operation", re.I),
(
"The normal situations in which the concept operates. This is different "
"from exceptional cases, competing rules, or genuine edge cases, which "
"belong in a Boundary Conditions block."
),
),
(
re.compile(r"ordinary application", re.I),
(
"The normal situations in which the concept operates. This is different "
"from exceptional cases, competing rules, or genuine edge cases, which "
"belong in a Boundary Conditions block."
),
),
(
re.compile(r"boundary situations?", re.I),
(
"A recognized situation at or near the legal edge of the concept's "
"ordinary operation: an exception, limiting principle, competing rule, "
"or genuine area of legal uncertainty. A simple case in which a basic "
"prerequisite is plainly missing is not necessarily a meaningful "
"boundary situation."
),
),
(
re.compile(r"boundary circumstances?", re.I),
(
"A recognized situation at or near the legal edge of the concept's "
"ordinary operation: an exception, limiting principle, competing rule, "
"or genuine area of legal uncertainty. A simple case in which a basic "
"prerequisite is plainly missing is not necessarily a meaningful "
"boundary situation."
),
),
(
re.compile(r"substantially uniform", re.I),
(
"The concept can still be described as materially the same across the "
"jurisdiction despite some recognized local differences."
),
),
(
re.compile(r"sub-jurisdictional", re.I),
(
"A legally relevant constituent jurisdiction within the jurisdiction "
"being analyzed—for example, a state, province, Land, canton, or other "
"constituent legal unit."
),
),
(
re.compile(r"sub-jurisdictions?", re.I),
(
"A legally relevant constituent jurisdiction within the jurisdiction "
"being analyzed—for example, a state, province, Land, canton, or other "
"constituent legal unit."
),
),
)
def _parse_emphasis(question_md: str) -> tuple[str, list[tuple[int, int]]]:
"""Expand ``:blue[...]`` markers into plain text + emphasis spans."""
cleaned = question_md
while ":blue[:blue[" in cleaned:
cleaned = re.sub(r":blue\[:blue\[(.*?)\]\s*\]", r":blue[\1]", cleaned)
plain_chars: list[str] = []
emphasis: list[tuple[int, int]] = []
pos = 0
for match in re.finditer(r":blue\[(.*?)\]", cleaned):
plain_chars.extend(cleaned[pos : match.start()])
inner = match.group(1).strip()
start = len(plain_chars)
plain_chars.extend(inner)
emphasis.append((start, start + len(inner)))
pos = match.end()
plain_chars.extend(cleaned[pos:])
return "".join(plain_chars), emphasis
def _glossary_spans(plain: str) -> list[tuple[int, int, str]]:
"""Non-overlapping glossary matches on plain question text."""
occupied = [False] * len(plain)
spans: list[tuple[int, int, str]] = []
for pattern, tip in _GLOSSARY_PATTERNS:
for match in pattern.finditer(plain):
start, end = match.start(), match.end()
if any(occupied[start:end]):
continue
for i in range(start, end):
occupied[i] = True
spans.append((start, end, tip))
spans.sort(key=lambda item: item[0])
return spans
def _emphasized(index: int, emphasis: Iterable[tuple[int, int]]) -> bool:
return any(start <= index < end for start, end in emphasis)
def _tip_icon(tip: str) -> str:
return (
'<span class="glossary-tip" aria-label="Glossary">'
"?"
f'<span class="glossary-bubble">{html.escape(tip)}</span>'
"</span>"
)
def format_question_html(question_md: str) -> str:
"""Render a question with bold emphasis spans and glossary tip icons."""
plain, emphasis = _parse_emphasis(question_md)
tips = _glossary_spans(plain)
parts: list[str] = []
# Icon sits at the start of the term (top-left), not after it.
tip_at = {start: tip for start, _, tip in tips}
i = 0
after_tag = False
while i < len(plain):
if i in tip_at:
parts.append(_tip_icon(tip_at[i]))
after_tag = True
if _emphasized(i, emphasis):
j = i + 1
while j < len(plain) and _emphasized(j, emphasis):
j += 1
parts.append(f"<strong>{html.escape(plain[i:j])}</strong>")
i = j
after_tag = True
continue
ch = plain[i]
# Streamlit's HTML markdown path drops ordinary spaces right after
# closing tags, which glues "identify"+"the" into "identifythe".
if after_tag and ch == " ":
parts.append("&nbsp;")
else:
parts.append(html.escape(ch))
i += 1
after_tag = False
body = "".join(parts)
return (
"<div class='grading-question' "
"style='font-size:1.15rem;font-weight:400;line-height:1.45;"
f"margin:0.25rem 0 0'>{body}</div>"
)
GLOSSARY_TIP_CSS = """
.glossary-tip {
position: relative;
display: inline-block;
margin: 0 0.12rem 0 0.02rem;
padding: 0 0.18rem;
border: 1px solid #9a9a9a;
border-radius: 999px;
font-size: 0.55rem;
font-weight: 600;
line-height: 1.1;
color: #666;
vertical-align: 0.55em;
cursor: help;
}
.glossary-tip .glossary-bubble {
visibility: hidden;
opacity: 0;
position: absolute;
left: 0;
bottom: calc(100% + 0.35rem);
z-index: 1000;
width: max-content;
max-width: min(24rem, 70vw);
padding: 0.85rem 1rem;
border-radius: 0.45rem;
background: #1f1f1f;
color: #f5f5f5;
font-size: 0.95rem;
font-weight: 400;
line-height: 1.45;
text-align: left;
white-space: normal;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.22);
pointer-events: none;
transition: none;
}
.glossary-tip:hover .glossary-bubble {
visibility: visible;
opacity: 1;
}
"""