import sys, os.path
import threading
import time
import uuid
from pathlib import Path
from typing import Tuple, Dict, List, Optional
from collections import defaultdict
import hashlib
import json
import math
import re as _re
import html as _html
import torch
import numpy as np
import gradio as gr
import pandas as pd
import ast
from tqdm import tqdm
# ZeroGPU support for HuggingFace Spaces
try:
import spaces
_gpu = spaces.GPU
except ImportError:
_gpu = lambda f: f # no-op when not on HF Spaces
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../')))
BASE_DIR = Path(__file__).resolve().parent.parent
# Controls how aggressively agreement colors are amplified.
# Lower = more vivid colors (0.2 = very strong, 1.0 = no amplification).
# Asymmetric: unique/red (positive) is amplified less than common/blue (negative)
# to avoid overwhelming red when most sentences are unique.
# Module-level storage for background thread state (avoids pickling issues with ZeroGPU)
_thread_states = {}
AGREEMENT_AMP_UNIQUE = 1.0 # exponent for positive scores (red = unique)
AGREEMENT_AMP_COMMON = 1.0 # exponent for negative scores (blue = common)
MAX_PREPROCESSED_REVIEWS = 10 # Number of review/agreement/rebuttal slots in pre-processed tab
LISTENER_CONCENTRATION_THRESHOLD = 0.70 # Above this, listener "wins" over uniqueness score
INFORMATIVENESS_MULTIPLIER = 2.0 # Multiplied by uniform baseline (1/K) for informativeness threshold
def _make_sentence_id(sentence: str) -> str:
"""Deterministic DOM ID for a sentence, used by click-to-scroll."""
return "sent_" + hashlib.md5(sentence.encode("utf-8")).hexdigest()[:12]
def _click_to_scroll_js(sent_id: str, color: str = "#3b82f6") -> str:
"""Return inline onclick JS for smooth-scroll + outline flash."""
return (
f"(function(){{var el=document.getElementById('{sent_id}');"
f"if(el){{el.scrollIntoView({{behavior:'smooth',block:'center'}});"
f"el.style.outline='3px solid {color}';"
f"setTimeout(function(){{el.style.outline='';}},2500);}}}})();"
)
def _source_badges_html(sent: str, sentence_lists: list) -> str:
"""Return R# badge HTML for all reviews containing the sentence."""
source = [r_idx + 1 for r_idx, sl in enumerate(sentence_lists) if sent in sl]
return " ".join(
f'R{n}'
for n in source
)
def _listener_dist_bars(sent: str, listener: dict, source_badges: str,
badge_fg: str = "#1e40af") -> str:
"""Render L_t(d|s) distribution bars or plain badge row."""
if listener and sent in listener:
dist = listener[sent]
bar_parts = []
for label, prob in sorted(dist.items()):
pct = int(round(prob * 100))
bar_w = max(2, int(prob * 80))
bar_parts.append(
f''
f'{label}'
f''
f'{pct}%'
f''
)
return (
f'
'
def _get_context(sentence: str, sentence_lists: list):
"""Return (context_before, context_after) strings for the first review containing sentence."""
for sl in sentence_lists:
if sentence in sl:
idx = sl.index(sentence)
before = _html.escape(sl[idx - 1]) if idx > 0 else ""
after = _html.escape(sl[idx + 1]) if idx < len(sl) - 1 else ""
return before, after
return "", ""
_TOGGLE_BTN_STYLE = (
'background:none;border:1px solid #d1d5db;border-radius:6px;padding:4px 12px;'
'font-size:0.78em;color:#6b7280;cursor:pointer;white-space:nowrap;'
'line-height:1;height:28px;box-sizing:border-box;vertical-align:middle;'
'display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;'
)
def _toggle_html(selector: str, text_when_all_open: str,
text_when_not_all_open: str, initial_label: str) -> str:
"""Generate a toggle button for expanding/collapsing details elements."""
return (
''
)
def _rebuttal_toggle_html() -> str:
"""Generate an Expand/Collapse All Responses toggle button with inline JS."""
return _toggle_html("details:not(.review-collapse)",
"Expand All Responses", "Collapse All Responses",
"Expand All Responses")
def _review_toggle_html() -> str:
"""Generate a Collapse/Expand All Reviews toggle button with inline JS."""
return _toggle_html("details.review-collapse",
"Expand All Reviews", "Collapse All Reviews",
"Collapse All Reviews")
def _jump_buttons_html(active_count: int, prefix: str = "int") -> str:
"""Generate jump-to buttons [R1] [R2] ... that scroll to each review.
prefix: 'int' for interactive tab, 'pre' for pre-processed tab."""
buttons = []
for i in range(1, active_count + 1):
anchor_id = f"{prefix}-review-anchor-{i}"
js = (
f"(function(){{var el=document.getElementById('{anchor_id}');"
f"if(el)el.scrollIntoView({{behavior:'smooth',block:'start'}});"
f"}})()"
)
buttons.append(
f''
)
return "".join(buttons)
def _should_break_before(sent: str) -> bool:
"""Detect if a paragraph break should be inserted before this sentence."""
s = sent.strip()
# Numbered items: 1), 2., (1), 1:, etc.
if _re.match(r'^[\(\[]?\d+[\)\]\.:]', s):
return True
# Dash/bullet items
if len(s) > 2 and s[0] in ('-', '•', '*', '–', '—') and s[1] == ' ':
return True
# Markdown separators / headers
if s.startswith('##') or s.startswith('---'):
return True
# Common review section headers
if _re.match(
r'^\*{0,2}(Rating|Strengths?|Weaknesses?|Questions?|Limitations?|Summary|'
r'Soundness|Presentation|Contribution|Confidence|Experience|Review Assessment|'
r'Recommendation|Overall|Minor|Major|Typos?|Suggestions?|Comments?|'
r'Detailed\s+Comments?|Pros?|Cons?|Flag|Clarity|Significance|Originality)',
s, _re.IGNORECASE,
):
return True
return False
def _is_review_header(sent: str) -> bool:
"""Detect if a sentence is a review metadata header (Rating:, Experience:, etc.)."""
return bool(_re.match(
r'^\*{0,2}(Rating|Confidence|Experience|Review Assessment|Recommendation|Flag)\b',
sent.strip(), _re.IGNORECASE,
))
# ---- Polarity / Topic color maps for HTML rendering ----
_POLARITY_COLORS = {
2: "#d4fcd6", 0: "#fcd6d6", # integer keys (pre-processed tab)
"➕": "#d4fcd6", "➖": "#fcd6d6", # emoji keys (interactive tab)
} # positive=green, negative=red
_TOPIC_HTML_COLORS = {
"Substance": "#b3e5fc",
"Clarity": "#c8e6c9",
"Soundness/Correctness": "#fff9c4",
"Originality": "#f8bbd0",
"Motivation/Impact": "#d1c4e9",
"Meaningful Comparison": "#ffe0b2",
"Replicability": "#b2dfdb",
}
def _wrap_review_card(label: str, inner_html: str, collapsible: bool = True) -> str:
"""Wrap review content in a styled card with gray header. Single source of truth for review card styling."""
escaped = _html.escape(label) if label else ""
if collapsible:
return (
f''
f''
f'▶ '
f'{escaped}'
f'
{inner_html}
'
f''
)
else:
if not label:
return inner_html
return (
f'
'
f'
{escaped}
'
f'
{inner_html}
'
f'
'
)
def render_review_html(
review_items: list,
mode: str = "plain",
label: str = "Review",
wrap: bool = False,
) -> str:
"""
Render a review as HTML with proper paragraph formatting.
Args:
review_items: list of (sentence, metadata_dict) tuples
mode: "plain", "polarity", or "topic"
label: header label
wrap: if False, return bare content (caller handles outer wrapper)
"""
if not review_items:
return ""
parts = []
parts.append('
')
for i, (sent, metadata) in enumerate(review_items):
# Paragraph break detection
if i > 0 and _should_break_before(sent):
parts.append(' ')
# Header styling (Rating:, Experience:, etc.)
is_header = _is_review_header(sent)
bg = ""
label_text = ""
if mode == "polarity":
polarity = metadata.get("polarity")
if polarity in _POLARITY_COLORS:
bg = f"background:{_POLARITY_COLORS[polarity]};"
elif mode == "topic":
topic = metadata.get("topic")
if topic and topic != "NONE" and topic in _TOPIC_HTML_COLORS:
bg = f"background:{_TOPIC_HTML_COLORS[topic]};"
label_text = topic
style = f"padding:1px 3px;border-radius:3px;{bg}"
if is_header:
style += "font-weight:600;color:#92400e;"
sent_id = _make_sentence_id(sent)
escaped = _html.escape(sent)
if label_text:
# Show topic label as a small tag
parts.append(
f''
f'{escaped} '
)
else:
parts.append(f'{escaped} ')
parts.append('
')
content = "".join(parts)
if wrap:
return _wrap_review_card(label, content, collapsible=True)
elif label:
return _wrap_review_card(label, content, collapsible=False)
return content
def format_summary_cards(
sentences: list,
scores: dict,
sentence_lists: list,
card_type: str = "common",
listener: dict = None,
speaker: dict = None,
) -> str:
"""
Most Common Opinions hub.
Selection: When listener/speaker data is available, re-ranks candidates by
informativeness × (1 − normalized_uniqueness) so substantive agreements
surface above generic filler. Falls back to the raw score order when the
full RSA data is not available (pre-processed tab).
Each card shows:
- L_t(d|s) distribution bars (R1 40% · R2 45% · R3 15%) when data is available
- Context snippet (1 sentence before / after)
- Clickable to scroll to the sentence in the full review below
"""
if not sentences:
return ""
border_color = "#93c5fd"
badge_bg = "#dbeafe"
badge_fg = "#1e40af"
# Pre-compute expected listener share per reviewer from review lengths.
# Used for bar chart normalization (bar width = deviation from expected, not raw prob).
num_reviews = len(sentence_lists)
total_sents = sum(len(sl) for sl in sentence_lists) or 1
expected_share = {
f"R{i+1}": len(sl) / total_sents
for i, sl in enumerate(sentence_lists)
}
# Render in the order given — selection and filtering happens in compute_rsa_in_background.
ctx_style = "color:#b0b0b0;font-size:0.85em;font-style:italic;"
cards_parts = []
for sent in sentences:
sent_id = _make_sentence_id(sent)
context_before, context_after = _get_context(sent, sentence_lists)
# --- Source badge: which review(s) this sentence physically appears in ---
source_badge = _source_badges_html(sent, sentence_lists)
# --- L_t(d|s) distribution bars ---
dist_html = _listener_dist_bars(sent, listener, source_badge, badge_fg=badge_fg)
onclick = _click_to_scroll_js(sent_id)
# Inline context: ...before SENTENCE after... (all one line)
before_span = f'...{_html.escape(context_before)} ' if context_before else ""
after_span = f' {_html.escape(context_after)}...' if context_after else ""
cards_parts.append(
f'
'
f'{dist_html}'
f'
'
f'{before_span}'
f'{_html.escape(sent)}'
f'{after_span}'
f'
'
f'
'
)
# Wrap in collapsible (open by default)
inner = "".join(cards_parts)
return (
f''
f''
f'▸Most Common Opinions'
f'{inner}'
f''
)
def _normalize_polarity(val) -> Optional[str]:
"""Normalize polarity from any format to 'positive'/'negative'/None."""
if val == "➕" or val == 2 or val == "positive":
return "positive"
if val == "➖" or val == 0 or val == "negative":
return "negative"
return None # neutral or unknown
def format_common_themes(
sentence_lists: list,
polarity_map: dict,
topic_map: dict,
speaker: dict = None,
uniqueness: dict = None,
listener: dict = None,
) -> str:
"""
Common Themes Across Reviews — groups sentences by topic, then shows
polarity breakdown within each topic.
A topic is "common" if sentences from ≥2 different reviewers share it.
Each topic card shows a polarity percentage bar and representative
sentences per reviewer under each polarity sub-group.
Falls back to RSA-based generic sentences if no common themes are found.
"""
num_reviews = len(sentence_lists)
if num_reviews < 2:
return ""
# --- Step 1: Build per-sentence (topic, polarity) index ---
# topic_data[topic][polarity][r_idx] = [sentences]
topic_data = defaultdict(lambda: defaultdict(lambda: defaultdict(list)))
for r_idx, sl in enumerate(sentence_lists):
for sent in sl:
if _should_break_before(sent) or _is_review_header(sent):
continue
if len(sent.split()) < 5:
continue
topic = topic_map.get(sent)
polarity = _normalize_polarity(polarity_map.get(sent))
if topic is None and polarity is None:
continue
topic_key = topic if topic else "Other"
polarity_key = polarity if polarity else "neutral"
topic_data[topic_key][polarity_key][r_idx].append(sent)
# --- Step 2: Filter to topics with ≥2 unique reviewers ---
common_topics = []
for topic, pol_dict in topic_data.items():
all_reviewers = set()
total_sents = 0
for pol, rev_dict in pol_dict.items():
all_reviewers.update(rev_dict.keys())
total_sents += sum(len(s) for s in rev_dict.values())
if len(all_reviewers) < 2:
continue
common_topics.append((topic, len(all_reviewers), total_sents, pol_dict))
# Prefer topics that have non-neutral polarity entries
has_sentiment = [t for t in common_topics
if any(p in t[3] for p in ("positive", "negative"))]
if len(has_sentiment) >= 1:
common_topics = has_sentiment
# Rank by reviewer count (desc), then sentence count (desc); push "Other" to bottom
common_topics.sort(key=lambda t: (0 if t[0] == "Other" else 1, t[1], t[2]), reverse=True)
# --- Fallback: Generic Sentences ---
if not common_topics:
if not uniqueness:
return ""
scores_series = pd.Series(uniqueness)
n_seed = min(5, len(scores_series))
fallback = scores_series.nsmallest(n_seed).index.tolist()
if not fallback:
return ""
parts = [
'',
''
'▼'
'Generic Sentences',
'
',
'
'
'No shared themes detected across reviewers. Showing sentences with lowest '
'RSA uniqueness score (most generic across reviews).
',
]
for sent in fallback:
sent_id = _make_sentence_id(sent)
badges = _source_badges_html(sent, sentence_lists)
dist_html = _listener_dist_bars(sent, listener, badges)
onclick = _click_to_scroll_js(sent_id)
parts.append(
f'
'
f'{dist_html}'
f'{_html.escape(sent)}'
f'
'
)
parts.append('
')
return "".join(parts)
# --- Step 3: Render topic cards with polarity breakdown ---
_pol_colors = {"negative": "#ef4444", "positive": "#22c55e", "neutral": "#9ca3af"}
_pol_labels = {"negative": "Negative", "positive": "Positive", "neutral": "Neutral"}
# Render order: negative first (concerns), then positive, then neutral
_pol_order = ["negative", "positive", "neutral"]
def _pick_best(sents, r_idx):
"""Pick best sentence by speaker score for this reviewer."""
r_label = f"R{r_idx + 1}"
if speaker and r_label in speaker:
sp = speaker[r_label]
scored = [(s, sp.get(s, 0.0)) for s in sents]
scored.sort(key=lambda x: x[1], reverse=True)
return scored[0][0]
return sents[0]
def _sent_row(sent, r_idx):
"""Render a single sentence row with R# badge and click-to-scroll."""
r_label = f"R{r_idx + 1}"
sent_id = _make_sentence_id(sent)
onclick = _click_to_scroll_js(sent_id)
return (
f'
'
f'{r_label}'
f'{_html.escape(sent)}'
f'
'
)
cards = []
for topic, n_reviewers, total_sents, pol_dict in common_topics:
border_color = _TOPIC_HTML_COLORS.get(topic, "#d1d5db")
reviewer_text = (
f"All {num_reviews} reviewers" if n_reviewers == num_reviews
else f"{n_reviewers} of {num_reviews} reviewers"
)
# --- Polarity percentage bar ---
pol_counts = {}
for pol in _pol_order:
if pol in pol_dict:
pol_counts[pol] = sum(len(s) for s in pol_dict[pol].values())
total = sum(pol_counts.values()) or 1
# Inline polarity bar + labels (compact, on one line)
_pol_colors_soft = {"negative": "#f87171", "positive": "#4ade80", "neutral": "#d1d5db"}
bar_segments = []
bar_labels = []
for pol in _pol_order:
cnt = pol_counts.get(pol, 0)
if cnt == 0:
continue
pct = round(cnt / total * 100)
color = _pol_colors_soft[pol]
bar_segments.append(
f''
)
bar_labels.append(
f''
f'{pct}% {_pol_labels[pol]}'
)
# Skip polarity bar for "Other" — unclassified sentences aren't necessarily related
if topic == "Other":
polarity_bar = ""
else:
polarity_bar = (
f'
'
f'
'
+ "".join(bar_segments)
+ '
'
+ " ".join(bar_labels)
+ '
'
)
# --- Header (polarity bar inline after reviewer count) ---
header = (
f'
'
f'{_html.escape(topic)}'
f'·'
f'{reviewer_text}'
f'{polarity_bar}'
f'
'
)
# --- Polarity sub-groups ---
sub_groups_html = []
for pol in _pol_order:
if pol not in pol_dict:
continue
rev_dict = pol_dict[pol]
cnt = pol_counts.get(pol, 0)
color = _pol_colors[pol]
sub_header = (
f'
'
f'{_pol_labels[pol]} ({cnt} sentence{"s" if cnt != 1 else ""})
'
)
rows = []
for r_idx in sorted(rev_dict.keys()):
best = _pick_best(rev_dict[r_idx], r_idx)
rows.append(_sent_row(best, r_idx))
sub_groups_html.append(sub_header + "".join(rows))
cards.append(
f'
'
f'{header}{"".join(sub_groups_html)}'
f'
'
)
inner = "".join(cards)
return (
f''
f''
f'▶'
f'Common Themes Across Reviews'
f'
{inner}
'
f''
)
def format_divergent_cards(
uniqueness: dict,
sentence_lists: list,
listener: dict,
speaker: dict,
) -> Dict[int, str]:
"""
Most Divergent Opinions — returns per-review HTML dict {review_index: html}.
For each review, finds the sentences where argmax(L_t(d|s)) points to that
review (i.e., the listener assigns it most strongly to that reviewer) AND
the uniqueness score is above the median. Ranks within each reviewer's set
by their Speaker score S_t(s|d) (how characteristic of that reviewer).
Shows the top 2 per reviewer.
"""
if not uniqueness or not listener or not speaker:
return {}
num_reviews = len(speaker)
if num_reviews == 0:
return {}
median_u = float(np.median(list(uniqueness.values())))
review_labels = [f"R{i+1}" for i in range(num_reviews)]
# Minimum speaker score to suppress generic filler.
k = max(sum(len(v) for v in speaker.values()) // max(len(speaker), 1), 1)
min_speaker_score = INFORMATIVENESS_MULTIPLIER / k
# Group sentences by their argmax review
grouped: dict = {label: [] for label in review_labels}
for sent, u_score in uniqueness.items():
if u_score <= median_u:
continue
if sent not in listener:
continue
dist = listener[sent]
if not dist:
continue
argmax_label = max(dist, key=lambda k: dist[k])
if argmax_label not in grouped:
continue
s_score = speaker.get(argmax_label, {}).get(sent, 0.0)
if s_score < min_speaker_score:
continue
grouped[argmax_label].append((sent, u_score, s_score))
# Sort each group by speaker score descending and take top 2
for label in review_labels:
grouped[label].sort(key=lambda x: x[2], reverse=True)
grouped[label] = grouped[label][:2]
border_color = "#fca5a5"
result: Dict[int, str] = {}
for i, label in enumerate(review_labels):
items = grouped[label]
if not items:
continue
ctx_style = "color:#b0b0b0;font-size:0.85em;font-style:italic;"
html_parts = [
'
'
f'Unique Points in This Review
'
]
for sent, u_score, s_score in items:
sent_id = _make_sentence_id(sent)
context_before, context_after = _get_context(sent, sentence_lists)
dom_pct = 0
if sent in listener:
dom_pct = int(round(max(listener[sent].values(), default=0.0) * 100))
uniqueness_badge = (
f''
f'{dom_pct}% listener share'
)
onclick = _click_to_scroll_js(sent_id, "#ef4444")
before_span = f'...{_html.escape(context_before)} ' if context_before else ""
after_span = f' {_html.escape(context_after)}...' if context_after else ""
html_parts.append(
f'
'
f'{uniqueness_badge}'
f'
'
f'{before_span}'
f'{_html.escape(sent)}'
f'{after_span}'
f'
'
f'
'
)
result[i] = "".join(html_parts)
return result
def render_agreement_html(
sentences: List[str],
uniqueness: Dict[str, float],
listener: Dict[str, Dict[str, float]],
speaker: Dict[str, Dict[str, float]],
num_reviews: int,
label: str = "Agreement",
wrap: bool = False,
) -> str:
"""
Custom HTML renderer for Agreement mode (replaces gr.HighlightedText).
Each sentence gets:
- Continuous opacity from score magnitude (strongest opinions most vivid)
- CSS hover tooltip showing L_t(d|s): "R1 (40%) · R2 (45%)"
- Informativeness dimming for generic common filler
- Sentence ID for click-to-scroll from summary cards
"""
if not sentences:
return ""
# Color scale legend bar
legend_html = (
'
'
''
'← Common | Unique →'
'
'
)
parts = []
parts.append(legend_html)
parts.append('
')
# Compute informativeness threshold: 2 / K (twice uniform baseline)
k = max(len(uniqueness), 1)
info_threshold = INFORMATIVENESS_MULTIPLIER / k
for idx, sent in enumerate(sentences):
# Paragraph break detection
if idx > 0 and _should_break_before(sent):
parts.append(' ')
sent_id = _make_sentence_id(sent)
score = uniqueness.get(sent)
# Header styling (Rating:, Experience:, etc.)
header_style = "font-weight:600;color:#92400e;" if _is_review_header(sent) else ""
if score is None or abs(score) < HIGHLIGHT_THRESHOLD:
# No highlight
parts.append(f'{_html.escape(sent)} ')
continue
# --- Color and opacity ---
if score < 0:
# Common: blue — opacity from listener ENTROPY so more balanced = more vivid.
r, g, b = 59, 130, 246
if listener and sent in listener:
dist = listener[sent]
max_prob = max(dist.values(), default=0.0)
max_entropy = math.log(max(num_reviews, 2))
entropy = sum(-p * math.log(p) for p in dist.values() if p > 0)
# If listener is highly concentrated on one reviewer (>70%), the RSA
# uniqueness score and listener disagree — trust the listener and
# suppress blue. This prevents e.g. R2 91% sentences from appearing blue.
if max_prob > LISTENER_CONCENTRATION_THRESHOLD:
opacity = 0.0
else:
opacity = (entropy / max_entropy) ** AGREEMENT_AMP_COMMON if max_entropy > 0 else 0.0
else:
opacity = abs(score) ** AGREEMENT_AMP_COMMON
# Informativeness dimming for generic filler
if speaker:
info = compute_informativeness(sent, speaker, num_reviews)
if info < info_threshold:
opacity *= 0.3
else:
# Unique: red — opacity from score magnitude (as before)
r, g, b = 239, 68, 68
opacity = abs(score) ** AGREEMENT_AMP_UNIQUE
bg_color = f"rgba({r},{g},{b},{opacity:.3f})"
# --- Tooltip content from L_t(d|s) ---
tooltip_text = ""
if listener and sent in listener:
dist = listener[sent]
parts_tooltip = " · ".join(
f"{lbl} {int(round(p * 100))}%"
for lbl, p in sorted(dist.items())
)
tooltip_text = f"{parts_tooltip}"
else:
tooltip_text = f"Score: {score:+.2f}"
# Inline JS positions the tooltip near the cursor using fixed positioning
hover_js = (
"var t=this.querySelector('.rsa-tooltip');var r=this.getBoundingClientRect();"
"t.style.display='block';"
"t.style.left=Math.min(r.left,window.innerWidth-290)+'px';"
"t.style.top=(r.top-t.offsetHeight-6)+'px';"
)
leave_js = "this.querySelector('.rsa-tooltip').style.display='none';"
parts.append(
f''
f'{_html.escape(sent)} '
f'{_html.escape(tooltip_text)}'
f''
)
parts.append("
") # close sentence container
content = "".join(parts)
if wrap:
return _wrap_review_card(label, content, collapsible=True)
elif label:
return _wrap_review_card(label, content, collapsible=False)
return content
def build_review_card(
label: str,
*,
review_items: list = None,
mode: str = "plain",
sentences: List[str] = None,
uniqueness: Dict = None,
listener: dict = None,
speaker: dict = None,
num_reviews: int = 0,
divergent_html: str = "",
rebuttal_html: str = "",
collapsible: bool = True,
) -> str:
"""Unified review card builder — single entry point for both tabs.
For plain/polarity/topic: pass review_items + mode.
For agreement: pass sentences + RSA dicts (uniqueness, listener, speaker, num_reviews).
Optional divergent_html and rebuttal_html are appended inside the card.
"""
if sentences is not None:
inner = render_agreement_html(
sentences, uniqueness or {}, listener, speaker,
num_reviews=num_reviews, label="",
)
elif review_items is not None:
inner = render_review_html(review_items, mode=mode, label="")
else:
inner = ""
return _wrap_review_card(label, f"{inner}{divergent_html}{rebuttal_html}", collapsible=collapsible)
# Auto-detect the preprocessed dataset CSV
def _find_preprocessed_csv() -> Path:
"""Find the most recent preprocessed_scored_reviews_*.csv in the data dir."""
data_dir = BASE_DIR / "data"
candidates = sorted(data_dir.glob("preprocessed_scored_reviews_*.csv"))
if candidates:
return candidates[-1] # Last alphabetically = latest year range
return data_dir / "preprocessed_scored_reviews.csv"
def load_scored_reviews_with_rebuttals(csv_path: Path = None):
"""Load dataset with rebuttal metadata. Auto-detects CSV if no path given."""
if csv_path is None:
csv_path = _find_preprocessed_csv()
if not csv_path.exists():
return [], pd.DataFrame()
df = pd.read_csv(csv_path)
tqdm.pandas(desc="Parsing scored_dict")
df["scored_dict"] = df["scored_dict"].progress_apply(ast.literal_eval)
# Parse metadata column
if "metadata" in df.columns:
df["metadata"] = df["metadata"].progress_apply(
lambda x: ast.literal_eval(x) if pd.notna(x) and x != '{}' else {}
)
else:
df["metadata"] = [{}] * len(df)
years = df["year"].tolist()
return years, df
years_new, df_new = load_scored_reviews_with_rebuttals()
if df_new.empty:
raise FileNotFoundError(
f"No preprocessed dataset found. Run the pipeline first (./pipeline/process_new_data.sh)."
)
# Use new data only
years, all_scored_reviews_df = years_new, df_new
# Build a {forum_url: paper_title} lookup from raw data CSVs (processed CSVs lack paper_title)
def _load_paper_titles() -> dict:
titles = {}
for csv in sorted((BASE_DIR / "data").glob("all_reviews_*.csv")):
try:
df = pd.read_csv(csv, usecols=["id", "paper_title"])
for _, row in df.iterrows():
if row["id"] not in titles and pd.notna(row.get("paper_title", "")):
titles[row["id"]] = str(row["paper_title"])
except Exception:
pass
return titles
_paper_titles = _load_paper_titles()
year_range_str = f"{min(years)}–{max(years)}" if years else "N/A"
# -----------------------------------
# Pre-processed Tab
# -----------------------------------
def get_preprocessed_scores(year):
scored_reviews = all_scored_reviews_df[all_scored_reviews_df["year"] == year]["scored_dict"].iloc[0]
return scored_reviews
def get_preprocessed_metadata(year):
row = all_scored_reviews_df[all_scored_reviews_df["year"] == year]
if "metadata" in row.columns and not row.empty:
meta = row["metadata"].iloc[0]
return meta if isinstance(meta, dict) else {}
return {}
# -----------------------------------
# Interactive Tab Configuration
# -----------------------------------
# Define the manual color map for topics
topic_color_map = {
"Substance": "#cce0ff", # lighter blue
"Clarity": "#e6ee9c", # lighter yellow-green
"Soundness/Correctness": "#ffcccc", # lighter red
"Originality": "#d1c4e9", # lighter purple
"Motivation/Impact": "#b2ebf2", # lighter teal
"Meaningful Comparison": "#fff9c4", # lighter yellow
"Replicability": "#c8e6c9", # lighter green
}
# GLIMPSE Home/Description Page
glimpse_description = f"""
# ReView: A Tool for Visualizing and Analyzing Scientific Reviews
## **Overview**
ReView is a visualization tool designed to assist **area chairs** and **researchers** in efficiently analyzing scholarly reviews. The interface offers two main ways to explore scholarly reviews:
- Pre-Processed Reviews: Explore real peer reviews from ICLR ({year_range_str}) with structured visualizations of sentiment, topics, and reviewer agreement.
- Interactive Tab: Enter your own reviews and view them analyzed in real time using the same NLP-powered highlighting options.
All reviews are shown in their original, unaltered form, with visual overlays to help identify key insights such as disagreements, sentiment and common themes—reducing cognitive load and scrolling effort.
---
## **Key Features**
- *Traceability and Transparency:* The tool preserves the original text of each review and overlays highlights for key aspects (e.g., sentiment, topic, agreement), allowing area chairs to trace back every insight to its source without modifying or summarizing the content.
- *Structured Overview*: All reviews are displayed in one interface and with radio buttons, one can navigate from one highlighting option to the other.
- *Interactive*: The tool allows users to input their own reviews and, within seconds, view them annotated with highlighted aspects
---
## **Highlighting Options**
- *Agreement:* Identifies both shared and conflicting points across reviews, helping to surface consensus and disagreement.
- *Polarity:* Highlights positive and negative sentiments within the reviews to reveal tone and stance.
- *Topic:* Organizes the review sentences by their discussed topics, ensuring coverage of diverse reviewer perspectives and improving clarity.
---
### How to Use ReView
ReView offers two main ways to explore peer reviews: using pre-processed reviews or by entering your own.
#### Pre-Processed Reviews Tab
Use this tab to explore reviews from ICLR ({year_range_str}):
1. **Select a conference year** from the dropdown menu on the right.
2. **Navigate between submissions** using the *Next* and *Previous* buttons on the left.
3. **Choose a highlighting view** using the radio buttons:
- **Original**: Displays unmodified review text.
- **Agreement**: Highlights consensus points in **red** and disagreements in **purple**.
- **Polarity**: Highlights **positive** sentiment in **green** and **negative** sentiment in **red**.
- **Topic**: Highlights comments by discussion topic using color-coded labels.
#### Interactive Tab
Use this tab to analyze your own review text:
1. **Enter 2–6 reviews** in the input fields. Use the **➕ Add Review** button to add up to 6 reviews.
2. **Click "Process"** to analyze the input (average processing time: ~42 seconds).
3. **Explore the results** using the same highlighting options as above (Agreement, Polarity, Topic).
"""
EXAMPLES = [
"The paper gives really interesting insights on the topic of transfer learning. It is well presented and the experiment are extensive. I believe the authors missed Jane and al 2021. In addition, I think, there is a mistake in the math.",
"The paper gives really interesting insights on the topic of transfer learning. It is well presented and the experiment are extensive. Some parts remain really unclear and I would like to see a more detailed explanation of the proposed method.",
"The paper gives really interesting insights on the topic of transfer learning. It is not well presented and lack experiments. Some parts remain really unclear and I would like to see a more detailed explanation of the proposed method.",
]
PROCESSING_TIMER_HTML = """
'''
# ===== INTERACTIVE TAB: GLOBAL PROCESSOR INITIALIZATION =====
# Initialize once at module load to avoid reloading models
from interface.interactive_processor import InteractiveReviewProcessor
from dependencies.sentence_filter import (
is_noise_sentence, filter_and_clean_sentences, strip_header_prefix,
HIGHLIGHT_THRESHOLD, compute_informativeness,
)
_interactive_processor = None
def get_interactive_processor():
"""Lazy-load the processor to avoid duplicate model loading."""
global _interactive_processor
if _interactive_processor is None:
device = "cuda" if torch.cuda.is_available() else "cpu"
_interactive_processor = InteractiveReviewProcessor(device=device)
return _interactive_processor
@_gpu
def _gpu_predict_polarity_topic(sentences: List[str]) -> Tuple[Dict, Dict]:
"""Run polarity + topic inference on GPU. Decorated with @spaces.GPU for ZeroGPU."""
processor = get_interactive_processor()
processor.ensure_device()
polarity_map = processor.predict_polarity(sentences)
topic_map = processor.predict_topic(sentences)
return polarity_map, topic_map
@_gpu
def _gpu_predict_rsa(active_texts: List[str], progress_callback=None) -> Dict:
"""Run RSA inference on GPU. Decorated with @spaces.GPU for ZeroGPU."""
processor = get_interactive_processor()
processor.ensure_device()
return processor.predict_rsa_full(*active_texts, progress_callback=progress_callback)
MAX_INTERACTIVE_REVIEWS = 6
def fetch_openreview_reviews(link: str):
"""
Fetch reviews from OpenReview link and populate the textboxes.
Returns:
Tuple of (review1..6, title, status_html)
"""
print(f"\n[DEMO] fetch_openreview_reviews called with link: {link}")
if not link.strip():
raise gr.Error("Please paste a valid OpenReview link before fetching.")
try:
from interface.interactive_processor import fetch_reviews_from_openreview_link
reviews, title, rebuttal = fetch_reviews_from_openreview_link(link)
print(f"[DEMO] Got {len(reviews)} reviews from fetch function")
while len(reviews) < MAX_INTERACTIVE_REVIEWS:
reviews.append("")
reviews = reviews[:MAX_INTERACTIVE_REVIEWS]
num_reviews = len([r for r in reviews if r.strip()])
status = _status_html(f"Fetched {num_reviews} reviews for: {title}", "success")
return (*reviews, title, rebuttal, status)
except gr.Error:
raise
except ValueError as e:
raise gr.Error(str(e))
except Exception as e:
error_msg = str(e)
print(f"[DEMO] Exception caught: {type(e).__name__}: {error_msg}")
if "openreview" in error_msg.lower():
suggestion = " Try: pip install openreview-py"
elif "connection" in error_msg.lower() or "timeout" in error_msg.lower():
suggestion = " Check your internet connection."
else:
suggestion = ""
raise gr.Error(f"{error_msg}{suggestion}")
def _parse_rebuttal_json(rebuttal: str) -> Optional[list]:
"""Parse rebuttal JSON string, returning list of items or None."""
if not rebuttal or not rebuttal.strip():
return None
try:
items = json.loads(rebuttal)
return items if items else None
except (json.JSONDecodeError, TypeError, AttributeError):
return None
_REBUTTAL_PER_REVIEW_STYLE = (
"margin-top:8px;margin-bottom:12px;border-radius:6px;overflow:hidden;"
"border:1px solid #fde68a;background:#fffef5;"
)
_REBUTTAL_GENERAL_STYLE = (
"margin-top:16px;border-radius:8px;overflow:hidden;"
"border:1px solid #fde68a;"
)
def format_rebuttal_plain(text: str) -> str:
"""Format a plain text rebuttal as collapsible HTML.
For pre-processed data where each review has its own rebuttal string."""
if not text or not text.strip():
return ""
return (
f''
''
'▶ Author Response'
'
'
f'
{text}
'
'
'
)
def format_rebuttal_for_review(rebuttal: str, review_num: int) -> str:
"""Format rebuttals that reply to a specific review number."""
if not rebuttal or not rebuttal.strip():
return ""
items = _parse_rebuttal_json(rebuttal)
if items is not None:
# Filter to only rebuttals for this review
relevant = [item for item in items if item.get("reply_to") == review_num]
if not relevant:
return ""
response_parts = []
for i, item in enumerate(relevant):
text = item.get("text", "").strip()
if not text:
continue
response_parts.append(
f'
'
f'
{text}
'
f'
'
)
if not response_parts:
return ""
return (
f''
f''
f'▶ Author Response'
+ "".join(response_parts)
+ ""
)
# Plain text - show under first review only
if review_num == 1:
text = rebuttal.strip()
return (
f''
f''
f'▶ Author Response'
f'
'
f'
{text}
'
f'
'
)
return ""
def format_general_rebuttals(rebuttal: str) -> str:
"""Format general rebuttals (those not replying to a specific review)."""
if not rebuttal or not rebuttal.strip():
return ""
HEADER_STYLE = "background:#fffbeb;padding:10px 16px;border-bottom:1px solid #fde68a;display:flex;align-items:center;gap:8px;"
TITLE_STYLE = "font-weight:600;color:#92400e;"
items = _parse_rebuttal_json(rebuttal)
if items is not None:
# Filter to only general rebuttals (no specific reply_to)
general = [item for item in items if item.get("reply_to") is None]
if not general:
return ""
response_parts = []
for i, item in enumerate(general):
text = item.get("text", "").strip()
if not text:
continue
bg = "white" if i % 2 == 0 else "#fafafa"
sep = "border-top:1px solid #fde68a;" if i > 0 else ""
response_parts.append(
f'
'
f'
{text}
'
f'
'
)
if not response_parts:
return ""
count_label = f"{len(response_parts)} general response{'s' if len(response_parts) > 1 else ''}"
return (
f''
f''
f'💬'
f'General Author Response'
f'{count_label}'
f''
+ "".join(response_parts) +
''
)
# Plain text - treat as general response
text = rebuttal.strip()
return (
f''
f''
f'💬'
f'General Author Response'
f'
'
f'
{text}
'
f'
'
)
def process_interactive_reviews_fast(text1: str, text2: str, text3: str, text4: str, text5: str, text6: str, focus: str, rebuttal_str: str = "", thread_state=None, progress=gr.Progress()) -> Tuple:
"""
Fast processing: Polarity + Topic only (~3-5 sec on CPU).
RSA (agreement) runs in background.
If thread_state is provided, polarity+topic was already started during page transition —
just wait for it instead of re-computing.
"""
import time as _time
from dependencies.Glimpse_tokenizer import glimpse_tokenizer
t_start = _time.time()
# Check if polarity+topic was already started in background by _show_raw_and_switch
# thread_state is a string key into _thread_states dict (avoids pickling issues with ZeroGPU)
_bg_state = _thread_states.pop(thread_state, None) if isinstance(thread_state, str) else None
if _bg_state and _bg_state.get("thread"):
bg_thread = _bg_state["thread"]
_result = _bg_state["result"]
sentence_lists = _bg_state["sentence_lists"]
active_texts = _bg_state["active_texts"]
all_sentences = _bg_state["all_sentences"]
progress(0.30, desc="Predicting polarity and topics...")
# Wait for the background thread (may already be done!)
bg_thread.join()
if _result.get("error"):
raise _result["error"]
polarity_map = _result["polarity"]
topic_map = _result["topic"]
print(f"[TIMING] Polarity+Topic (from early-start thread): {_time.time() - t_start:.1f}s wait")
else:
# Fallback: compute from scratch (e.g. if thread_state was not passed)
all_texts = [text1, text2, text3, text4, text5, text6]
active_texts = [t for t in all_texts if t and t.strip()]
if len(active_texts) < 2:
raise ValueError("Please enter at least two reviews")
progress(0.0, desc="Loading models...")
t0 = _time.time()
processor = get_interactive_processor()
print(f"[TIMING] get_interactive_processor: {_time.time() - t0:.1f}s")
progress(0.10, desc="Tokenizing reviews...")
t0 = _time.time()
sentence_lists = [[s for s in glimpse_tokenizer(t) if s.strip()] for t in active_texts]
sentence_lists = [sl for sl in sentence_lists if sl]
print(f"[TIMING] Tokenization: {_time.time() - t0:.1f}s ({sum(len(sl) for sl in sentence_lists)} total sentences)")
if len(sentence_lists) < 2:
raise ValueError("At least two reviews must have valid sentences")
t0 = _time.time()
all_sentences = filter_and_clean_sentences(
list(set(s for sl in sentence_lists for s in sl))
)
print(f"[TIMING] filter_and_clean: {_time.time() - t0:.1f}s ({len(all_sentences)} unique sentences)")
progress(0.30, desc="Predicting polarity and topics...")
t0 = _time.time()
polarity_map, topic_map = _gpu_predict_polarity_topic(all_sentences)
print(f"[TIMING] Polarity+Topic (sequential): {_time.time() - t0:.1f}s")
print(f"[TIMING] Fast processing total: {_time.time() - t_start:.1f}s")
# Step 5: Format results as HTML with collapsible review cards
progress(0.90, desc="Formatting results...")
# Pre-compute per-review rebuttal HTML (embedded inside each card, like pre-processed tab)
rebuttal_htmls = [format_rebuttal_for_review(rebuttal_str or "", i + 1) for i in range(MAX_INTERACTIVE_REVIEWS)]
# Build per-review outputs as HTML (same format as pre-processed tab)
none_out, agree_out, polar_out, topic_out = [], [], [], []
for i in range(MAX_INTERACTIVE_REVIEWS):
if i < len(sentence_lists):
review_label = f"Review {i + 1}"
reb = rebuttal_htmls[i]
# Build (sentence, metadata) tuples for render_review_html
plain_items = [(s, {}) for s in sentence_lists[i]]
polar_items = [(s, {"polarity": polarity_map.get(s)}) for s in sentence_lists[i]]
topic_items = [(s, {"topic": topic_map.get(s)}) for s in sentence_lists[i]]
none_html = build_review_card(review_label, review_items=plain_items, mode="plain", rebuttal_html=reb)
polar_html = build_review_card(review_label, review_items=polar_items, mode="polarity", rebuttal_html=reb)
topic_html = build_review_card(review_label, review_items=topic_items, mode="topic", rebuttal_html=reb)
none_out.append(gr.update(visible=True, value=none_html))
agree_out.append(gr.update(visible=False, value=""))
polar_out.append(gr.update(visible=False, value=polar_html))
topic_out.append(gr.update(visible=False, value=topic_html))
else:
none_out.append(gr.update(visible=False, value=""))
agree_out.append(gr.update(visible=False, value=""))
polar_out.append(gr.update(visible=False, value=""))
topic_out.append(gr.update(visible=False, value=""))
progress(1.0, desc="Done! Computing agreement in background...")
# Store sentence lists and texts in state for async RSA
rsa_state = {
"sentence_lists": sentence_lists,
"active_texts": active_texts,
"polarity_map": polarity_map,
"topic_map": topic_map,
"rebuttal_str": rebuttal_str or "",
}
return (
*none_out,
*agree_out,
"", "", # most_common, most_unique (will be filled when RSA done)
*polar_out,
*topic_out,
len(sentence_lists), # active review count
rsa_state, # state for async RSA
)
def _fmt_time(sec: float) -> str:
"""Format seconds as MM:SS or HH:MM:SS like tqdm."""
if sec is None:
return "?"
sec = int(sec)
if sec < 3600:
return f"{sec // 60:02d}:{sec % 60:02d}"
return f"{sec // 3600}:{(sec % 3600) // 60:02d}:{sec % 60:02d}"
def _agreement_progress_html(pct: int, done: int, total: int,
eta_sec: float = None, elapsed: float = None,
rate: float = None) -> str:
"""Progress bar HTML for the agreement computation, tqdm-style [elapsed 0 and elapsed is not None:
info = f"{done}/{total} [{_fmt_time(elapsed)}<{_fmt_time(eta_sec)}, {rate:.1f}s/it]"
else:
info = f"{done}/{total}"
return f"""
Computing agreement: {pct}%
{info}
"""
def compute_rsa_in_background(rsa_state: Dict, current_focus: str):
"""
Generator: streams real progress to agreement_progress_html while RSA runs in a thread,
then yields final agreement HTML + summary cards.
Outputs: agreement_text1..6, most_common, most_divergent, agreement_progress_html, focus_radio
"""
_empty = tuple([gr.update(visible=False, value="")] * (MAX_INTERACTIVE_REVIEWS + 2)
+ [gr.update(visible=False, value=""),
gr.update(choices=["No Highlighting", "Polarity", "Topic", "Agreement"], interactive=True)])
if not rsa_state or not rsa_state.get("sentence_lists"):
yield _empty
return
processor = get_interactive_processor()
sentence_lists = rsa_state["sentence_lists"]
active_texts = rsa_state["active_texts"]
# Shared progress state updated by progress_callback from the RSA thread
_prog = {"done": 0, "total": 1, "result": None, "error": None, "start_time": None}
def _progress_callback(done, total):
if _prog["start_time"] is None:
_prog["start_time"] = time.time()
_prog["done"] = done
_prog["total"] = total
def _run():
try:
_prog["result"] = processor.predict_rsa_full(*active_texts, progress_callback=_progress_callback)
except Exception as e:
_prog["error"] = e
t = threading.Thread(target=_run, daemon=True)
t.start()
# Yield progress updates while RSA thread is running
_no_op_8 = [gr.update()] * (MAX_INTERACTIVE_REVIEWS + 2)
while t.is_alive():
done, total = _prog["done"], _prog["total"]
pct = int(done / total * 100) if total > 0 else 0
# tqdm-style ETA: elapsed/done * remaining
eta_sec = None
elapsed = None
rate = None
t0 = _prog.get("start_time")
if t0 and done > 0:
elapsed = time.time() - t0
rate = elapsed / done # seconds per batch
eta_sec = rate * (total - done)
yield (*_no_op_8, gr.update(visible=True, value=_agreement_progress_html(pct, done, total, eta_sec, elapsed, rate)), gr.update())
time.sleep(0.4)
t.join()
if _prog["error"]:
print(f"[RSA ERROR] {type(_prog['error']).__name__}: {_prog['error']}")
error_msg = f"❌ Agreement computation failed: {str(_prog['error'])[:100]}"
yield (
*[gr.update(visible=False, value="")] * MAX_INTERACTIVE_REVIEWS,
error_msg, "",
gr.update(visible=False, value=""),
gr.update(choices=["No Highlighting", "Polarity", "Topic", "Agreement"], interactive=True),
)
return
rsa_result = _prog["result"] or {}
uniqueness = rsa_result.get("uniqueness", {})
listener = rsa_result.get("listener", {})
speaker = rsa_result.get("speaker", {})
polarity_map = rsa_state.get("polarity_map", {})
topic_map = rsa_state.get("topic_map", {})
most_common_text = format_common_themes(
sentence_lists, polarity_map, topic_map,
speaker=speaker, uniqueness=uniqueness, listener=listener,
)
if uniqueness:
divergent_per_review = format_divergent_cards(uniqueness, sentence_lists, listener, speaker)
else:
divergent_per_review = {}
show_agreement = current_focus in ("Agreement", "Agreement (Processing)")
num_reviews = len(active_texts)
# Pre-compute per-review rebuttal HTML (embedded inside agreement cards)
rebuttal_str = rsa_state.get("rebuttal_str", "")
rebuttal_htmls = [format_rebuttal_for_review(rebuttal_str, i + 1) for i in range(MAX_INTERACTIVE_REVIEWS)]
agree_out = []
for i in range(MAX_INTERACTIVE_REVIEWS):
if i < len(sentence_lists):
html_val = build_review_card(
f"Agreement in Review {i + 1}",
sentences=sentence_lists[i],
uniqueness=uniqueness, listener=listener, speaker=speaker,
num_reviews=num_reviews,
divergent_html=divergent_per_review.get(i, ""),
rebuttal_html=rebuttal_htmls[i],
)
agree_out.append(gr.update(visible=show_agreement, value=html_val))
else:
agree_out.append(gr.update(visible=False, value=""))
yield (
*agree_out,
most_common_text, "",
gr.update(visible=False, value=""),
gr.update(choices=["No Highlighting", "Polarity", "Topic", "Agreement"], interactive=True),
)
CUSTOM_CSS = """
.review-section-header h3 {
color: #1e40af;
border-left: 4px solid #3b82f6;
padding-left: 10px;
margin-top: 16px;
}
.rebuttal-section-header h3 {
color: #92400e;
border-left: 4px solid #f59e0b;
padding-left: 10px;
margin-top: 16px;
}
/* RSA sentence tooltip styles — uses JS positioning via onmouseenter */
.rsa-sentence {
position: relative;
cursor: help;
padding: 1px 3px;
border-radius: 2px;
display: inline;
}
.rsa-tooltip {
display: none;
position: fixed;
background: #1f2937;
color: white;
padding: 6px 10px;
border-radius: 6px;
font-size: 0.78em;
z-index: 10000;
pointer-events: none;
max-width: 280px;
width: max-content;
white-space: normal;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
}
/* Collapsible author response toggle */
details summary::-webkit-details-marker { display: none; }
details[open] summary span:first-child { display: inline-block; transform: rotate(90deg); }
/* Smooth scrolling everywhere */
html, body, .gradio-container, main, .contain { scroll-behavior: smooth !important; }
/* Back to top button */
#back-to-top-btn {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
padding: 8px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: white;
color: #374151;
font-size: 0.82em;
font-weight: 500;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
display: none;
transition: opacity 0.2s;
}
#back-to-top-btn:hover { background: #f3f4f6; }
/* Paper title heading style for interactive tab */
.paper-title-heading textarea {
font-size: 1.17em !important;
font-weight: 700 !important;
color: #1f2937 !important;
border: none !important;
background: transparent !important;
padding: 0 !important;
line-height: 1.3 !important;
min-height: 0 !important;
}
.paper-title-heading { padding: 0 !important; margin: 0 !important; min-height: 0 !important; }
/* Zero-height review anchor elements for jump navigation */
.review-anchor { height: 0 !important; overflow: hidden !important; margin: 0 !important; padding: 0 !important; min-height: 0 !important; border: none !important; }
.review-anchor > * { height: 0 !important; margin: 0 !important; padding: 0 !important; }
/* Tighter vertical spacing in results section */
.results-compact { gap: 4px !important; }
/* Progress bar group — zero internal spacing, collapses when both children hidden */
.progress-group, .progress-group > * {
gap: 0 !important; padding: 0 !important; margin: 0 !important;
border: none !important; box-shadow: none !important;
border-radius: 0 !important; min-height: 0 !important;
}
/* Remove Gradio wrapper spacing around individual progress bars */
.progress-compact { margin: 0 !important; padding: 0 !important; width: 100% !important; min-height: 0 !important; }
/* Suppress Gradio's loading/progress indicator on progress bar components */
.progress-compact .progress-bar, .progress-compact .eta-bar,
.progress-compact > .wrap, .progress-compact .generating { display: none !important; }
/* Suppress Gradio's orange "pending update" pulsing border */
.generating { animation: none !important; border-color: #e5e7eb !important; box-shadow: none !important; }
/* Remove the border/separator line around the display mode radio row */
.no-border-row { border: none !important; box-shadow: none !important; padding: 0 !important; margin-bottom: 0 !important; }
/* Progress bar animations — global so they survive HTML replacement in generators */
@keyframes procspin { to { transform: rotate(360deg); } }
@keyframes agrslide { 0% { width:15%; margin-left:0; } 50% { width:35%; margin-left:50%; } 100% { width:15%; margin-left:85%; } }
"""
# Build a theme where dark mode looks identical to light mode
_theme = gr.themes.Default()
_dark_overrides = {}
for _attr in dir(_theme):
if _attr.endswith('_dark') and not _attr.startswith('_'):
_light_attr = _attr[:-5]
_light_val = getattr(_theme, _light_attr, None)
if _light_val is not None:
_dark_overrides[_attr] = _light_val
_theme.set(**_dark_overrides)
with gr.Blocks(
title="ReView",
css=CUSTOM_CSS,
theme=_theme,
js="""() => {
var btn = document.createElement('button');
btn.id = 'back-to-top-btn';
btn.textContent = '\\u2191 Top';
btn.onclick = function() {
window.scrollTo({top: 0, behavior: 'smooth'});
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
var els = document.querySelectorAll('.gradio-container, main, .contain, .main');
for (var i = 0; i < els.length; i++) els[i].scrollTop = 0;
};
document.body.appendChild(btn);
function getMaxScroll() {
var y = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0;
var els = document.querySelectorAll('.gradio-container, main, .contain, .main');
for (var i = 0; i < els.length; i++) {
if (els[i].scrollTop > y) y = els[i].scrollTop;
}
return y;
}
document.addEventListener('scroll', function() {
btn.style.display = getMaxScroll() > 400 ? '' : 'none';
}, true);
setInterval(function() {
btn.style.display = getMaxScroll() > 400 ? '' : 'none';
}, 500);
/* Gray-out and prevent selection of radio choices containing ⏳ */
var _prevRadio = 'No Highlighting';
setInterval(function() {
var labels = document.querySelectorAll('.no-border-row label input[type=radio]');
labels.forEach(function(inp) {
var lbl = inp.closest('label') || inp.parentElement;
var txt = (lbl && lbl.textContent) || '';
if (txt.indexOf('⏳') !== -1) {
lbl.style.opacity = '0.4';
lbl.style.pointerEvents = 'none';
lbl.title = 'Still computing…';
} else {
lbl.style.opacity = '';
lbl.style.pointerEvents = '';
lbl.title = '';
}
});
}, 300);
}""",
) as demo:
# gr.Markdown("# ReView Interface")
# TODO: Uncomment this for home/description tab once finished with testing.
# with gr.Tab("Introduction"):
# gr.Markdown(glimpse_description)
# -----------------------------------
# Pre-processed Tab
# -----------------------------------
with gr.Tab("Pre-processed Reviews", elem_classes=["results-compact"]):
# Initialize state for this session.
if not years:
raise ValueError("No years available in new dataset")
initial_year = years[0]
initial_scored_reviews = get_preprocessed_scores(initial_year)
initial_review_ids = list(initial_scored_reviews.keys())
initial_review = initial_scored_reviews[initial_review_ids[0]]
number_of_displayed_reviews = len(initial_scored_reviews[initial_review_ids[0]])
initial_metadata = get_preprocessed_metadata(initial_year)
initial_state = {
"year_choice": initial_year,
"scored_reviews_for_year": initial_scored_reviews,
"review_ids": initial_review_ids,
"current_review_index": 0,
"current_review": initial_review,
"number_of_displayed_reviews": number_of_displayed_reviews,
"metadata_for_year": initial_metadata,
}
state = gr.State(initial_state)
def update_review_display(state, score_type):
review_ids = state["review_ids"]
current_index = state["current_review_index"]
current_review = state["scored_reviews_for_year"][review_ids[current_index]]
show_polarity = score_type == "Polarity"
show_consensuality = score_type == "Agreement"
show_topic = score_type == "Topic"
if show_polarity:
color_map = {"➕": "#d4fcd6", "➖": "#fcd6d6"}
legend = False
elif show_topic:
color_map = topic_color_map # No color map for topics
legend = False
elif show_consensuality:
color_map = None # Continuous scale, no predefined colors
legend = True
else:
color_map = {} # Default to empty map
legend = False
current_id = review_ids[current_index]
# Primary source: raw CSV lookup (processed CSVs lack paper_title)
paper_title = _paper_titles.get(current_id, "")
# Fallback: metadata column in preprocessed CSV
if not paper_title:
paper_meta = state.get("metadata_for_year", {}).get(current_id, {})
paper_title = paper_meta.get("paper_title", "") if isinstance(paper_meta, dict) else ""
if paper_title:
new_review_id = (
f"### {paper_title}\n\n"
f"[View on OpenReview]({current_id}) · "
f"({current_index + 1} of {len(state['review_ids'])} submissions)"
)
else:
new_review_id = (
f"### [View on OpenReview]({current_id})\n\n"
f"({current_index + 1} of {len(state['review_ids'])} submissions)"
)
number_of_displayed_reviews = len(current_review)
review_updates = []
rebuttal_updates = []
consensuality_dict = {}
# Pre-compute robust normalization stats (median + IQR) for raw KL scores
_kl_median, _kl_iqr = 0.0, 0.0
if show_consensuality:
all_raw_scores = []
for review_data in current_review:
if isinstance(review_data, dict) and "sentences" in review_data:
items = review_data["sentences"].items()
else:
items = review_data.items() if isinstance(review_data, dict) else []
for _, metadata in items:
all_raw_scores.append(metadata.get("consensuality", 0.0))
if all_raw_scores:
arr = np.array(all_raw_scores)
_kl_median = float(np.median(arr))
q25, q75 = float(np.percentile(arr, 25)), float(np.percentile(arr, 75))
_kl_iqr = q75 - q25
# Build per-review sentence lists, cache, and polarity/topic maps in a single pass
review_sentence_lists = []
review_items_cache = [] # Cache (review_item, rebuttal_html) per review
prep_polarity_map = {}
prep_topic_map = {}
for idx in range(number_of_displayed_reviews):
review_data = current_review[idx]
rebuttal_html = ""
if isinstance(review_data, dict) and "sentences" in review_data:
review_item = list(review_data["sentences"].items())
rebuttal_html = format_rebuttal_plain(review_data.get("rebuttal", ""))
else:
review_item = list(review_data.items())
review_sentence_lists.append([s for s, _ in review_item])
review_items_cache.append((review_item, rebuttal_html))
# Build polarity/topic maps from pre-processed metadata
for sent, meta in review_item:
if not isinstance(meta, dict):
continue
pol_val = meta.get("polarity")
if pol_val == 0:
prep_polarity_map[sent] = "➖"
elif pol_val == 2:
prep_polarity_map[sent] = "➕"
topic = meta.get("topic")
if topic and topic != "NONE":
prep_topic_map[sent] = topic
# For agreement mode, build uniqueness dict and extract RSA distributions
# RSA listener/speaker come from metadata (if pipeline saved them)
prep_listener = None
prep_speaker = None
if show_consensuality:
for idx in range(number_of_displayed_reviews):
review_item, _ = review_items_cache[idx]
for sentence, metadata in review_item:
raw = metadata.get("consensuality", 0.0)
if _kl_iqr > 0:
score = max(-1.0, min(1.0, (raw - _kl_median) / (_kl_iqr * 2)))
else:
score = 0.0
if not is_noise_sentence(sentence) and abs(score) >= HIGHLIGHT_THRESHOLD:
consensuality_dict[sentence] = score
# Extract listener/speaker from metadata (saved by pipeline)
meta_for_year = state.get("metadata_for_year", {})
submission_meta = meta_for_year.get(current_id, {})
if isinstance(submission_meta, dict):
rsa_data = submission_meta.get("rsa", {})
if rsa_data:
prep_listener = rsa_data.get("listener")
prep_speaker = rsa_data.get("speaker")
agreement_updates = []
divergent_per_review = {}
# Pre-compute per-review divergent cards if we have RSA data
if show_consensuality and prep_listener and prep_speaker and consensuality_dict:
divergent_per_review = format_divergent_cards(
consensuality_dict, review_sentence_lists, prep_listener, prep_speaker,
)
for i in range(MAX_PREPROCESSED_REVIEWS):
if i < number_of_displayed_reviews:
review_item, rebuttal_html = review_items_cache[i]
# All modes now use HTML rendering for proper paragraph formatting.
# HighlightedText is always hidden; prep_agreement HTML is always shown.
review_updates.append(
gr.update(
visible=False,
value=[],
show_legend=False,
color_map=color_map,
key=f"updated_{score_type}_{i}"
)
)
review_label = f"Review {i + 1}"
if show_consensuality:
html_content = build_review_card(
review_label,
sentences=[s for s, _ in review_item],
uniqueness=consensuality_dict,
listener=prep_listener, speaker=prep_speaker,
num_reviews=number_of_displayed_reviews,
divergent_html=divergent_per_review.get(i, ""),
rebuttal_html=rebuttal_html,
)
else:
m = "polarity" if show_polarity else ("topic" if show_topic else "plain")
html_content = build_review_card(
review_label, review_items=review_item, mode=m,
rebuttal_html=rebuttal_html,
)
agreement_updates.append(gr.update(visible=True, value=html_content))
# Rebuttal is now embedded in the review card, so hide the separate component
rebuttal_updates.append(gr.update(visible=False, value=""))
else:
review_updates.append(
gr.update(
visible=False,
value=[],
show_legend=False,
color_map=color_map,
key=f"updated_{score_type}_{i}"
)
)
agreement_updates.append(gr.update(visible=False, value=""))
rebuttal_updates.append(gr.update(visible=False, value=""))
# General rebuttal display (currently unused in new format, kept for backward compat)
general_rebuttal_update = gr.update(visible=False, value="")
# Common Themes (topic+polarity grouping) — consistent with interactive tab
if show_consensuality:
most_common_html = format_common_themes(
review_sentence_lists, prep_polarity_map, prep_topic_map,
speaker=prep_speaker, uniqueness=consensuality_dict if consensuality_dict else None,
listener=prep_listener,
)
most_common_visibility = gr.update(visible=True, value=most_common_html)
most_unique_visibility = gr.update(visible=False, value="")
else:
most_common_visibility = gr.update(visible=False, value="")
most_unique_visibility = gr.update(visible=False, value="")
# update color legend (topic or polarity)
if show_polarity:
polarity_legend = (
'
'
'Positive'
'Negative'
'Neutral (no highlight)'
'
'
)
topic_color_map_visibility = gr.update(visible=True, value=polarity_legend)
elif show_topic:
legend_items = " ".join(
f'{_html.escape(name)}'
for name, color in _TOPIC_HTML_COLORS.items()
)
topic_legend_html = (
f'