import streamlit as st import pandas as pd import numpy as np import altair as alt from datasets import load_from_disk from huggingface_hub import snapshot_download import colorsys import html import os import re import streamlit.components.v1 as components # text utils LABEL_ORDER = [ "FA - Factual Argument", "FO - Precedent", "FO - Systematic Interpretation", "FO - Textual Interpretation", "SU - Non-Legal Argument", "SU - Teleological or Purposive Interpretation", "SU - Proportionality Analysis", "FA - Factual Question", "FO - Formal Question", "SU - Substantive Question", "Negative Frame (ISS-N)", "Positive Frame (ISS-P)", "Crime Frame (JUST-C)", "Health Frame (JUST-H)", "National Security Frame (JUST-S)", "Rights Frame (JUST-R)", ] webcast_id_to_title = { "93113_14092016" : "Satakunnan Markkinapörssi Oy and Satamedia Oy v Finland (2016)", "578608_03042013" : "Söderman v Sweden (2013)", "669718_10062020" : "MA v Denmark (2020)", "1273810_13112013" : "Jeunesse v the Netherlands (2013)", "1321605_22012014" : "Chiragov and Others v Armenia (2014)", "2785309_10102012" : "X v Latvia (2012)", "3525208_10072019" : "Centrum förrättvisa v Sweden (2019)", "3735909_16102013" : "Hamalainen v Finland (2013)", "3826308_23052018" : "Georgia v Russia (2018)", "3931506_19062012" : "Telegraaf Media Nederland Landelijke Media BV and Others v the Netherlands (2012)", "3963009_16052012" : "El-Masri v the former Yugoslav Republic of Macedonia (2012)", "4016706_05022014" : "Sargsyan v Azerbaijan (2014)", "4714306_24092014" : "Roman Zakharov v Russia (2014)", "5280609_18092012" : "Vilnes and Others v Norway (2012)", "5817013_07112017" : "Big Brother Watch and Others v the UnitedKingdom (2017)", "5817013_10072019" : "Big Brother Watch and Others v the UnitedKingdom (2019)" } def concat_global_text(df, webcast_id, text_col="text"): rows = df[df["webcast_id"] == webcast_id] if "segment_id" in rows.columns: rows = rows.sort_values(["segment_id", "sequence_id"]) elif "paragraph_id" in rows.columns: rows = rows.sort_values("paragraph_id") return " ".join(rows[text_col].fillna("").tolist()) def sanity_check(df_ann, text_len): if df_ann.empty: return True return df_ann["global_end"].max() <= text_len def html_attr(value): return html.escape(str(value), quote=True).replace("\n", " ") def get_hf_dataset_root(): repo = os.getenv("HF_DATASET_REPO") if not repo: return None cached_root = st.session_state.get("hf_dataset_root") if cached_root: return cached_root token = os.getenv("HF_TOKEN") if not token: st.error("HF_TOKEN secret missing for private dataset access.") st.stop() cache_dir = os.path.join(os.getcwd(), ".hf_data_cache") try: snapshot_path = snapshot_download( repo_id=repo, repo_type="dataset", token=token, local_dir=cache_dir, local_dir_use_symlinks=False, ) except Exception as exc: st.error(f"Failed to load dataset repo: {exc}") st.stop() st.session_state["hf_dataset_root"] = snapshot_path return snapshot_path def resolve_dataset_path(relative_path): root = get_hf_dataset_root() return os.path.join(root, relative_path) if root else relative_path def format_char_count(value): n = int(value) if n >= 10000: return f"{int(round(n / 1000.0))}k" if n >= 100: scaled = round(n / 1000.0, 1) text = f"{scaled:.1f}".rstrip("0").rstrip(".") return f"{text}k" return str(n) def normalize_section_title(text): return " ".join(str(text).split()) if text else "Unknown" def normalize_hearing_speaker(name): raw = normalize_section_title(name) if raw == "UNK": return "UNK" match = re.match(r"^(AP|GR|TP)\s*(\d+)$", raw) if match: return f"{match.group(1)}{match.group(2)}" return "Judge" def compute_hearing_sections(df_text): if df_text is None or df_text.empty: return [] rows = df_text.sort_values(["segment_id", "sequence_id"]) sections = [] cursor = 0 total_rows = len(rows) processed_rows = 0 for _, seg_rows in rows.groupby("segment_id"): speaker = ( seg_rows.iloc[0].get("speaker_name") or seg_rows.iloc[0].get("speaker_role") or "Unknown" ) seg_start = cursor pieces = [] for _, r in seg_rows.iterrows(): t = r["text"] or "" pieces.append(t) processed_rows += 1 segment_text = " ".join(pieces) seg_end = seg_start + len(segment_text) if processed_rows < total_rows: cursor = seg_end + 1 else: cursor = seg_end section_name = normalize_hearing_speaker(speaker) if sections and sections[-1]["name"] == section_name: sections[-1]["end"] = seg_end else: sections.append({ "name": section_name, "start": seg_start, "end": seg_end, }) return sections def compute_judgment_sections(df_text): if df_text is None or df_text.empty: return [] rows = df_text.sort_values("paragraph_id") paragraphs = [] cursor = 0 for _, row in rows.iterrows(): ptext = row["text"] or "" start = cursor end = start + len(ptext) paragraphs.append({"text": ptext, "start": start, "end": end}) cursor = end + 1 if not paragraphs: return [] facts_idx = None for i, p in enumerate(paragraphs): if "THE FACTS" in p["text"]: facts_idx = i if facts_idx is None: return [] law_idx = None for i in range(facts_idx + 1, len(paragraphs)): if "THE LAW" in paragraphs[i]["text"]: law_idx = i if law_idx is None: return [] opinion_indices = [ i for i in range(law_idx + 1, len(paragraphs)) if "OPINION" in paragraphs[i]["text"] ] sections = [] facts_start = paragraphs[facts_idx]["start"] law_start = paragraphs[law_idx]["start"] facts_end = law_start sections.append({ "name": normalize_section_title(paragraphs[facts_idx]["text"]), "start": facts_start, "end": facts_end, }) law_end = ( paragraphs[opinion_indices[0]]["start"] if opinion_indices else paragraphs[-1]["end"] ) sections.append({ "name": normalize_section_title(paragraphs[law_idx]["text"]), "start": law_start, "end": law_end, }) for idx, op_idx in enumerate(opinion_indices): start = paragraphs[op_idx]["start"] end = ( paragraphs[opinion_indices[idx + 1]]["start"] if idx + 1 < len(opinion_indices) else paragraphs[-1]["end"] ) sections.append({ "name": normalize_section_title(paragraphs[op_idx]["text"]), "start": start, "end": end, }) return sections def render_section_guide(sections, compact_columns=None): #st.markdown("### Section Guide") if not sections: #st.info("No section guide could be generated for this document.") return if compact_columns: cells = [] for section in sections: name = html.escape(str(section["name"])) start = format_char_count(section["start"]) end = format_char_count(section["end"]) cells.append( "
" f"
{name}
" f"
{start} - {end}
" "
" ) # st.markdown( # "" # f"
" # + "".join(cells) # + "
", # unsafe_allow_html=True, # ) else: rows = [] for section in sections: name = html.escape(str(section["name"])) start = format_char_count(section["start"]) end = format_char_count(section["end"]) rows.append( "
" f"{name}" f"{start} - {end}" "
" ) # st.markdown( # "" # "
" # + "".join(rows) # + "
", # unsafe_allow_html=True, # ) # span -> bin coverage def bin_spans_into_brackets(df_ann, text_len, bin_size): if df_ann.empty: return pd.DataFrame() records = [] for _, row in df_ann.iterrows(): s = int(row["global_begin"]) e = int(min(row["global_end"], text_len)) if s >= e: continue start_bin = s // bin_size end_bin = e // bin_size for b in range(start_bin, end_bin + 1): bin_start = b * bin_size bin_end = min((b + 1) * bin_size, text_len) overlap_start = max(s, bin_start) overlap_end = min(e, bin_end) if overlap_start < overlap_end: overlap_len = overlap_end - overlap_start argument_text = str(row.get("label", "")).strip() annotator = str(row.get("annotator", "")).strip() if argument_text and annotator: argument_entry = f"{argument_text} ({annotator})" elif argument_text: argument_entry = argument_text elif annotator: argument_entry = f"(annotator: {annotator})" else: argument_entry = "" records.append({ "label": row["label"], "bin": b, "overlap_len": overlap_len, "bin_size": bin_size, "argument_entry": argument_entry, }) if not records: return pd.DataFrame() df = pd.DataFrame(records) def join_unique(values): seen = [] for value in values: if value and value not in seen: seen.append(value) return "\n".join(seen) df = ( df.groupby(["label", "bin"], as_index=False) .agg({ "overlap_len": "sum", "bin_size": "first", }) ) df_args = ( pd.DataFrame(records) .groupby("bin", as_index=False) .agg({"argument_entry": join_unique}) ) df = df.merge(df_args, on="bin", how="left") df = df.rename(columns={"argument_entry": "arguments"}) df["coverage_ratio"] = (df["overlap_len"] / df["bin_size"]).clip(0, 1) return df # matrix style heatmap def make_matrix_style_heatmap( df_heat, bin_size, text_len, color="#1f6aff", background_sections=None, ): if df_heat.empty: return alt.Chart(pd.DataFrame({"a": []})).mark_text(text="No annotations") df = df_heat.copy() label_ordered = [l for l in LABEL_ORDER if l in df["label"].unique()] n_labels = max(len(label_ordered), 1) df["bin_start"] = df["bin"] * bin_size df["bin_end"] = df["bin_start"] + bin_size df["row_type"] = "cell" df["bg_color"] = None def parse_hex_color(value): val = str(value or "").strip().lstrip("#") if len(val) == 3: val = "".join(ch * 2 for ch in val) if len(val) != 6: return 31, 106, 255 return int(val[0:2], 16), int(val[2:4], 16), int(val[4:6], 16) hr, hg, hb = parse_hex_color(color) df["color_value"] = df["coverage_ratio"].fillna(0).apply( lambda v: ( f"rgba({int(255 + (hr - 255) * v)}," f"{int(255 + (hg - 255) * v)}," f"{int(255 + (hb - 255) * v)},1)" ) ) df_bg = None if background_sections: bg_rows = [] color_map = { "AP": "rgba(76, 175, 80, 0.14)", "GR": "rgba(244, 67, 54, 0.14)", "TP": "rgba(33, 150, 243, 0.14)", "UNK": "rgba(158, 158, 158, 0.14)", "Judge": "rgba(255, 193, 7, 0.14)", } def section_category(name): if name == "UNK": return "UNK" match = re.match(r"^(AP|GR|TP)", str(name)) if match: return match.group(1) return "Judge" for section in background_sections: start = int(section.get("start", 0)) end = int(section.get("end", 0)) start = max(0, min(start, text_len)) end = max(0, min(end, text_len)) if end <= start: continue start_bin = max(start // bin_size, 0) end_bin = max((end - 1) // bin_size, start_bin) if section.get("color"): bg_color = section["color"] else: category = section_category(section.get("name")) bg_color = color_map.get(category, "rgba(200, 200, 200, 0.12)") for b in range(start_bin, end_bin + 1): bin_start = b * bin_size bin_end = min((b + 1) * bin_size, text_len) for label in label_ordered: bg_rows.append({ "label": label, "bin": b, "bin_start": bin_start, "bin_end": bin_end, "row_type": "bg", "bg_color": bg_color, "color_value": bg_color, "coverage_ratio": 0, "arguments": None, }) if bg_rows: df_bg = pd.DataFrame(bg_rows) if df_bg is not None: df = pd.concat([df_bg, df], ignore_index=True) df = df.sort_values( ["row_type", "label", "bin_start"], ascending=[True, True, True], ) heatmap_select = alt.selection_point( fields=["label", "bin"], on="click", clear="dblclick", name="heatmap_select", ) chart = ( alt.Chart(df) .mark_rect() .encode( x=alt.X( "bin_start:Q", title="Character Bracket", axis=alt.Axis(format="~s"), scale=alt.Scale(domain=[0, text_len]), ), x2="bin_end:Q", y=alt.Y( "label:N", title="Argument Type", sort=alt.SortArray(label_ordered), axis=alt.Axis(labelLimit=0, labelPadding=8), ), color=alt.Color( "color_value:N", legend=None, scale=None, ), tooltip=[ alt.Tooltip("label:N", title="Argument"), alt.Tooltip("bin_start:Q", title="Bin start", format=","), alt.Tooltip("bin_end:Q", title="Bin end", format=","), alt.Tooltip("coverage_ratio:Q", title="Coverage", format=".0%"), alt.Tooltip("arguments:N", title="Arguments (annotator)") ], ) .add_params(heatmap_select) .properties( height=40 * n_labels, ) ) return chart # highlighting utils def generate_color_palette(n): colors = [] for i in range(n): hue = i / max(1, n) r, g, b = colorsys.hls_to_rgb(hue, 0.6, 0.8) colors.append(f"rgba({int(r*255)}, {int(g*255)}, {int(b*255)}, 0.35)") return colors def make_annotator_color_map(annotators): colors = generate_color_palette(len(annotators)) return {a: c for a, c in zip(annotators, colors)} def compute_interval_segments(text_len, spans, extra_boundaries=None): boundaries = {0, text_len} for s, e, _ in spans: boundaries.add(int(s)) boundaries.add(int(e)) if extra_boundaries: for b in extra_boundaries: if b is not None: boundaries.add(int(b)) cuts = sorted(b for b in boundaries if 0 <= b <= text_len) intervals = [] for i in range(len(cuts) - 1): s, e = cuts[i], cuts[i+1] if s >= e: continue active = [span for span in spans if span[0] < e and span[1] > s] intervals.append((s, e, [a[2] for a in active])) return intervals def render_highlighted_html( text, spans, color_map, meta_map, focus_ann_id=None, focus_range=None, focus_label=None, ): if not spans: return f"
{html.escape(text)}
" extra_boundaries = [] if focus_range: extra_boundaries.extend([focus_range[0], focus_range[1]]) intervals = compute_interval_segments(len(text), spans, extra_boundaries) out = [] anchored = False for s, e, ann_ids in intervals: chunk = html.escape(text[s:e]) if ann_ids: bg_layers = ", ".join( f"linear-gradient({color_map[a]} 0 0)" for a in ann_ids ) tooltip_lines = [] for a in ann_ids: m = meta_map[a] tooltip_lines.append( f"{m['label']} — {m['annotator']} ({m['curation']})" ) title_attr = html_attr("\n".join(tooltip_lines)) is_focus = focus_ann_id in ann_ids if focus_ann_id else False focus_box = False if focus_range: focus_box = max(s, focus_range[0]) < min(e, focus_range[1]) anchor_attr = "" if is_focus and not anchored: safe_id = re.sub(r"[^a-zA-Z0-9_-]", "_", str(focus_ann_id)) anchor_attr = f' id="ann-{safe_id}"' anchored = True if focus_box: if is_focus: focus_style = "box-shadow: inset 0 0 0 1px #111;" else: focus_style = "box-shadow: inset 0 0 0 2px #111;" else: focus_style = "" chunk = ( f'' f'{chunk}' ) elif focus_range: focus_box = max(s, focus_range[0]) < min(e, focus_range[1]) if focus_box: chunk = ( f'' f"{chunk}" ) out.append(chunk) return "
" + "".join(out) + "
" def extract_heatmap_selection(event): if event is None: return None selection = getattr(event, "selection", None) if selection is None and isinstance(event, dict): selection = event.get("selection") def pull_fields(sel): if sel is None: return None if isinstance(sel, dict): if "label" in sel and "bin" in sel: return sel if "values" in sel: return pull_fields(sel.get("values")) for value in sel.values(): extracted = pull_fields(value) if extracted: return extracted if isinstance(sel, list) and sel: return pull_fields(sel[0]) return None return pull_fields(selection) def project_spans_to_interval(spans_global, seg_start, seg_end): projected = [] for g_start, g_end, ann_id in spans_global: if g_end <= seg_start or g_start >= seg_end: continue local_start = max(g_start, seg_start) - seg_start local_end = min(g_end, seg_end) - seg_start if local_start < local_end: projected.append((local_start, local_end, ann_id)) return projected def pick_focus_annotation(df_ann, label, bin_start, bin_end): if df_ann.empty: return None df_sel = df_ann[ (df_ann["label"] == label) & (df_ann["global_begin"] < bin_end) & (df_ann["global_end"] > bin_start) ] if df_sel.empty: return None overlaps = ( df_sel.assign( overlap=lambda d: ( np.minimum(d["global_end"], bin_end) - np.maximum(d["global_begin"], bin_start) ) ) .sort_values(["overlap", "global_begin"], ascending=[False, True]) ) return overlaps.iloc[0]["annotation_id"] def scroll_to_annotation(focus_ann_id): if focus_ann_id is None: return components.html( "", height=0, ) def scroll_to_heatmap(anchor_id): if not anchor_id: return components.html( "", height=0, ) def render_floating_heatmap_button(anchor_id, button_id): if not anchor_id: return components.html( "", height=0, ) def render_preview_box(html_content): st.markdown( "" f"
{html_content}
", unsafe_allow_html=True, ) def render_heatmap_legend(color_hex): st.markdown( "" "
" "
Intensity
" "
0%
" "
" "
100%
" "
".format(color=color_hex), unsafe_allow_html=True, ) st.markdown(" ", unsafe_allow_html=True) def generate_section_palette(names, alpha=0.3): colors = [] n = max(len(names), 1) for i in range(n): hue = i / n r, g, b = colorsys.hls_to_rgb(hue, 0.75, 0.45) colors.append( f"rgba({int(r*255)}, {int(g*255)}, {int(b*255)}, {alpha})" ) return {name: color for name, color in zip(names, colors)} def render_section_legend(sections, title="Sections"): if not sections: return unique = [] for section in sections: name = section.get("name") color = section.get("color") if name and color: unique.append((name, color)) seen = set() items = [] for name, color in unique: if name in seen: continue seen.add(name) items.append( "
" f"" f"{html.escape(str(name))}
" ) if not items: return st.markdown( "" f"
{html.escape(title)}
" "
" + "".join(items) + "
", unsafe_allow_html=True, ) # streamlit UI st.set_page_config(page_title="Argument Heatmap Explorer", layout="wide") st.title("Argument Saturation Heatmap") app_password = os.getenv("APP_PASSWORD") if app_password: if not st.session_state.get("auth_ok"): with st.sidebar: st.markdown("### Access") pw = st.text_input("Password", type="password") if pw: if pw == app_password: st.session_state["auth_ok"] = True else: st.session_state["auth_ok"] = False st.error("Incorrect password.") if not st.session_state.get("auth_ok"): st.stop() st.caption("Rows = argument types · Columns = character bins · Color = % coverage") st.markdown( "", unsafe_allow_html=True, ) components.html( "", height=0, ) components.html( "", height=0, ) # sidebar st.sidebar.header("Load Data") hearings_ds_path = st.sidebar.text_input( "Hearings dataset path", "la_cour_dataset_hearings" ) judgments_ds_path = st.sidebar.text_input( "Judgments dataset path", "la_cour_dataset_judgments" ) # default CSV locations default_hear_csv = resolve_dataset_path("la_cour_hearings_annotations.csv") default_judg_csv = resolve_dataset_path("la_cour_judgments_annotations.csv") st.sidebar.markdown("#### Annotation CSVs") hear_ann_upload = st.sidebar.file_uploader( "Hearing annotations CSV", type="csv", key="hear_csv_upload" ) judg_ann_upload = st.sidebar.file_uploader( "Judgment annotations CSV", type="csv", key="judg_csv_upload" ) def load_csv_or_default(upload_file, default_path): if upload_file: return pd.read_csv(upload_file), f"(uploaded) {upload_file.name}" if os.path.exists(default_path): return pd.read_csv(default_path), f"(default) {default_path}" return None, "(missing)" df_hear_ann, hear_status = load_csv_or_default(hear_ann_upload, default_hear_csv) df_judg_ann, judg_status = load_csv_or_default(judg_ann_upload, default_judg_csv) st.sidebar.caption(f"Hearing CSV: {hear_status}") st.sidebar.caption(f"Judgment CSV: {judg_status}") bin_size = st.sidebar.slider( "Characters per bin", min_value=50, max_value=3000, value=400, step=50, ) heat_color = st.sidebar.color_picker("Heatmap color", value="#1f6aff") go_heatmap = st.sidebar.button("Back to heatmap") if df_hear_ann is None and df_judg_ann is None: st.info("No annotations loaded — upload a CSV or place defaults in working directory.") st.stop() # load datasets lazily ds_hear = ( load_from_disk(resolve_dataset_path(hearings_ds_path)) if df_hear_ann is not None else None ) ds_judg = ( load_from_disk(resolve_dataset_path(judgments_ds_path)) if df_judg_ann is not None else None ) df_hear_text = ds_hear.to_pandas() if ds_hear else None df_judg_text = ds_judg.to_pandas() if ds_judg else None # tab renderer def render_heatmap_tab(df_ann, df_text, title, key, is_hearing): if df_ann is None: st.warning(f"No {title.lower()} annotations loaded.") return st.subheader(f"{title} Heatmap") webcast_ids = sorted(df_ann["webcast_id"].unique()) webcast = st.selectbox( "Select document", webcast_ids, key=f"wc_{key}", format_func=lambda wc: webcast_id_to_title.get(wc, wc), ) case_title = webcast_id_to_title.get(webcast, webcast) st.markdown(f"#### Case: {case_title}") dfA = df_ann[df_ann["webcast_id"] == webcast] dfT = df_text[df_text["webcast_id"] == webcast] labels = sorted(dfA["label"].dropna().unique()) annotators = sorted(dfA["annotator"].dropna().unique()) prev_webcast_key = f"wc_prev_{key}" if st.session_state.get(prev_webcast_key) != webcast: st.session_state[prev_webcast_key] = webcast st.session_state[f"ann_{key}"] = annotators st.session_state[f"hl_{key}"] = annotators c1, c2, c3, c4 = st.columns(4) sel_labels = c1.multiselect("Argument types", labels, default=labels, key=f"lbl_{key}") sel_ann = c2.multiselect("Annotators (heatmap)", annotators, default=annotators, key=f"ann_{key}") valid_only = c3.checkbox("Valid only", value=True, key=f"valid_{key}") preview_ann = c4.multiselect( "Annotators (preview)", annotators, default=annotators, key=f"hl_{key}", ) dfA = dfA[dfA["label"].isin(sel_labels) & dfA["annotator"].isin(sel_ann)] if valid_only: dfA = dfA[dfA["curation"] == "valid"] full_text = concat_global_text(dfT, webcast) if not sanity_check(dfA, len(full_text)): st.error("Annotation spans exceed text length.") return df_heat = bin_spans_into_brackets(dfA, len(full_text), bin_size) sections = ( compute_hearing_sections(dfT) if is_hearing else compute_judgment_sections(dfT) ) if not is_hearing and sections: names = [s.get("name") for s in sections if s.get("name")] color_map = generate_section_palette(list(dict.fromkeys(names))) for section in sections: name = section.get("name") if name in color_map: section["color"] = color_map[name] st.markdown("### Heatmap") heatmap_anchor = f"heatmap-anchor-{key}" st.markdown( f"
", unsafe_allow_html=True, ) render_floating_heatmap_button(heatmap_anchor, f"heatmap-btn-{key}") heatmap_chart = make_matrix_style_heatmap( df_heat, bin_size, len(full_text), color=heat_color, background_sections=sections, ) chart_col, legend_col = st.columns([0.92, 0.08]) with chart_col: try: heatmap_event = st.altair_chart( heatmap_chart, use_container_width=True, on_select="rerun", ) except TypeError: heatmap_event = None st.altair_chart(heatmap_chart, use_container_width=True) with legend_col: render_heatmap_legend(heat_color) selected = extract_heatmap_selection(heatmap_event) selection_key = f"heat_sel_{key}" if selected: try: st.session_state[selection_key] = { "label": selected["label"], "bin": int(selected["bin"]), } except (TypeError, ValueError): pass elif heatmap_event is not None: st.session_state.pop(selection_key, None) if is_hearing: st.markdown( "" "
" "
" "Applicant
" "
" "Government
" "
" "Third Party
" "
" "UNK
" "
" "Judge
" "
", unsafe_allow_html=True, ) else: render_section_legend(sections, title="Judgment Sections") render_section_guide(sections, compact_columns=10 if is_hearing else None) # highlighted text preview st.markdown("### Highlighted Text Preview") if preview_ann: annot_color_map = make_annotator_color_map(preview_ann) legend_rows = [] for annot in preview_ann: color = annot_color_map.get(annot, "rgba(0,0,0,0.25)") legend_rows.append( f"
" f"" f"" f"{html.escape(str(annot))}
" ) else: annot_color_map = {} legend_rows = [] dfH = df_ann[ (df_ann["webcast_id"] == webcast) & (df_ann["annotator"].isin(preview_ann)) ] if valid_only: dfH = dfH[dfH["curation"] == "valid"] spans_global = [ (int(r["global_begin"]), int(r["global_end"]), r["annotation_id"]) for _, r in dfH.iterrows() ] ann_id_to_annot = { r["annotation_id"]: r["annotator"] for _, r in dfH.iterrows() } combo_rows = [] if preview_ann and spans_global: intervals = compute_interval_segments(len(full_text), spans_global) seen_combos = set() for _, _, ann_ids in intervals: annotators_in_span = sorted( {ann_id_to_annot.get(a) for a in ann_ids if a in ann_id_to_annot} ) if len(annotators_in_span) <= 1: continue combo_key = tuple(annotators_in_span) if combo_key in seen_combos: continue seen_combos.add(combo_key) bg_layers = ", ".join( f"linear-gradient({annot_color_map[a]} 0 0)" for a in annotators_in_span if a in annot_color_map ) label = " + ".join(html.escape(str(a)) for a in combo_key) combo_rows.append( f"
" f"" f"{label}
" ) color_map = { r["annotation_id"]: annot_color_map.get( r["annotator"], "rgba(0,0,0,0.25)" ) for _, r in dfH.iterrows() } if legend_rows or combo_rows: st.markdown( "" "
" "
Annotators
" + "".join(legend_rows) + ( "
Combinations
" + "".join(combo_rows) if combo_rows else "" ) + "
", unsafe_allow_html=True, ) meta_map = { r["annotation_id"]: { "label": r["label"], "annotator": r["annotator"], "curation": r["curation"] } for _, r in dfH.iterrows() } focus_ann_id = None focus_range_global = None focus_label = None selection_state = st.session_state.get(selection_key) if selection_state and not dfH.empty: sel_label = selection_state.get("label") sel_bin = selection_state.get("bin") if sel_label is not None and sel_bin is not None: bin_start = sel_bin * bin_size bin_end = min(bin_start + bin_size, len(full_text)) focus_range_global = (bin_start, bin_end) focus_label = sel_label focus_ann_id = pick_focus_annotation( dfH, sel_label, bin_start, bin_end ) # hearing preview if is_hearing: rows = dfT.sort_values(["segment_id", "sequence_id"]) html_blocks = [] cursor = 0 for seg_id, seg_rows in rows.groupby("segment_id"): speaker = ( seg_rows.iloc[0].get("speaker_name") or seg_rows.iloc[0].get("speaker_role") or "Unknown" ) pieces = [] seg_start = cursor for _, r in seg_rows.iterrows(): t = r["text"] or "" pieces.append(t) cursor += len(t) + 1 segment_text = " ".join(pieces) seg_end = seg_start + len(segment_text) local_spans = project_spans_to_interval(spans_global, seg_start, seg_end) html_blocks.append(f"{html.escape(str(speaker))}
") focus_local = None if focus_range_global: fr_start, fr_end = focus_range_global if fr_start < seg_end and fr_end > seg_start: focus_local = ( max(fr_start, seg_start) - seg_start, min(fr_end, seg_end) - seg_start, ) html_blocks.append( render_highlighted_html( segment_text, local_spans, color_map, meta_map, focus_ann_id=focus_ann_id, focus_range=focus_local, focus_label=focus_label, ) ) html_blocks.append("
") render_preview_box("".join(html_blocks)) # judgment preview else: rows = dfT.sort_values("paragraph_id") html_blocks = [] cursor = 0 for _, row in rows.iterrows(): ptext = row["text"] or "" seg_start = cursor seg_end = seg_start + len(ptext) local_spans = project_spans_to_interval(spans_global, seg_start, seg_end) cursor = seg_end + 1 focus_local = None if focus_range_global: fr_start, fr_end = focus_range_global if fr_start < seg_end and fr_end > seg_start: focus_local = ( max(fr_start, seg_start) - seg_start, min(fr_end, seg_end) - seg_start, ) html_blocks.append( render_highlighted_html( ptext, local_spans, color_map, meta_map, focus_ann_id=focus_ann_id, focus_range=focus_local, focus_label=focus_label, ) ) html_blocks.append("
\n") render_preview_box("".join(html_blocks)) scroll_to_annotation(focus_ann_id) if go_heatmap: scroll_to_heatmap(heatmap_anchor) # tabs tab1, tab2 = st.tabs(["Hearings", "Judgments"]) with tab1: render_heatmap_tab(df_hear_ann, df_hear_text, "Hearing", "hear", is_hearing=True) with tab2: render_heatmap_tab(df_judg_ann, df_judg_text, "Judgment", "judg", is_hearing=False)