"""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 ( '' "?" f'{html.escape(tip)}' "" ) 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"{html.escape(plain[i:j])}") 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(" ") else: parts.append(html.escape(ch)) i += 1 after_tag = False body = "".join(parts) return ( "
{body}
" ) 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; } """