| 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 |
|
|
| |
| try: |
| import spaces |
| _gpu = spaces.GPU |
| except ImportError: |
| _gpu = lambda f: f |
|
|
| sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) |
|
|
| BASE_DIR = Path(__file__).resolve().parent.parent |
|
|
| |
| |
| |
| |
| |
| _thread_states = {} |
|
|
| AGREEMENT_AMP_UNIQUE = 1.0 |
| AGREEMENT_AMP_COMMON = 1.0 |
| MAX_PREPROCESSED_REVIEWS = 10 |
| LISTENER_CONCENTRATION_THRESHOLD = 0.70 |
| INFORMATIVENESS_MULTIPLIER = 2.0 |
|
|
|
|
| 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'<span style="background:#f3f4f6;color:#374151;padding:2px 6px;' |
| f'border-radius:4px;font-size:0.72em;font-weight:600;">R{n}</span>' |
| 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'<span style="display:inline-flex;align-items:center;gap:2px;margin-right:6px;">' |
| f'<span style="font-size:0.7em;font-weight:600;color:{badge_fg};">{label}</span>' |
| f'<span style="display:inline-block;width:{bar_w}px;height:5px;' |
| f'background:#3b82f6;border-radius:3px;"></span>' |
| f'<span style="font-size:0.7em;color:#6b7280;">{pct}%</span>' |
| f'</span>' |
| ) |
| return ( |
| f'<div style="display:flex;flex-wrap:wrap;align-items:center;gap:3px;margin-bottom:3px;">' |
| f'{source_badges}' |
| f'<span style="color:#d1d5db;font-size:0.75em;">\u2192</span> ' |
| + "".join(bar_parts) + "</div>" |
| ) |
| return f'<div style="display:flex;gap:4px;margin-bottom:3px;">{source_badges}</div>' |
|
|
|
|
| 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 ( |
| '<button onclick="' |
| f"let tab=this.closest('.tabitem')||this.closest('.gradio-container');" |
| f"let details=tab.querySelectorAll('{selector}');" |
| "if(!details.length)return;" |
| "let allOpen=Array.from(details).every(d=>d.open);" |
| "details.forEach(d=>d.open=!allOpen);" |
| f"this.textContent=allOpen?'{text_when_all_open}':'{text_when_not_all_open}';" |
| f'" style="{_TOGGLE_BTN_STYLE}"' |
| f'>{initial_label}</button>' |
| ) |
|
|
|
|
| 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'<button onclick="{js}" ' |
| f'style="{_TOGGLE_BTN_STYLE}font-weight:600;">' |
| f'R{i}</button>' |
| ) |
| return "".join(buttons) |
|
|
|
|
|
|
| def _should_break_before(sent: str) -> bool: |
| """Detect if a paragraph break should be inserted before this sentence.""" |
| s = sent.strip() |
| |
| if _re.match(r'^[\(\[]?\d+[\)\]\.:]', s): |
| return True |
| |
| if len(s) > 2 and s[0] in ('-', '•', '*', '–', '—') and s[1] == ' ': |
| return True |
| |
| if s.startswith('##') or s.startswith('---'): |
| return True |
| |
| 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_COLORS = { |
| 2: "#d4fcd6", 0: "#fcd6d6", |
| "➕": "#d4fcd6", "➖": "#fcd6d6", |
| } |
| _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'<details open class="review-collapse" style="border:1px solid #d1d5db;' |
| f'border-radius:8px;padding:0;margin-bottom:10px;overflow:hidden;">' |
| f'<summary style="font-weight:600;font-size:0.9em;color:#374151;cursor:pointer;' |
| f'list-style:none;display:flex;align-items:center;gap:6px;' |
| f'background:#f9fafb;padding:8px 14px;border-bottom:1px solid #e5e7eb;">' |
| f'<span style="transition:transform 0.2s;font-size:0.7em;">▶</span> ' |
| f'{escaped}</summary>' |
| f'<div style="padding:12px 16px;">{inner_html}</div>' |
| f'</details>' |
| ) |
| else: |
| if not label: |
| return inner_html |
| return ( |
| f'<div style="border:1px solid #d1d5db;border-radius:8px;padding:0;margin-bottom:10px;overflow:hidden;">' |
| f'<div style="background:#f9fafb;padding:8px 14px;border-bottom:1px solid #e5e7eb;' |
| f'font-weight:600;font-size:0.85em;color:#374151;">{escaped}</div>' |
| f'<div style="padding:12px 16px;">{inner_html}</div>' |
| f'</div>' |
| ) |
|
|
|
|
| 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('<div style="line-height:1.8;font-size:0.95em;margin-top:6px;">') |
|
|
| for i, (sent, metadata) in enumerate(review_items): |
| |
| if i > 0 and _should_break_before(sent): |
| parts.append('<br>') |
|
|
| |
| 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: |
| |
| parts.append( |
| f'<span id="{sent_id}" style="{style}" title="{_html.escape(label_text)}">' |
| f'{escaped} </span>' |
| ) |
| else: |
| parts.append(f'<span id="{sent_id}" style="{style}">{escaped} </span>') |
|
|
| parts.append('</div>') |
| 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" |
|
|
| |
| |
| 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) |
| } |
|
|
| |
| 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 = _source_badges_html(sent, sentence_lists) |
|
|
| |
| dist_html = _listener_dist_bars(sent, listener, source_badge, badge_fg=badge_fg) |
|
|
| onclick = _click_to_scroll_js(sent_id) |
|
|
| |
| before_span = f'<span style="{ctx_style}">...{_html.escape(context_before)} </span>' if context_before else "" |
| after_span = f'<span style="{ctx_style}"> {_html.escape(context_after)}...</span>' if context_after else "" |
|
|
| cards_parts.append( |
| f'<div style="border:1px solid #e5e7eb;border-left:3px solid {border_color};' |
| f'border-radius:6px;padding:8px 12px;margin-bottom:5px;cursor:pointer;" ' |
| f'onclick="{_html.escape(onclick)}">' |
| f'{dist_html}' |
| f'<div style="color:#111827;line-height:1.5;">' |
| f'{before_span}' |
| f'<span style="font-weight:500;">{_html.escape(sent)}</span>' |
| f'{after_span}' |
| f'</div>' |
| f'</div>' |
| ) |
|
|
| |
| inner = "".join(cards_parts) |
| return ( |
| f'<details open style="margin-bottom:8px;">' |
| f'<summary style="cursor:pointer;font-weight:600;font-size:0.9em;color:#374151;' |
| f'margin-bottom:6px;list-style:none;">' |
| f'<span style="margin-right:4px;">▸</span>Most Common Opinions</summary>' |
| f'{inner}' |
| f'</details>' |
| ) |
|
|
|
|
| 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 |
|
|
|
|
| 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 "" |
|
|
| |
| |
| 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) |
|
|
| |
| 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)) |
|
|
| |
| 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 |
|
|
| |
| common_topics.sort(key=lambda t: (0 if t[0] == "Other" else 1, t[1], t[2]), reverse=True) |
|
|
| |
| 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 = [ |
| '<details open style="margin-bottom:10px;border:1px solid #d1d5db;border-radius:8px;' |
| 'padding:0;overflow:hidden;">', |
| '<summary style="cursor:pointer;font-weight:700;font-size:0.92em;color:#1f2937;' |
| 'padding:10px 14px;list-style:none;background:#f9fafb;border-bottom:1px solid #e5e7eb;' |
| 'user-select:none;display:flex;align-items:center;gap:6px;">' |
| '<span class="collapse-arrow" style="display:inline-block;transition:transform 0.2s;' |
| 'font-size:0.75em;">▼</span>' |
| 'Generic Sentences</summary>', |
| '<div style="padding:8px 10px;">', |
| '<div style="background:#fefce8;border:1px solid #fde68a;border-radius:6px;' |
| 'padding:8px 12px;margin-bottom:8px;font-size:0.82em;color:#92400e;">' |
| 'No shared themes detected across reviewers. Showing sentences with lowest ' |
| 'RSA uniqueness score (most generic across reviews).</div>', |
| ] |
| 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'<div style="border:1px solid #e5e7eb;border-left:3px solid #d1d5db;' |
| f'border-radius:6px;padding:6px 10px;margin-bottom:4px;cursor:pointer;" ' |
| f'onclick="{_html.escape(onclick)}">' |
| f'{dist_html}' |
| f'<span style="color:#111827;">{_html.escape(sent)}</span>' |
| f'</div>' |
| ) |
| parts.append('</div></details>') |
| return "".join(parts) |
|
|
| |
| _pol_colors = {"negative": "#ef4444", "positive": "#22c55e", "neutral": "#9ca3af"} |
| _pol_labels = {"negative": "Negative", "positive": "Positive", "neutral": "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'<div style="display:flex;align-items:baseline;gap:6px;padding:2px 0;' |
| f'padding-left:8px;cursor:pointer;" ' |
| f'onclick="{_html.escape(onclick)}">' |
| f'<span style="background:#f3f4f6;color:#374151;padding:1px 5px;' |
| f'border-radius:3px;font-size:0.7em;font-weight:600;flex-shrink:0;">{r_label}</span>' |
| f'<span style="color:#374151;font-size:0.85em;line-height:1.4;">{_html.escape(sent)}</span>' |
| f'</div>' |
| ) |
|
|
| 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" |
| ) |
|
|
| |
| 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 |
|
|
| |
| _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'<span style="display:inline-block;height:6px;width:{max(pct, 4)}%;' |
| f'background:{color};opacity:0.75;"></span>' |
| ) |
| bar_labels.append( |
| f'<span style="font-size:0.7em;color:#6b7280;">' |
| f'{pct}% {_pol_labels[pol]}</span>' |
| ) |
|
|
| |
| if topic == "Other": |
| polarity_bar = "" |
| else: |
| polarity_bar = ( |
| f'<div style="display:flex;align-items:center;gap:6px;margin-left:8px;">' |
| f'<div style="display:flex;width:80px;height:6px;border-radius:3px;overflow:hidden;' |
| f'background:#f3f4f6;flex-shrink:0;">' |
| + "".join(bar_segments) |
| + '</div>' |
| + " ".join(bar_labels) |
| + '</div>' |
| ) |
|
|
| |
| header = ( |
| f'<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">' |
| f'<span style="font-weight:600;font-size:0.88em;color:#1f2937;">{_html.escape(topic)}</span>' |
| f'<span style="color:#9ca3af;font-size:0.75em;">·</span>' |
| f'<span style="font-size:0.75em;color:#6b7280;">{reviewer_text}</span>' |
| f'{polarity_bar}' |
| f'</div>' |
| ) |
|
|
| |
| 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'<div style="font-size:0.78em;font-weight:600;color:{color};' |
| f'margin:4px 0 2px 0;">' |
| f'{_pol_labels[pol]} ({cnt} sentence{"s" if cnt != 1 else ""})</div>' |
| ) |
|
|
| 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'<div style="border:1px solid #e5e7eb;border-left:3px solid {border_color};' |
| f'border-radius:6px;padding:8px 12px;margin-bottom:5px;">' |
| f'{header}{"".join(sub_groups_html)}' |
| f'</div>' |
| ) |
|
|
| inner = "".join(cards) |
| return ( |
| f'<details open style="margin-bottom:10px;border:1px solid #d1d5db;border-radius:8px;' |
| f'padding:0;overflow:hidden;">' |
| f'<summary style="cursor:pointer;font-weight:700;font-size:0.92em;color:#1f2937;' |
| f'padding:10px 14px;list-style:none;background:#f9fafb;border-bottom:1px solid #e5e7eb;' |
| f'user-select:none;display:flex;align-items:center;gap:6px;">' |
| f'<span class="collapse-arrow" style="display:inline-block;transition:transform 0.2s;' |
| f'font-size:0.75em;">▶</span>' |
| f'Common Themes Across Reviews</summary>' |
| f'<div style="padding:8px 10px;">{inner}</div>' |
| f'</details>' |
| ) |
|
|
|
|
| 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)] |
|
|
| |
| k = max(sum(len(v) for v in speaker.values()) // max(len(speaker), 1), 1) |
| min_speaker_score = INFORMATIVENESS_MULTIPLIER / k |
|
|
| |
| 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)) |
|
|
| |
| 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 = [ |
| '<div style="margin-top:10px;margin-bottom:6px;font-weight:600;font-size:0.82em;color:#7f1d1d;">' |
| f'Unique Points in This Review</div>' |
| ] |
| 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'<span style="background:#fee2e2;color:#991b1b;padding:2px 6px;' |
| f'border-radius:4px;font-size:0.7em;font-weight:600;display:inline-block;margin-bottom:3px;">' |
| f'{dom_pct}% listener share</span>' |
| ) |
|
|
| onclick = _click_to_scroll_js(sent_id, "#ef4444") |
|
|
| before_span = f'<span style="{ctx_style}">...{_html.escape(context_before)} </span>' if context_before else "" |
| after_span = f'<span style="{ctx_style}"> {_html.escape(context_after)}...</span>' if context_after else "" |
|
|
| html_parts.append( |
| f'<div style="border:1px solid #e5e7eb;border-left:3px solid {border_color};' |
| f'border-radius:6px;padding:8px 12px;margin-bottom:5px;cursor:pointer;" ' |
| f'onclick="{_html.escape(onclick)}">' |
| f'{uniqueness_badge}' |
| f'<div style="color:#111827;line-height:1.5;">' |
| f'{before_span}' |
| f'<span style="font-weight:500;">{_html.escape(sent)}</span>' |
| f'{after_span}' |
| f'</div>' |
| f'</div>' |
| ) |
|
|
| 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 "" |
|
|
| |
| legend_html = ( |
| '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;' |
| 'font-size:0.75em;color:#6b7280;">' |
| '<span style="background:linear-gradient(to right,rgba(59,130,246,0.7),rgba(209,213,219,0.3),rgba(239,68,68,0.7));' |
| 'width:120px;height:8px;border-radius:4px;display:inline-block;"></span>' |
| '<span>← Common | Unique →</span>' |
| '</div>' |
| ) |
|
|
| parts = [] |
| parts.append(legend_html) |
| parts.append('<div style="line-height:1.8;font-size:0.95em;margin-top:6px;">') |
|
|
| |
| k = max(len(uniqueness), 1) |
| info_threshold = INFORMATIVENESS_MULTIPLIER / k |
|
|
| for idx, sent in enumerate(sentences): |
| |
| if idx > 0 and _should_break_before(sent): |
| parts.append('<br>') |
|
|
| sent_id = _make_sentence_id(sent) |
| score = uniqueness.get(sent) |
|
|
| |
| header_style = "font-weight:600;color:#92400e;" if _is_review_header(sent) else "" |
|
|
| if score is None or abs(score) < HIGHLIGHT_THRESHOLD: |
| |
| parts.append(f'<span id="{sent_id}" style="{header_style}">{_html.escape(sent)} </span>') |
| continue |
|
|
| |
| if score < 0: |
| |
| 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 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 |
|
|
| |
| if speaker: |
| info = compute_informativeness(sent, speaker, num_reviews) |
| if info < info_threshold: |
| opacity *= 0.3 |
| else: |
| |
| r, g, b = 239, 68, 68 |
| opacity = abs(score) ** AGREEMENT_AMP_UNIQUE |
|
|
| bg_color = f"rgba({r},{g},{b},{opacity:.3f})" |
|
|
| |
| 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}" |
|
|
| |
| 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'<span id="{sent_id}" class="rsa-sentence" style="background:{bg_color};" ' |
| f'onmouseenter="{hover_js}" onmouseleave="{leave_js}">' |
| f'{_html.escape(sent)} ' |
| f'<span class="rsa-tooltip">{_html.escape(tooltip_text)}</span>' |
| f'</span>' |
| ) |
|
|
| parts.append("</div>") |
| 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) |
|
|
|
|
| |
| 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] |
| 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) |
|
|
| |
| 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)." |
| ) |
|
|
| |
| years, all_scored_reviews_df = years_new, df_new |
|
|
| |
| 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" |
|
|
| |
| |
| |
|
|
| 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 {} |
|
|
|
|
| |
| |
| |
|
|
| |
| topic_color_map = { |
| "Substance": "#cce0ff", |
| "Clarity": "#e6ee9c", |
| "Soundness/Correctness": "#ffcccc", |
| "Originality": "#d1c4e9", |
| "Motivation/Impact": "#b2ebf2", |
| "Meaningful Comparison": "#fff9c4", |
| "Replicability": "#c8e6c9", |
| } |
|
|
|
|
| |
| 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 = """ |
| <div style="display:flex;align-items:center;gap:12px;padding:16px;background:#f0f4ff;border-radius:8px;border:1px solid #c7d2fe;margin:8px 0;"> |
| <div style="width:24px;height:24px;border:3px solid #e0e7ff;border-top:3px solid #4f46e5;border-radius:50%;animation:procspin 1s linear infinite;flex-shrink:0;"></div> |
| <div> |
| <div style="font-weight:600;color:#312e81;">Processing reviews...</div> |
| </div> |
| </div> |
| <style>@keyframes procspin{to{transform:rotate(360deg);}}</style> |
| """ |
|
|
| FETCHING_HTML = """ |
| <div style="display:flex;align-items:center;gap:12px;padding:16px;background:#f0f4ff;border-radius:8px;border:1px solid #c7d2fe;margin:8px 0;"> |
| <div style="width:24px;height:24px;border:3px solid #e0e7ff;border-top:3px solid #4f46e5;border-radius:50%;animation:procspin 1s linear infinite;flex-shrink:0;"></div> |
| <div> |
| <div style="font-weight:600;color:#312e81;">Fetching reviews from OpenReview...</div> |
| </div> |
| </div> |
| <style>@keyframes procspin{to{transform:rotate(360deg);}}</style> |
| """ |
|
|
| POLARITY_PROGRESS_HTML = """ |
| <div style="padding:10px 16px;background:#f0fff4;border-radius:8px;border:1px solid #bbf7d0;margin:0;"> |
| <div style="display:flex;align-items:center;gap:10px;"> |
| <div style="width:18px;height:18px;border:3px solid #dcfce7;border-top:3px solid #16a34a;border-radius:50%;animation:procspin 1s linear infinite;flex-shrink:0;"></div> |
| <span style="font-weight:600;color:#14532d;font-size:0.9em;white-space:nowrap;">Computing polarity & topic... </span> |
| <div style="flex:1;background:#dcfce7;border-radius:4px;height:6px;overflow:hidden;"> |
| <div style="background:linear-gradient(90deg,#4ade80,#16a34a);height:100%;border-radius:4px;animation:agrslide 2s ease-in-out infinite;"></div> |
| </div> |
| </div> |
| </div> |
| """ |
|
|
| AGREEMENT_PROGRESS_HTML = """ |
| <div style="padding:10px 16px;background:#f0f4ff;border-radius:8px;border:1px solid #c7d2fe;margin:0;"> |
| <div style="display:flex;align-items:center;gap:10px;"> |
| <div style="width:18px;height:18px;border:3px solid #e0e7ff;border-top:3px solid #4f46e5;border-radius:50%;animation:procspin 1s linear infinite;flex-shrink:0;"></div> |
| <span style="font-weight:600;color:#312e81;font-size:0.9em;white-space:nowrap;">Computing agreement in background...</span> |
| <div style="flex:1;background:#e0e7ff;border-radius:4px;height:6px;overflow:hidden;"> |
| <div style="background:linear-gradient(90deg,#818cf8,#4f46e5);height:100%;border-radius:4px;animation:agrslide 2s ease-in-out infinite;"></div> |
| </div> |
| </div> |
| </div> |
| """ |
|
|
|
|
| def _status_html(msg, kind="success"): |
| """Generate styled HTML for status messages.""" |
| styles = { |
| "success": ("✅", "#f0fdf4", "#16a34a", "#166534", "#bbf7d0"), |
| "error": ("❌", "#fef2f2", "#dc2626", "#991b1b", "#fecaca"), |
| "warning": ("⚠️", "#fffbeb", "#d97706", "#92400e", "#fde68a"), |
| } |
| icon, bg, _, text_color, border_color = styles.get(kind, styles["success"]) |
| return f'''<div style="display:flex;align-items:center;gap:10px;padding:12px 16px;background:{bg};border-radius:8px;border:1px solid {border_color};margin:8px 0;"> |
| <span style="font-size:1.2em;">{icon}</span> |
| <span style="color:{text_color};font-weight:500;">{msg}</span> |
| </div>''' |
|
|
| |
| |
| 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'<details style="{_REBUTTAL_PER_REVIEW_STYLE}">' |
| '<summary style="padding:10px 14px;cursor:pointer;font-size:0.75em;color:#92400e;' |
| 'font-weight:600;list-style:none;display:flex;align-items:center;gap:6px;">' |
| '<span style="transition:transform 0.2s;">▶</span> Author Response</summary>' |
| '<div style="padding:10px 14px;">' |
| f'<div style="white-space:pre-wrap;color:#1f2937;font-size:0.85em;line-height:1.5;">{text}</div>' |
| '</div></details>' |
| ) |
|
|
|
|
| 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: |
| |
| 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'<div style="padding:10px 14px;">' |
| f'<div style="white-space:pre-wrap;color:#1f2937;font-size:0.85em;line-height:1.5;">{text}</div>' |
| f'</div>' |
| ) |
|
|
| if not response_parts: |
| return "" |
|
|
| return ( |
| f'<details style="{_REBUTTAL_PER_REVIEW_STYLE}">' |
| f'<summary style="padding:10px 14px;cursor:pointer;font-size:0.75em;color:#92400e;' |
| f'font-weight:600;list-style:none;display:flex;align-items:center;gap:6px;">' |
| f'<span style="transition:transform 0.2s;">▶</span> Author Response</summary>' |
| + "".join(response_parts) |
| + "</details>" |
| ) |
|
|
| |
| if review_num == 1: |
| text = rebuttal.strip() |
| return ( |
| f'<details style="{_REBUTTAL_PER_REVIEW_STYLE}">' |
| f'<summary style="padding:10px 14px;cursor:pointer;font-size:0.75em;color:#92400e;' |
| f'font-weight:600;list-style:none;display:flex;align-items:center;gap:6px;">' |
| f'<span style="transition:transform 0.2s;">▶</span> Author Response</summary>' |
| f'<div style="padding:10px 14px;">' |
| f'<div style="white-space:pre-wrap;color:#1f2937;font-size:0.85em;line-height:1.5;">{text}</div>' |
| f'</div></details>' |
| ) |
| 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: |
| |
| 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'<div style="padding:14px 16px;background:{bg};{sep}">' |
| f'<div style="white-space:pre-wrap;color:#1f2937;font-size:0.9em;line-height:1.6;">{text}</div>' |
| f'</div>' |
| ) |
|
|
| if not response_parts: |
| return "" |
|
|
| count_label = f"{len(response_parts)} general response{'s' if len(response_parts) > 1 else ''}" |
| return ( |
| f'<details style="{_REBUTTAL_GENERAL_STYLE}">' |
| f'<summary style="{HEADER_STYLE}cursor:pointer;list-style:none;">' |
| f'<span style="font-size:1.1em;">💬</span>' |
| f'<span style="{TITLE_STYLE}">General Author Response</span>' |
| f'<span style="margin-left:auto;font-size:0.8em;color:#78716c;">{count_label}</span>' |
| f'</summary>' |
| + "".join(response_parts) + |
| '</details>' |
| ) |
|
|
| |
| text = rebuttal.strip() |
| return ( |
| f'<details style="{_REBUTTAL_GENERAL_STYLE}">' |
| f'<summary style="{HEADER_STYLE}cursor:pointer;list-style:none;">' |
| f'<span style="font-size:1.1em;">💬</span>' |
| f'<span style="{TITLE_STYLE}">General Author Response</span></summary>' |
| f'<div style="padding:14px 16px;background:white;">' |
| f'<div style="white-space:pre-wrap;color:#1f2937;font-size:0.9em;line-height:1.6;">{text}</div>' |
| f'</div></details>' |
| ) |
|
|
|
|
| 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() |
|
|
| |
| |
| _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...") |
|
|
| |
| 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: |
| |
| 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") |
|
|
| |
| progress(0.90, desc="Formatting results...") |
|
|
| |
| rebuttal_htmls = [format_rebuttal_for_review(rebuttal_str or "", i + 1) for i in range(MAX_INTERACTIVE_REVIEWS)] |
|
|
| |
| 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] |
| |
| 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...") |
|
|
| |
| 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, |
| "", "", |
| *polar_out, |
| *topic_out, |
| len(sentence_lists), |
| rsa_state, |
| ) |
|
|
|
|
| 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<eta, rate s/it].""" |
| if done > 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""" |
| <div style="padding:10px 16px;background:#f0f4ff;border-radius:8px;border:1px solid #c7d2fe;margin:0;"> |
| <div style="display:flex;align-items:center;gap:10px;"> |
| <span style="font-weight:600;color:#312e81;font-size:0.9em;white-space:nowrap;">Computing agreement: {pct}%</span> |
| <div style="flex:1;background:#e0e7ff;border-radius:4px;height:6px;overflow:hidden;"> |
| <div style="background:linear-gradient(90deg,#818cf8,#4f46e5);height:100%;width:{pct}%;border-radius:4px;transition:width 0.4s ease;"></div> |
| </div> |
| <span style="font-size:0.78em;color:#6b7280;white-space:nowrap;">{info}</span> |
| </div> |
| </div> |
| """ |
|
|
|
|
| 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"] |
|
|
| |
| _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() |
|
|
| |
| _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 |
| |
| eta_sec = None |
| elapsed = None |
| rate = None |
| t0 = _prog.get("start_time") |
| if t0 and done > 0: |
| elapsed = time.time() - t0 |
| rate = elapsed / done |
| 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) |
|
|
| |
| 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%; } } |
| """ |
|
|
| |
| _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: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| with gr.Tab("Pre-processed Reviews", elem_classes=["results-compact"]): |
| |
| 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 |
| legend = False |
| elif show_consensuality: |
| color_map = None |
| legend = True |
| else: |
| color_map = {} |
| legend = False |
|
|
| current_id = review_ids[current_index] |
| |
| paper_title = _paper_titles.get(current_id, "") |
| |
| 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 = {} |
|
|
| |
| _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 |
|
|
| |
| review_sentence_lists = [] |
| review_items_cache = [] |
| 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)) |
|
|
| |
| 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 |
|
|
| |
| |
| 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 |
|
|
| |
| 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 = {} |
| |
| 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] |
|
|
| |
| |
| 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_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_update = gr.update(visible=False, value="") |
|
|
| |
| 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="") |
| |
| |
| if show_polarity: |
| polarity_legend = ( |
| '<div style="display:flex;gap:12px;align-items:center;padding:8px 0;font-size:0.8em;">' |
| '<span style="background:#d4fcd6;padding:2px 8px;border-radius:4px;">Positive</span>' |
| '<span style="background:#fcd6d6;padding:2px 8px;border-radius:4px;">Negative</span>' |
| '<span style="color:#9ca3af;">Neutral (no highlight)</span>' |
| '</div>' |
| ) |
| topic_color_map_visibility = gr.update(visible=True, value=polarity_legend) |
| elif show_topic: |
| legend_items = " ".join( |
| f'<span style="background:{color};padding:2px 8px;border-radius:4px;' |
| f'font-size:0.8em;margin-right:4px;">{_html.escape(name)}</span>' |
| for name, color in _TOPIC_HTML_COLORS.items() |
| ) |
| topic_legend_html = ( |
| f'<div style="display:flex;flex-wrap:wrap;gap:4px;align-items:center;' |
| f'padding:8px 0;">{legend_items}</div>' |
| ) |
| topic_color_map_visibility = gr.update(visible=True, value=topic_legend_html) |
| else: |
| topic_color_map_visibility = gr.update(visible=False, value="") |
|
|
| |
| has_any_rebuttal = any( |
| rebuttal_html |
| for _, rebuttal_html in review_items_cache |
| ) |
| toggle_buttons = [_review_toggle_html()] |
| if has_any_rebuttal: |
| toggle_buttons.append(_rebuttal_toggle_html()) |
| jump_html = _jump_buttons_html(number_of_displayed_reviews, prefix="pre") |
| toggle_bar_html = ( |
| '<div style="display:flex;align-items:center;gap:8px;">' |
| f'<span style="font-size:0.78em;color:#6b7280;white-space:nowrap;">Jump to:</span>' |
| + jump_html |
| + '<span style="flex:1;"></span>' |
| + "".join(toggle_buttons) + '</div>' |
| ) |
| toggle_bar_update = gr.update(visible=True, value=toggle_bar_html) |
|
|
| return ( |
| new_review_id, |
| *review_updates, |
| *agreement_updates, |
| most_common_visibility, |
| most_unique_visibility, |
| topic_color_map_visibility, |
| toggle_bar_update, |
| *rebuttal_updates, |
| general_rebuttal_update, |
| state |
| ) |
|
|
|
|
|
|
| |
| init_display = update_review_display(initial_state, score_type="Original") |
| |
|
|
| with gr.Row(): |
| |
| with gr.Column(scale=1): |
| review_id = gr.Markdown(value=init_display[0], container=True) |
| with gr.Row(): |
| previous_button = gr.Button("Previous", variant="secondary", interactive=True) |
| next_button = gr.Button("Next", variant="primary", interactive=True) |
| |
| |
| with gr.Column(scale=1): |
| |
| year = gr.Dropdown(choices=years, label="Select Year", interactive=True, value=initial_year) |
| score_type = gr.Radio( |
| choices=["No Highlighting", "Polarity", "Topic", "Agreement"], |
| label="Display Mode:", |
| value="No Highlighting", |
| interactive=True |
| ) |
|
|
| |
| with gr.Row(): |
| most_common_sentences = gr.HTML( |
| visible=False, |
| value="", |
| label="Most Common Opinions", |
| ) |
| most_unique_sentences = gr.HTML( |
| visible=False, |
| value="", |
| label="Most Divergent Opinions", |
| ) |
| |
| |
| topic_text_box = gr.HTML(visible=False, value="") |
| |
| prep_toggle_bar = gr.HTML(visible=False, value="") |
| gr.HTML(value='<div id="pre-review-anchor-1"></div>', elem_classes=["review-anchor"]) |
| review1 = gr.HighlightedText(show_legend=False, label="📝 Review 1", visible=number_of_displayed_reviews >= 1, key="initial_review1") |
| prep_agreement1 = gr.HTML(visible=False, value="") |
| prep_rebuttal1 = gr.HTML(visible=False, value="") |
| gr.HTML(value='<div id="pre-review-anchor-2"></div>', elem_classes=["review-anchor"]) |
| review2 = gr.HighlightedText(show_legend=False, label="📝 Review 2", visible=number_of_displayed_reviews >= 2, key="initial_review2") |
| prep_agreement2 = gr.HTML(visible=False, value="") |
| prep_rebuttal2 = gr.HTML(visible=False, value="") |
| gr.HTML(value='<div id="pre-review-anchor-3"></div>', elem_classes=["review-anchor"]) |
| review3 = gr.HighlightedText(show_legend=False, label="📝 Review 3", visible=number_of_displayed_reviews >= 3, key="initial_review3") |
| prep_agreement3 = gr.HTML(visible=False, value="") |
| prep_rebuttal3 = gr.HTML(visible=False, value="") |
| gr.HTML(value='<div id="pre-review-anchor-4"></div>', elem_classes=["review-anchor"]) |
| review4 = gr.HighlightedText(show_legend=False, label="📝 Review 4", visible=number_of_displayed_reviews >= 4, key="initial_review4") |
| prep_agreement4 = gr.HTML(visible=False, value="") |
| prep_rebuttal4 = gr.HTML(visible=False, value="") |
| gr.HTML(value='<div id="pre-review-anchor-5"></div>', elem_classes=["review-anchor"]) |
| review5 = gr.HighlightedText(show_legend=False, label="📝 Review 5", visible=number_of_displayed_reviews >= 5, key="initial_review5") |
| prep_agreement5 = gr.HTML(visible=False, value="") |
| prep_rebuttal5 = gr.HTML(visible=False, value="") |
| gr.HTML(value='<div id="pre-review-anchor-6"></div>', elem_classes=["review-anchor"]) |
| review6 = gr.HighlightedText(show_legend=False, label="📝 Review 6", visible=number_of_displayed_reviews >= 6, key="initial_review6") |
| prep_agreement6 = gr.HTML(visible=False, value="") |
| prep_rebuttal6 = gr.HTML(visible=False, value="") |
| gr.HTML(value='<div id="pre-review-anchor-7"></div>', elem_classes=["review-anchor"]) |
| review7 = gr.HighlightedText(show_legend=False, label="📝 Review 7", visible=number_of_displayed_reviews >= 7, key="initial_review7") |
| prep_agreement7 = gr.HTML(visible=False, value="") |
| prep_rebuttal7 = gr.HTML(visible=False, value="") |
| gr.HTML(value='<div id="pre-review-anchor-8"></div>', elem_classes=["review-anchor"]) |
| review8 = gr.HighlightedText(show_legend=False, label="📝 Review 8", visible=number_of_displayed_reviews >= 8, key="initial_review8") |
| prep_agreement8 = gr.HTML(visible=False, value="") |
| prep_rebuttal8 = gr.HTML(visible=False, value="") |
| gr.HTML(value='<div id="pre-review-anchor-9"></div>', elem_classes=["review-anchor"]) |
| review9 = gr.HighlightedText(show_legend=False, label="📝 Review 9", visible=number_of_displayed_reviews >= 9, key="initial_review9") |
| prep_agreement9 = gr.HTML(visible=False, value="") |
| prep_rebuttal9 = gr.HTML(visible=False, value="") |
| gr.HTML(value='<div id="pre-review-anchor-10"></div>', elem_classes=["review-anchor"]) |
| review10 = gr.HighlightedText(show_legend=False, label="📝 Review 10", visible=number_of_displayed_reviews >= 10, key="initial_review10") |
| prep_agreement10 = gr.HTML(visible=False, value="") |
| prep_rebuttal10 = gr.HTML(visible=False, value="") |
|
|
| |
| prep_general_rebuttal = gr.HTML(visible=False, value="") |
|
|
| |
| def year_change(year, state, score_type): |
| state["year_choice"] = year |
| state["scored_reviews_for_year"] = get_preprocessed_scores(year) |
| state["metadata_for_year"] = get_preprocessed_metadata(year) |
| state["review_ids"] = list(state["scored_reviews_for_year"].keys()) |
| state["current_review_index"] = 0 |
| state["current_review"] = state["scored_reviews_for_year"][state["review_ids"][0]] |
| return update_review_display(state, score_type) |
|
|
| def next_review(state, score_type): |
| state["current_review_index"] = (state["current_review_index"] + 1) % len(state["review_ids"]) |
| state["current_review"] = state["scored_reviews_for_year"][state["review_ids"][state["current_review_index"]]] |
| return update_review_display(state, score_type) |
|
|
| def previous_review(state, score_type): |
| state["current_review_index"] = (state["current_review_index"] - 1) % len(state["review_ids"]) |
| state["current_review"] = state["scored_reviews_for_year"][state["review_ids"][state["current_review_index"]]] |
| return update_review_display(state, score_type) |
|
|
| |
| _review_outputs = [review_id, review1, review2, review3, review4, review5, review6, review7, review8, review9, review10, prep_agreement1, prep_agreement2, prep_agreement3, prep_agreement4, prep_agreement5, prep_agreement6, prep_agreement7, prep_agreement8, prep_agreement9, prep_agreement10, most_common_sentences, most_unique_sentences, topic_text_box, prep_toggle_bar, prep_rebuttal1, prep_rebuttal2, prep_rebuttal3, prep_rebuttal4, prep_rebuttal5, prep_rebuttal6, prep_rebuttal7, prep_rebuttal8, prep_rebuttal9, prep_rebuttal10, prep_general_rebuttal, state] |
| year.change(fn=year_change, inputs=[year, state, score_type], outputs=_review_outputs) |
| score_type.change(fn=update_review_display, inputs=[state, score_type], outputs=_review_outputs) |
| next_button.click(fn=next_review, inputs=[state, score_type], outputs=_review_outputs) |
| previous_button.click(fn=previous_review, inputs=[state, score_type], outputs=_review_outputs) |
| |
| |
| |
| |
| |
| |
| |
| with gr.Tab("Interactive", interactive=True): |
|
|
| |
| with gr.Row(): |
| paper_title_html = gr.Textbox("", visible=False, interactive=False, show_label=False, container=False, elem_classes=["paper-title-heading"]) |
| back_to_input_btn = gr.Button("✏️ Edit Reviews / New Input", visible=False, variant="secondary") |
| view_results_btn = gr.Button("📊 View Results", visible=False, variant="secondary") |
|
|
| |
| with gr.Column(visible=True) as input_section: |
|
|
| with gr.Tabs(): |
| with gr.Tab("OpenReview Link"): |
| gr.Markdown(""" |
| Paste an OpenReview forum link to automatically fetch and process its reviews. |
| |
| **Example link:** |
| https://openreview.net/forum?id=... |
| """) |
| openreview_link_input = gr.Textbox( |
| label="OpenReview Forum Link", |
| placeholder="https://openreview.net/forum?id=...", |
| interactive=True |
| ) |
| fetch_reviews_button = gr.Button("Fetch & Process", variant="primary", interactive=True) |
| openreview_title = gr.State("") |
| openreview_rebuttal = gr.State("") |
|
|
| with gr.Tab("Paste Reviews Manually"): |
| review1_textbox = gr.Textbox(lines=5, value=EXAMPLES[0], label="📝 Review 1", interactive=True) |
| review2_textbox = gr.Textbox(lines=5, value=EXAMPLES[1], label="📝 Review 2", interactive=True) |
| review3_textbox = gr.Textbox(lines=5, value=EXAMPLES[2], label="📝 Review 3", interactive=True) |
| review4_textbox = gr.Textbox(lines=5, value="", label="📝 Review 4", interactive=True, visible=False) |
| review5_textbox = gr.Textbox(lines=5, value="", label="📝 Review 5", interactive=True, visible=False) |
| review6_textbox = gr.Textbox(lines=5, value="", label="📝 Review 6", interactive=True, visible=False) |
| paste_rebuttal = gr.Textbox(label="💬 Author Rebuttal (optional)", interactive=True, lines=3, placeholder="Paste the author rebuttal here (optional)...") |
| interactive_review_count = gr.State(3) |
| with gr.Row(): |
| add_review_btn = gr.Button("➕ Add Review", variant="secondary", interactive=True) |
| submit_button = gr.Button("Process", variant="primary", interactive=True) |
| clear_button = gr.Button("Clear", variant="secondary", interactive=True) |
|
|
| status_html = gr.HTML("", visible=False) |
|
|
| |
| with gr.Column(visible=False, elem_classes=["results-compact"]) as results_section: |
| with gr.Row(elem_classes=["no-border-row"]): |
| focus_radio = gr.Radio( |
| choices=["No Highlighting", "Polarity", "Topic", "Agreement (Processing)"], |
| value="No Highlighting", |
| label="Display Mode:", |
| interactive=True |
| ) |
|
|
| |
| with gr.Group(elem_classes=["progress-group"]): |
| polarity_progress_html = gr.HTML("", visible=False, elem_classes=["progress-compact"]) |
| agreement_progress_html = gr.HTML("", visible=False, elem_classes=["progress-compact"]) |
|
|
| |
| interactive_legend_html = gr.HTML("", visible=False) |
|
|
| with gr.Row(): |
| most_divergent = gr.HTML( |
| visible=False, value="", label="Most Divergent Opinions", |
| ) |
| most_common = gr.HTML( |
| visible=False, value="", label="Most Common Opinions", |
| ) |
|
|
| interactive_rebuttal_toggle = gr.HTML(visible=False, value="") |
|
|
| |
| gr.HTML(value='<div id="int-review-anchor-1"></div>', elem_classes=["review-anchor"]) |
| none_text1 = gr.HTML(visible=True, value="", elem_id="int-review-1") |
| agreement_text1 = gr.HTML(visible=False, value="") |
| polarity_text1 = gr.HTML(visible=False, value="") |
| topic_text1 = gr.HTML(visible=False, value="") |
| rebuttal_for_review1 = gr.HTML(visible=False, value="") |
|
|
| |
| gr.HTML(value='<div id="int-review-anchor-2"></div>', elem_classes=["review-anchor"]) |
| none_text2 = gr.HTML(visible=False, value="", elem_id="int-review-2") |
| agreement_text2 = gr.HTML(visible=False, value="") |
| polarity_text2 = gr.HTML(visible=False, value="") |
| topic_text2 = gr.HTML(visible=False, value="") |
| rebuttal_for_review2 = gr.HTML(visible=False, value="") |
|
|
| |
| gr.HTML(value='<div id="int-review-anchor-3"></div>', elem_classes=["review-anchor"]) |
| none_text3 = gr.HTML(visible=False, value="", elem_id="int-review-3") |
| agreement_text3 = gr.HTML(visible=False, value="") |
| polarity_text3 = gr.HTML(visible=False, value="") |
| topic_text3 = gr.HTML(visible=False, value="") |
| rebuttal_for_review3 = gr.HTML(visible=False, value="") |
|
|
| |
| gr.HTML(value='<div id="int-review-anchor-4"></div>', elem_classes=["review-anchor"]) |
| none_text4 = gr.HTML(visible=False, value="", elem_id="int-review-4") |
| agreement_text4 = gr.HTML(visible=False, value="") |
| polarity_text4 = gr.HTML(visible=False, value="") |
| topic_text4 = gr.HTML(visible=False, value="") |
| rebuttal_for_review4 = gr.HTML(visible=False, value="") |
|
|
| |
| gr.HTML(value='<div id="int-review-anchor-5"></div>', elem_classes=["review-anchor"]) |
| none_text5 = gr.HTML(visible=False, value="", elem_id="int-review-5") |
| agreement_text5 = gr.HTML(visible=False, value="") |
| polarity_text5 = gr.HTML(visible=False, value="") |
| topic_text5 = gr.HTML(visible=False, value="") |
| rebuttal_for_review5 = gr.HTML(visible=False, value="") |
|
|
| |
| gr.HTML(value='<div id="int-review-anchor-6"></div>', elem_classes=["review-anchor"]) |
| none_text6 = gr.HTML(visible=False, value="", elem_id="int-review-6") |
| agreement_text6 = gr.HTML(visible=False, value="") |
| polarity_text6 = gr.HTML(visible=False, value="") |
| topic_text6 = gr.HTML(visible=False, value="") |
| rebuttal_for_review6 = gr.HTML(visible=False, value="") |
|
|
| |
| interactive_rebuttal_display = gr.HTML(visible=False, value="") |
|
|
| |
|
|
| |
| interactive_rebuttal_state = gr.State("") |
|
|
| |
| processing_thread_state = gr.State(None) |
|
|
| _interactive_inputs = [review1_textbox, review2_textbox, review3_textbox, review4_textbox, review5_textbox, review6_textbox, focus_radio, interactive_rebuttal_state, processing_thread_state] |
|
|
| |
| rsa_computation_state = gr.State({}) |
|
|
| |
| |
| |
| _none_sinks = [gr.State(None) for _ in range(6)] |
|
|
| _interactive_outputs = [ |
| *_none_sinks, |
| agreement_text1, agreement_text2, agreement_text3, agreement_text4, agreement_text5, agreement_text6, |
| most_common, most_divergent, |
| polarity_text1, polarity_text2, polarity_text3, polarity_text4, polarity_text5, polarity_text6, |
| topic_text1, topic_text2, topic_text3, topic_text4, topic_text5, topic_text6, |
| interactive_review_count, |
| rsa_computation_state, |
| ] |
|
|
| |
| _rsa_outputs = [ |
| agreement_text1, agreement_text2, agreement_text3, agreement_text4, agreement_text5, agreement_text6, |
| most_common, most_divergent, |
| agreement_progress_html, |
| focus_radio, |
| ] |
|
|
| |
| def _validate_and_start_fetch(link): |
| if not link or not link.strip(): |
| raise gr.Error("Please paste a valid OpenReview link before fetching.") |
| return gr.update(value=FETCHING_HTML, visible=True), gr.update(interactive=False) |
|
|
| no_title_state = gr.State("") |
|
|
| def _show_raw_and_switch(r1, r2, r3, r4, r5, r6, rebuttal, title=""): |
| """Immediately switch to results view with raw tokenized reviews. |
| Also kicks off polarity+topic in a background thread so processing |
| overlaps with page transition rendering.""" |
| import time as _time |
| from dependencies.Glimpse_tokenizer import glimpse_tokenizer |
| texts = [r1, r2, r3, r4, r5, r6] |
| active_count = sum(1 for t in texts if t and t.strip()) |
|
|
| |
| rebuttal_htmls = [format_rebuttal_for_review(rebuttal or "", i + 1) for i in range(6)] |
| has_per_review = any(rebuttal_htmls) |
|
|
| |
| |
| none_out = [] |
| for idx, t in enumerate(texts): |
| if t and t.strip(): |
| sentences = [s for s in glimpse_tokenizer(t) if s.strip()] |
| plain_items = [(s, {}) for s in sentences] |
| html = build_review_card(f"Review {idx + 1}", review_items=plain_items, mode="plain", rebuttal_html=rebuttal_htmls[idx]) |
| none_out.append(gr.update(visible=True, value=html)) |
| else: |
| none_out.append(gr.update(visible=False, value="")) |
|
|
| |
| per_review = [gr.update(visible=False, value="") for _ in range(6)] |
|
|
| general_formatted = format_general_rebuttals(rebuttal or "") |
| has_any = has_per_review or bool(general_formatted) |
|
|
| |
| right_buttons = [_review_toggle_html()] |
| if has_any: |
| right_buttons.append(_rebuttal_toggle_html()) |
| toggle_bar = ( |
| '<div style="display:flex;align-items:center;gap:8px;">' |
| '<span style="font-size:0.78em;color:#6b7280;white-space:nowrap;">Jump to:</span>' |
| + _jump_buttons_html(active_count) |
| + '<span style="flex:1;"></span>' |
| + "".join(right_buttons) + '</div>' |
| ) |
|
|
| title_text = title.strip() if title and title.strip() else "" |
|
|
| |
| |
| |
| active_texts = [t for t in texts if t and t.strip()] |
| sentence_lists = [] |
| for t in active_texts: |
| sents = [s for s in glimpse_tokenizer(t) if s.strip()] |
| if sents: |
| sentence_lists.append(sents) |
| all_sentences = filter_and_clean_sentences( |
| list(set(s for sl in sentence_lists for s in sl)) |
| ) |
|
|
| processor = get_interactive_processor() |
| _thread_result = {"polarity": None, "topic": None, "error": None} |
|
|
| def _run_polarity_topic(): |
| try: |
| t0 = _time.time() |
| _thread_result["polarity"] = processor.predict_polarity(all_sentences) |
| _thread_result["topic"] = processor.predict_topic(all_sentences) |
| print(f"[TIMING] Early polarity+topic thread done in {_time.time() - t0:.1f}s") |
| except Exception as e: |
| _thread_result["error"] = e |
|
|
| bg_thread = threading.Thread(target=_run_polarity_topic, daemon=True) |
| bg_thread.start() |
| print(f"[TIMING] Background polarity+topic thread started (page transitioning...)") |
|
|
| thread_key = str(uuid.uuid4()) |
| _thread_states[thread_key] = { |
| "thread": bg_thread, |
| "result": _thread_result, |
| "sentence_lists": sentence_lists, |
| "active_texts": active_texts, |
| "all_sentences": all_sentences, |
| } |
|
|
| return ( |
| *none_out, |
| gr.update(visible=False), |
| gr.update(visible=True), |
| gr.update(visible=True), |
| gr.update(visible=bool(title_text), value=title_text), |
| gr.update(visible=False), |
| gr.update(choices=["No Highlighting", "Polarity ⏳", "Topic ⏳", "Agreement ⏳"], |
| value="No Highlighting", interactive=True), |
| gr.update(visible=True, value=POLARITY_PROGRESS_HTML), |
| gr.update(visible=True, value=AGREEMENT_PROGRESS_HTML), |
| gr.update(visible=True, value=toggle_bar), |
| *per_review, |
| gr.update(visible=bool(general_formatted), value=general_formatted), |
| active_count, |
| rebuttal or "", |
| gr.update(visible=False, value=""), |
| thread_key, |
| ) |
|
|
| def _show_results_with_rebuttal(rebuttal, active_count): |
| |
| per_review = [] |
| has_per_review = False |
| for i in range(1, 7): |
| formatted = format_rebuttal_for_review(rebuttal or "", i) |
| if formatted: |
| has_per_review = True |
| per_review.append(gr.update(visible=bool(formatted), value=formatted)) |
|
|
| |
| general_formatted = format_general_rebuttals(rebuttal or "") |
| has_general = bool(general_formatted) |
|
|
| |
| has_any = has_per_review or has_general |
| right_buttons = [_review_toggle_html()] |
| if has_any: |
| right_buttons.append(_rebuttal_toggle_html()) |
| toggle_bar = ( |
| '<div style="display:flex;align-items:center;gap:8px;">' |
| '<span style="font-size:0.78em;color:#6b7280;white-space:nowrap;">Jump to:</span>' |
| + _jump_buttons_html(active_count) |
| + '<span style="flex:1;"></span>' |
| + "".join(right_buttons) + '</div>' |
| ) |
| toggle_update = gr.update(visible=True, value=toggle_bar) |
|
|
| return ( |
| gr.update(visible=False), |
| gr.update(visible=True), |
| gr.update(value=AGREEMENT_PROGRESS_HTML, visible=True), |
| gr.update(visible=True), |
| gr.update(visible=False), |
| gr.update(choices=["No Highlighting", "Polarity", "Topic", "Agreement (Processing)"], value="No Highlighting"), |
| toggle_update, |
| *per_review, |
| gr.update(visible=has_general, value=general_formatted), |
| ) |
|
|
| |
| def toggle_display_mode(focus, active_count): |
| |
| effective_focus = focus.split(" ⏳")[0] if " ⏳" in focus else focus |
| |
| if effective_focus == "Agreement (Processing)": |
| effective_focus = "Agreement" |
|
|
| updates = [] |
| for mode in ["No Highlighting", "Polarity", "Topic", "Agreement"]: |
| for i in range(MAX_INTERACTIVE_REVIEWS): |
| updates.append(gr.update(visible=(mode == effective_focus and i < active_count))) |
|
|
| |
| show_opinions = effective_focus == "Agreement" and focus != "Agreement (Processing)" |
| updates.append(gr.update(visible=False)) |
| updates.append(gr.update(visible=show_opinions)) |
|
|
| |
| if effective_focus == "Polarity": |
| updates.append(gr.update(visible=True, value=_POLARITY_LEGEND)) |
| elif effective_focus == "Topic": |
| updates.append(gr.update(visible=True, value=_TOPIC_LEGEND)) |
| else: |
| updates.append(gr.update(visible=False, value="")) |
|
|
| return tuple(updates) |
|
|
| fetch_reviews_button.click( |
| fn=_validate_and_start_fetch, |
| inputs=[openreview_link_input], |
| outputs=[status_html, fetch_reviews_button] |
| ).success( |
| fn=fetch_openreview_reviews, |
| inputs=[openreview_link_input], |
| outputs=[review1_textbox, review2_textbox, review3_textbox, review4_textbox, review5_textbox, review6_textbox, |
| openreview_title, openreview_rebuttal, status_html] |
| ).success( |
| fn=lambda r4, r5, r6: ( |
| gr.update(visible=bool(r4.strip())), |
| gr.update(visible=bool(r5.strip())), |
| gr.update(visible=bool(r6.strip())), |
| ), |
| inputs=[review4_textbox, review5_textbox, review6_textbox], |
| outputs=[review4_textbox, review5_textbox, review6_textbox] |
| ).success( |
| fn=_show_raw_and_switch, |
| inputs=[review1_textbox, review2_textbox, review3_textbox, review4_textbox, review5_textbox, review6_textbox, |
| openreview_rebuttal, openreview_title], |
| outputs=[none_text1, none_text2, none_text3, none_text4, none_text5, none_text6, |
| input_section, results_section, back_to_input_btn, paper_title_html, view_results_btn, focus_radio, |
| polarity_progress_html, agreement_progress_html, |
| interactive_rebuttal_toggle, |
| rebuttal_for_review1, rebuttal_for_review2, rebuttal_for_review3, |
| rebuttal_for_review4, rebuttal_for_review5, rebuttal_for_review6, |
| interactive_rebuttal_display, interactive_review_count, |
| interactive_rebuttal_state, interactive_legend_html, |
| processing_thread_state] |
| ).success( |
| fn=process_interactive_reviews_fast, |
| inputs=_interactive_inputs, |
| outputs=_interactive_outputs |
| ).success( |
| fn=lambda: ( |
| gr.update(visible=False, value=""), |
| gr.update(choices=["No Highlighting", "Polarity", "Topic", "Agreement ⏳"], interactive=True), |
| ), |
| inputs=[], |
| outputs=[polarity_progress_html, focus_radio] |
| ).success( |
| fn=toggle_display_mode, |
| inputs=[focus_radio, interactive_review_count], |
| outputs=[ |
| none_text1, none_text2, none_text3, none_text4, none_text5, none_text6, |
| polarity_text1, polarity_text2, polarity_text3, polarity_text4, polarity_text5, polarity_text6, |
| topic_text1, topic_text2, topic_text3, topic_text4, topic_text5, topic_text6, |
| agreement_text1, agreement_text2, agreement_text3, agreement_text4, agreement_text5, agreement_text6, |
| most_divergent, most_common, |
| interactive_legend_html, |
| ] |
| ).success( |
| fn=lambda: gr.update(interactive=True), |
| inputs=[], |
| outputs=[fetch_reviews_button] |
| ).success( |
| fn=compute_rsa_in_background, |
| inputs=[rsa_computation_state, focus_radio], |
| outputs=_rsa_outputs |
| ) |
|
|
| |
| submit_button.click( |
| fn=lambda: gr.update(interactive=False), |
| inputs=[], |
| outputs=[submit_button] |
| ).success( |
| fn=_show_raw_and_switch, |
| inputs=[review1_textbox, review2_textbox, review3_textbox, review4_textbox, review5_textbox, review6_textbox, |
| paste_rebuttal, no_title_state], |
| outputs=[none_text1, none_text2, none_text3, none_text4, none_text5, none_text6, |
| input_section, results_section, back_to_input_btn, paper_title_html, view_results_btn, focus_radio, |
| polarity_progress_html, agreement_progress_html, |
| interactive_rebuttal_toggle, |
| rebuttal_for_review1, rebuttal_for_review2, rebuttal_for_review3, |
| rebuttal_for_review4, rebuttal_for_review5, rebuttal_for_review6, |
| interactive_rebuttal_display, interactive_review_count, |
| interactive_rebuttal_state, interactive_legend_html, |
| processing_thread_state] |
| ).success( |
| fn=process_interactive_reviews_fast, |
| inputs=_interactive_inputs, |
| outputs=_interactive_outputs |
| ).success( |
| fn=lambda: ( |
| gr.update(visible=False, value=""), |
| gr.update(choices=["No Highlighting", "Polarity", "Topic", "Agreement ⏳"], interactive=True), |
| ), |
| inputs=[], |
| outputs=[polarity_progress_html, focus_radio] |
| ).success( |
| fn=toggle_display_mode, |
| inputs=[focus_radio, interactive_review_count], |
| outputs=[ |
| none_text1, none_text2, none_text3, none_text4, none_text5, none_text6, |
| polarity_text1, polarity_text2, polarity_text3, polarity_text4, polarity_text5, polarity_text6, |
| topic_text1, topic_text2, topic_text3, topic_text4, topic_text5, topic_text6, |
| agreement_text1, agreement_text2, agreement_text3, agreement_text4, agreement_text5, agreement_text6, |
| most_divergent, most_common, |
| interactive_legend_html, |
| ] |
| ).success( |
| fn=lambda: gr.update(interactive=True), |
| inputs=[], |
| outputs=[submit_button] |
| ).success( |
| fn=compute_rsa_in_background, |
| inputs=[rsa_computation_state, focus_radio], |
| outputs=_rsa_outputs |
| ) |
|
|
| |
| back_to_input_btn.click( |
| fn=lambda: ( |
| gr.update(visible=True), |
| gr.update(visible=False), |
| gr.update(visible=False), |
| gr.update(visible=False), |
| gr.update(visible=True), |
| ), |
| inputs=[], |
| outputs=[input_section, results_section, back_to_input_btn, paper_title_html, view_results_btn] |
| ) |
|
|
| |
| view_results_btn.click( |
| fn=lambda: ( |
| gr.update(visible=False), |
| gr.update(visible=True), |
| gr.update(visible=True), |
| gr.update(visible=True), |
| gr.update(visible=False), |
| ), |
| inputs=[], |
| outputs=[input_section, results_section, back_to_input_btn, paper_title_html, view_results_btn] |
| ) |
|
|
| |
| clear_button.click( |
| fn=lambda: ( |
| "", "", "", "", "", "", |
| "", |
| 3, |
| "", "", |
| *([""] * 6), *([""] * 6), *([""] * 6), *([""] * 6), |
| {}, |
| ), |
| inputs=[], |
| outputs=[ |
| review1_textbox, review2_textbox, review3_textbox, review4_textbox, review5_textbox, review6_textbox, |
| paste_rebuttal, |
| interactive_review_count, |
| most_common, most_divergent, |
| none_text1, none_text2, none_text3, none_text4, none_text5, none_text6, |
| agreement_text1, agreement_text2, agreement_text3, agreement_text4, agreement_text5, agreement_text6, |
| polarity_text1, polarity_text2, polarity_text3, polarity_text4, polarity_text5, polarity_text6, |
| topic_text1, topic_text2, topic_text3, topic_text4, topic_text5, topic_text6, |
| rsa_computation_state, |
| ] |
| ).then( |
| fn=lambda: ( |
| gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), |
| gr.update(visible=False, value=""), |
| gr.update(visible=False, value=""), gr.update(visible=False, value=""), gr.update(visible=False, value=""), |
| gr.update(visible=False, value=""), gr.update(visible=False, value=""), gr.update(visible=False, value=""), |
| gr.update(visible=False, value=""), |
| gr.update(visible=False, value=""), |
| gr.update(visible=False, value=""), |
| gr.update(visible=False, value=""), |
| ), |
| inputs=[], |
| outputs=[review4_textbox, review5_textbox, review6_textbox, |
| interactive_rebuttal_toggle, |
| rebuttal_for_review1, rebuttal_for_review2, rebuttal_for_review3, |
| rebuttal_for_review4, rebuttal_for_review5, rebuttal_for_review6, |
| interactive_rebuttal_display, paper_title_html, |
| polarity_progress_html, agreement_progress_html] |
| ) |
|
|
| |
| _POLARITY_LEGEND = ( |
| '<div style="display:flex;gap:12px;align-items:center;padding:8px 0;font-size:0.8em;">' |
| '<span style="background:#d4fcd6;padding:2px 8px;border-radius:4px;">Positive</span>' |
| '<span style="background:#fcd6d6;padding:2px 8px;border-radius:4px;">Negative</span>' |
| '<span style="color:#9ca3af;">Neutral (no highlight)</span>' |
| '</div>' |
| ) |
| _TOPIC_LEGEND = ( |
| '<div style="display:flex;flex-wrap:wrap;gap:4px;align-items:center;padding:8px 0;">' |
| + " ".join( |
| f'<span style="background:{color};padding:2px 8px;border-radius:4px;' |
| f'font-size:0.8em;margin-right:4px;">{_html.escape(name)}</span>' |
| for name, color in _TOPIC_HTML_COLORS.items() |
| ) |
| + '</div>' |
| ) |
|
|
| focus_radio.change( |
| fn=toggle_display_mode, |
| inputs=[focus_radio, interactive_review_count], |
| outputs=[ |
| none_text1, none_text2, none_text3, none_text4, none_text5, none_text6, |
| polarity_text1, polarity_text2, polarity_text3, polarity_text4, polarity_text5, polarity_text6, |
| topic_text1, topic_text2, topic_text3, topic_text4, topic_text5, topic_text6, |
| agreement_text1, agreement_text2, agreement_text3, agreement_text4, agreement_text5, agreement_text6, |
| most_divergent, most_common, |
| interactive_legend_html, |
| ] |
| ) |
|
|
| |
| def add_review(count): |
| new_count = min(count + 1, MAX_INTERACTIVE_REVIEWS) |
| vis = [gr.update(visible=(i + 4 <= new_count)) for i in range(MAX_INTERACTIVE_REVIEWS - 3)] |
| return (new_count, *vis) |
|
|
| add_review_btn.click( |
| fn=add_review, |
| inputs=[interactive_review_count], |
| outputs=[interactive_review_count, review4_textbox, review5_textbox, review6_textbox] |
| ) |
|
|
| |
| demo.load( |
| fn=lambda s: update_review_display(s, "No Highlighting"), |
| inputs=[state], |
| outputs=_review_outputs, |
| ) |
|
|
| |
| get_interactive_processor() |
|
|
| demo.launch(share=False, ssr_mode=False) |