Sina1138 commited on
Commit
a0817e1
·
1 Parent(s): 623f4c7

Refactor summary card rendering: remove _find_corresponding function, streamline HTML structure, and enhance collapsible elements for author responses and divergent opinions.

Browse files
Files changed (1) hide show
  1. interface/Demo.py +128 -181
interface/Demo.py CHANGED
@@ -28,52 +28,6 @@ def _make_sentence_id(sentence: str) -> str:
28
  return "sent_" + hashlib.md5(sentence.encode("utf-8")).hexdigest()[:12]
29
 
30
 
31
- def _find_corresponding(
32
- sent: str,
33
- source_review_indices: list,
34
- sentence_lists: list,
35
- listener: dict,
36
- min_similarity: float = 0.70,
37
- ) -> list:
38
- """
39
- Find the most semantically similar sentence in each OTHER review using
40
- only the listener distribution L_t(d|s).
41
-
42
- Two sentences with similar listener vectors "apply to" the same set of
43
- reviewers → they express the same concept in different words.
44
-
45
- Uses dot product of listener probability vectors (equivalent to
46
- Bhattacharyya coefficient since vectors are already normalized to sum=1).
47
-
48
- Returns: [(review_num, sentence, similarity), ...] for reviews that have
49
- a match above min_similarity.
50
- """
51
- if sent not in listener:
52
- return []
53
-
54
- vec_a = listener[sent] # {R1: prob, R2: prob, ...}
55
- results = []
56
-
57
- for r_idx, sl in enumerate(sentence_lists):
58
- if r_idx in source_review_indices:
59
- continue # skip the source review
60
-
61
- best_sent, best_sim = None, 0.0
62
- for candidate in sl:
63
- if candidate == sent or candidate not in listener:
64
- continue
65
- vec_b = listener[candidate]
66
- # Dot product of probability vectors
67
- sim = sum(vec_a.get(k, 0.0) * vec_b.get(k, 0.0) for k in vec_a)
68
- if sim > best_sim:
69
- best_sim = sim
70
- best_sent = candidate
71
-
72
- if best_sent and best_sim >= min_similarity:
73
- results.append((r_idx + 1, best_sent, best_sim))
74
-
75
- return results
76
-
77
 
78
  def _get_context(sentence: str, sentence_lists: list):
79
  """Return (context_before, context_after) strings for the first review containing sentence."""
@@ -124,29 +78,22 @@ def format_summary_cards(
124
  }
125
 
126
  # Render in the order given — selection and filtering happens in compute_rsa_in_background.
127
- cards_html = (
128
- '<div style="margin-bottom:6px;font-weight:600;font-size:0.9em;color:#374151;">'
129
- 'Most Common Opinions</div>'
130
- )
131
 
132
  for sent in sentences:
133
  sent_id = _make_sentence_id(sent)
134
  context_before, context_after = _get_context(sent, sentence_lists)
135
- ctx_style = "color:#9ca3af;font-size:0.8em;line-height:1.4;font-style:italic;"
136
- before_html = f'<div style="{ctx_style}">...{context_before}</div>' if context_before else ""
137
- after_html = f'<div style="{ctx_style}">{context_after}...</div>' if context_after else ""
138
 
139
  # --- Source badge: which review(s) this sentence physically appears in ---
140
  source_reviews = [r_idx + 1 for r_idx, sl in enumerate(sentence_lists) if sent in sl]
141
  source_badge = " ".join(
142
- f'<span style="background:#f3f4f6;color:#374151;padding:2px 8px;'
143
- f'border-radius:4px;font-size:0.72em;font-weight:600;">From Review {n}</span>'
144
  for n in source_reviews
145
  )
146
 
147
- # --- L_t(d|s) distribution bars: "Applies to" ---
148
- # Shows how much the RSA listener thinks this opinion describes each review,
149
- # even if the sentence only appears in one review's text.
150
  dist_html = ""
151
  if listener and sent in listener:
152
  dist = listener[sent]
@@ -155,22 +102,22 @@ def format_summary_cards(
155
  pct = int(round(prob * 100))
156
  bar_w = max(2, int(prob * 80))
157
  bar_parts.append(
158
- f'<span style="display:inline-flex;align-items:center;gap:3px;margin-right:8px;">'
159
- f'<span style="font-size:0.72em;font-weight:600;color:{badge_fg};">{label}</span>'
160
- f'<span style="display:inline-block;width:{bar_w}px;height:6px;'
161
  f'background:#3b82f6;border-radius:3px;"></span>'
162
- f'<span style="font-size:0.72em;color:#6b7280;">{pct}%</span>'
163
  f'</span>'
164
  )
165
  dist_html = (
166
- f'<div style="display:flex;flex-wrap:wrap;align-items:center;gap:4px;margin-bottom:5px;">'
167
  f'{source_badge}'
168
- f'<span style="color:#d1d5db;font-size:0.8em;">→ Applies to:</span> '
169
  + "".join(bar_parts)
170
  + "</div>"
171
  )
172
  else:
173
- dist_html = f'<div style="display:flex;gap:6px;margin-bottom:6px;">{source_badge}</div>'
174
 
175
  # Click-to-scroll via inline JS
176
  onclick = (
@@ -180,54 +127,33 @@ def format_summary_cards(
180
  f"setTimeout(function(){{el.style.outline='';}},2500);}}}})();"
181
  )
182
 
183
- # --- Corresponding sentences from other reviews ---
184
- corr_html = ""
185
- if listener:
186
- source_indices = [r_idx for r_idx, sl in enumerate(sentence_lists) if sent in sl]
187
- correspondences = _find_corresponding(sent, source_indices, sentence_lists, listener)
188
- if correspondences:
189
- corr_parts = []
190
- for r_num, corr_sent, sim in correspondences:
191
- corr_id = _make_sentence_id(corr_sent)
192
- pct_sim = int(round(sim * 100))
193
- corr_onclick = (
194
- f"event.stopPropagation();"
195
- f"var el=document.getElementById('{corr_id}');"
196
- f"if(el){{el.scrollIntoView({{behavior:'smooth',block:'center'}});"
197
- f"el.style.outline='3px solid #10b981';"
198
- f"setTimeout(function(){{el.style.outline='';}},2500);}}"
199
- )
200
- corr_parts.append(
201
- f'<div style="margin-top:4px;padding:4px 8px;background:#f0fdf4;'
202
- f'border-radius:4px;cursor:pointer;" onclick="{_html.escape(corr_onclick)}">'
203
- f'<span style="font-size:0.72em;font-weight:600;color:#065f46;">'
204
- f'Review {r_num}</span> '
205
- f'<span style="font-size:0.72em;color:#6b7280;">({pct_sim}% match)</span>'
206
- f'<div style="font-size:0.85em;color:#374151;line-height:1.4;">'
207
- f'{_html.escape(corr_sent[:150])}{"..." if len(corr_sent) > 150 else ""}</div>'
208
- f'</div>'
209
- )
210
- corr_html = (
211
- f'<div style="margin-top:6px;border-top:1px solid #e5e7eb;padding-top:6px;">'
212
- f'<div style="font-size:0.72em;font-weight:600;color:#6b7280;margin-bottom:3px;">'
213
- f'Similar in other reviews:</div>'
214
- + "".join(corr_parts)
215
- + "</div>"
216
- )
217
 
218
- cards_html += (
219
  f'<div style="border:1px solid #e5e7eb;border-left:3px solid {border_color};'
220
- f'border-radius:6px;padding:10px 14px;margin-bottom:6px;cursor:pointer;" '
221
  f'onclick="{_html.escape(onclick)}">'
222
  f'{dist_html}'
223
- f'{before_html}'
224
- f'<div style="color:#111827;line-height:1.5;padding:2px 0;">{_html.escape(sent)}</div>'
225
- f'{after_html}'
226
- f'{corr_html}'
 
227
  f'</div>'
228
  )
229
 
230
- return cards_html
 
 
 
 
 
 
 
 
 
231
 
232
 
233
  def format_divergent_cards(
@@ -235,9 +161,9 @@ def format_divergent_cards(
235
  sentence_lists: list,
236
  listener: dict,
237
  speaker: dict,
238
- ) -> str:
239
  """
240
- Most Divergent Opinions hub grouped by reviewer.
241
 
242
  For each review, finds the sentences where argmax(L_t(d|s)) points to that
243
  review (i.e., the listener assigns it most strongly to that reviewer) AND
@@ -246,19 +172,17 @@ def format_divergent_cards(
246
  Shows the top 2 per reviewer.
247
  """
248
  if not uniqueness or not listener or not speaker:
249
- return ""
250
 
251
  import numpy as np
252
  num_reviews = len(speaker)
253
  if num_reviews == 0:
254
- return ""
255
 
256
  median_u = float(np.median(list(uniqueness.values())))
257
  review_labels = [f"R{i+1}" for i in range(num_reviews)]
258
 
259
  # Minimum speaker score to suppress generic filler.
260
- # Sentences below this threshold are claimed weakly by all reviewers — likely filler.
261
- # Baseline uniform = 1/K; we require at least 2× baseline.
262
  k = max(sum(len(v) for v in speaker.values()) // max(len(speaker), 1), 1)
263
  min_speaker_score = 2.0 / k
264
 
@@ -266,7 +190,7 @@ def format_divergent_cards(
266
  grouped: dict = {label: [] for label in review_labels}
267
  for sent, u_score in uniqueness.items():
268
  if u_score <= median_u:
269
- continue # only above-median uniqueness sentences
270
  if sent not in listener:
271
  continue
272
  dist = listener[sent]
@@ -276,7 +200,6 @@ def format_divergent_cards(
276
  if argmax_label not in grouped:
277
  continue
278
  s_score = speaker.get(argmax_label, {}).get(sent, 0.0)
279
- # Suppress sentences that no reviewer claims distinctively
280
  if s_score < min_speaker_score:
281
  continue
282
  grouped[argmax_label].append((sent, u_score, s_score))
@@ -286,39 +209,30 @@ def format_divergent_cards(
286
  grouped[label].sort(key=lambda x: x[2], reverse=True)
287
  grouped[label] = grouped[label][:2]
288
 
289
- # Only render groups that have at least 1 sentence
290
- non_empty = [(label, items) for label, items in grouped.items() if items]
291
- if not non_empty:
292
- return ""
293
-
294
  border_color = "#fca5a5"
295
- html_parts = [
296
- '<div style="margin-bottom:6px;font-weight:600;font-size:0.9em;color:#374151;">'
297
- 'Most Divergent Opinions</div>'
298
- ]
299
-
300
- for label, items in non_empty:
301
- # Reviewer section heading
302
- r_num = label[1:] # "R1" → "1"
303
- html_parts.append(
304
- f'<div style="font-size:0.8em;font-weight:600;color:#7f1d1d;'
305
- f'margin:8px 0 4px 0;">Reviewer {r_num}\'s Unique Points</div>'
306
- )
307
  for sent, u_score, s_score in items:
308
  sent_id = _make_sentence_id(sent)
309
  context_before, context_after = _get_context(sent, sentence_lists)
310
- ctx_style = "color:#9ca3af;font-size:0.8em;line-height:1.4;font-style:italic;"
311
- before_html = f'<div style="{ctx_style}">...{context_before}</div>' if context_before else ""
312
- after_html = f'<div style="{ctx_style}">{context_after}...</div>' if context_after else ""
313
 
314
- # Show dominant listener probability (how strongly this reviewer "owns" this sentence)
315
  dom_pct = 0
316
- if listener and sent in listener:
317
  dom_pct = int(round(max(listener[sent].values(), default=0.0) * 100))
318
  uniqueness_badge = (
319
- f'<span style="background:#fee2e2;color:#991b1b;padding:2px 8px;'
320
- f'border-radius:4px;font-size:0.72em;font-weight:600;margin-bottom:5px;display:inline-block;">'
321
- f'Unique to {label} &nbsp;·&nbsp; {dom_pct}% listener share</span>'
322
  )
323
 
324
  onclick = (
@@ -328,18 +242,25 @@ def format_divergent_cards(
328
  f"setTimeout(function(){{el.style.outline='';}},2500);}}}})();"
329
  )
330
 
 
 
 
331
  html_parts.append(
332
  f'<div style="border:1px solid #e5e7eb;border-left:3px solid {border_color};'
333
- f'border-radius:6px;padding:10px 14px;margin-bottom:6px;cursor:pointer;" '
334
  f'onclick="{_html.escape(onclick)}">'
335
  f'{uniqueness_badge}'
336
- f'{before_html}'
337
- f'<div style="color:#111827;line-height:1.5;padding:2px 0;">{_html.escape(sent)}</div>'
338
- f'{after_html}'
 
 
339
  f'</div>'
340
  )
341
 
342
- return "".join(html_parts)
 
 
343
 
344
 
345
  def render_agreement_html(
@@ -428,8 +349,17 @@ def render_agreement_html(
428
  else:
429
  tooltip_text = f"Score: {score:+.2f}"
430
 
 
 
 
 
 
 
 
 
431
  parts.append(
432
- f'<span id="{sent_id}" class="rsa-sentence" style="background:{bg_color};">'
 
433
  f'{_html.escape(sent)} '
434
  f'<span class="rsa-tooltip">{_html.escape(tooltip_text)}</span>'
435
  f'</span>'
@@ -697,7 +627,6 @@ def format_rebuttal_for_review(rebuttal: str, review_num: int) -> str:
697
 
698
  response_parts.append(
699
  f'<div style="padding:10px 14px;">'
700
- f'<div style="font-size:0.75em;color:#92400e;font-weight:600;margin-bottom:4px;">💬 Author Response:</div>'
701
  f'<div style="white-space:pre-wrap;color:#1f2937;font-size:0.85em;line-height:1.5;">{text}</div>'
702
  f'</div>'
703
  )
@@ -705,18 +634,27 @@ def format_rebuttal_for_review(rebuttal: str, review_num: int) -> str:
705
  if not response_parts:
706
  return ""
707
 
708
- return f'<div style="{CARD_STYLE}">{"".join(response_parts)}</div>'
 
 
 
 
 
 
 
709
 
710
  except (json.JSONDecodeError, TypeError, AttributeError):
711
  # Plain text - show under first review only
712
  if review_num == 1:
713
  text = rebuttal.strip()
714
  return (
715
- f'<div style="{CARD_STYLE}">'
 
 
 
716
  f'<div style="padding:10px 14px;">'
717
- f'<div style="font-size:0.75em;color:#92400e;font-weight:600;margin-bottom:4px;">💬 Author Response:</div>'
718
  f'<div style="white-space:pre-wrap;color:#1f2937;font-size:0.85em;line-height:1.5;">{text}</div>'
719
- f'</div></div>'
720
  )
721
  return ""
722
 
@@ -759,23 +697,26 @@ def format_general_rebuttals(rebuttal: str) -> str:
759
 
760
  count_label = f"{len(response_parts)} general response{'s' if len(response_parts) > 1 else ''}"
761
  return (
762
- f'<div style="{CARD_STYLE}">'
763
- f'<div style="{HEADER_STYLE}"><span style="font-size:1.1em;">💬</span>'
 
764
  f'<span style="{TITLE_STYLE}">General Author Response</span>'
765
- f'<span style="margin-left:auto;font-size:0.8em;color:#78716c;">{count_label}</span></div>'
 
766
  + "".join(response_parts) +
767
- '</div>'
768
  )
769
  except (json.JSONDecodeError, TypeError, AttributeError):
770
  # Plain text - treat as general response
771
  text = rebuttal.strip()
772
  return (
773
- f'<div style="{CARD_STYLE}">'
774
- f'<div style="{HEADER_STYLE}"><span style="font-size:1.1em;">💬</span>'
775
- f'<span style="{TITLE_STYLE}">General Author Response</span></div>'
 
776
  f'<div style="padding:14px 16px;background:white;">'
777
  f'<div style="white-space:pre-wrap;color:#1f2937;font-size:0.9em;line-height:1.6;">{text}</div>'
778
- f'</div></div>'
779
  )
780
 
781
 
@@ -917,13 +858,13 @@ def compute_rsa_in_background(rsa_state: Dict, current_focus: str, progress=gr.P
917
  top_common, uniqueness, sentence_lists, "common",
918
  listener=listener, speaker=speaker,
919
  )
920
- # --- Most Divergent hub (grouped by reviewer) ---
921
- most_unique_text = format_divergent_cards(
922
  uniqueness, sentence_lists, listener, speaker,
923
  )
924
  else:
925
  most_common_text = ""
926
- most_unique_text = ""
927
 
928
  progress(0.90, desc="Formatting agreement results...")
929
 
@@ -937,13 +878,17 @@ def compute_rsa_in_background(rsa_state: Dict, current_focus: str, progress=gr.P
937
  num_reviews=num_reviews,
938
  label=f"Agreement in Review {i + 1}",
939
  )
 
 
 
940
  agree_out.append(gr.update(visible=show_agreement, value=html_val))
941
  else:
942
  agree_out.append(gr.update(visible=False, value=""))
943
 
944
  progress(1.0, desc="Agreement computation complete!")
945
 
946
- return (*agree_out, most_common_text, most_unique_text)
 
947
 
948
  except Exception as e:
949
  print(f"[RSA ERROR] {type(e).__name__}: {str(e)}")
@@ -967,7 +912,7 @@ CUSTOM_CSS = """
967
  margin-top: 16px;
968
  }
969
 
970
- /* RSA sentence tooltip styles */
971
  .rsa-sentence {
972
  position: relative;
973
  cursor: help;
@@ -976,32 +921,32 @@ CUSTOM_CSS = """
976
  display: inline;
977
  }
978
  .rsa-tooltip {
979
- visibility: hidden;
980
- position: absolute;
981
- bottom: 125%;
982
- left: 0;
983
  background: #1f2937;
984
  color: white;
985
  padding: 6px 10px;
986
  border-radius: 6px;
987
  font-size: 0.78em;
988
- white-space: nowrap;
989
- z-index: 100;
990
  pointer-events: none;
991
- max-width: 320px;
 
992
  white-space: normal;
993
  box-shadow: 0 2px 8px rgba(0,0,0,0.25);
994
  }
995
- .rsa-sentence:hover .rsa-tooltip {
996
- visibility: visible;
997
- }
 
998
  """
999
 
1000
  with gr.Blocks(title="ReView", css=CUSTOM_CSS) as demo:
1001
  # gr.Markdown("# ReView Interface")
1002
 
1003
- with gr.Tab("Introduction"):
1004
- gr.Markdown(glimpse_description)
 
1005
 
1006
  # -----------------------------------
1007
  # Pre-processed Tab
@@ -1104,13 +1049,15 @@ with gr.Blocks(title="ReView", css=CUSTOM_CSS) as demo:
1104
  review_item = list(review_data["sentences"].items())
1105
  rebuttal = review_data.get("rebuttal", "")
1106
  if rebuttal and rebuttal.strip():
1107
- # Format rebuttal as HTML card
1108
  rebuttal_html = (
1109
- '<div style="margin-top:8px;margin-bottom:12px;border-radius:6px;overflow:hidden;border:1px solid #fde68a;background:#fffef5;">'
 
 
 
1110
  '<div style="padding:10px 14px;">'
1111
- '<div style="font-size:0.75em;color:#92400e;font-weight:600;margin-bottom:4px;">💬 Author Response:</div>'
1112
  f'<div style="white-space:pre-wrap;color:#1f2937;font-size:0.85em;line-height:1.5;">{rebuttal}</div>'
1113
- '</div></div>'
1114
  )
1115
  else:
1116
  # Backward compatibility with old format
@@ -1653,9 +1600,9 @@ with gr.Blocks(title="ReView", css=CUSTOM_CSS) as demo:
1653
  for i in range(MAX_INTERACTIVE_REVIEWS):
1654
  updates.append(gr.update(visible=(mode == effective_focus and i < active_count)))
1655
 
1656
- # Most common/divergent only show in Agreement mode (and not while processing)
1657
  show_opinions = effective_focus == "Agreement" and focus != "Agreement (Processing)"
1658
- updates.append(gr.update(visible=show_opinions)) # most_divergent
1659
  updates.append(gr.update(visible=show_opinions)) # most_common
1660
 
1661
  return tuple(updates)
 
28
  return "sent_" + hashlib.md5(sentence.encode("utf-8")).hexdigest()[:12]
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  def _get_context(sentence: str, sentence_lists: list):
33
  """Return (context_before, context_after) strings for the first review containing sentence."""
 
78
  }
79
 
80
  # Render in the order given — selection and filtering happens in compute_rsa_in_background.
81
+ ctx_style = "color:#b0b0b0;font-size:0.85em;font-style:italic;"
82
+ cards_parts = []
 
 
83
 
84
  for sent in sentences:
85
  sent_id = _make_sentence_id(sent)
86
  context_before, context_after = _get_context(sent, sentence_lists)
 
 
 
87
 
88
  # --- Source badge: which review(s) this sentence physically appears in ---
89
  source_reviews = [r_idx + 1 for r_idx, sl in enumerate(sentence_lists) if sent in sl]
90
  source_badge = " ".join(
91
+ f'<span style="background:#f3f4f6;color:#374151;padding:2px 6px;'
92
+ f'border-radius:4px;font-size:0.72em;font-weight:600;">R{n}</span>'
93
  for n in source_reviews
94
  )
95
 
96
+ # --- L_t(d|s) distribution bars ---
 
 
97
  dist_html = ""
98
  if listener and sent in listener:
99
  dist = listener[sent]
 
102
  pct = int(round(prob * 100))
103
  bar_w = max(2, int(prob * 80))
104
  bar_parts.append(
105
+ f'<span style="display:inline-flex;align-items:center;gap:2px;margin-right:6px;">'
106
+ f'<span style="font-size:0.7em;font-weight:600;color:{badge_fg};">{label}</span>'
107
+ f'<span style="display:inline-block;width:{bar_w}px;height:5px;'
108
  f'background:#3b82f6;border-radius:3px;"></span>'
109
+ f'<span style="font-size:0.7em;color:#6b7280;">{pct}%</span>'
110
  f'</span>'
111
  )
112
  dist_html = (
113
+ f'<div style="display:flex;flex-wrap:wrap;align-items:center;gap:3px;margin-bottom:3px;">'
114
  f'{source_badge}'
115
+ f'<span style="color:#d1d5db;font-size:0.75em;">→</span> '
116
  + "".join(bar_parts)
117
  + "</div>"
118
  )
119
  else:
120
+ dist_html = f'<div style="display:flex;gap:4px;margin-bottom:3px;">{source_badge}</div>'
121
 
122
  # Click-to-scroll via inline JS
123
  onclick = (
 
127
  f"setTimeout(function(){{el.style.outline='';}},2500);}}}})();"
128
  )
129
 
130
+ # Inline context: ...before SENTENCE after... (all one line)
131
+ before_span = f'<span style="{ctx_style}">...{_html.escape(context_before)} </span>' if context_before else ""
132
+ after_span = f'<span style="{ctx_style}"> {_html.escape(context_after)}...</span>' if context_after else ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
134
+ cards_parts.append(
135
  f'<div style="border:1px solid #e5e7eb;border-left:3px solid {border_color};'
136
+ f'border-radius:6px;padding:8px 12px;margin-bottom:5px;cursor:pointer;" '
137
  f'onclick="{_html.escape(onclick)}">'
138
  f'{dist_html}'
139
+ f'<div style="color:#111827;line-height:1.5;">'
140
+ f'{before_span}'
141
+ f'<span style="font-weight:500;">{_html.escape(sent)}</span>'
142
+ f'{after_span}'
143
+ f'</div>'
144
  f'</div>'
145
  )
146
 
147
+ # Wrap in collapsible <details> (open by default)
148
+ inner = "".join(cards_parts)
149
+ return (
150
+ f'<details open style="margin-bottom:8px;">'
151
+ f'<summary style="cursor:pointer;font-weight:600;font-size:0.9em;color:#374151;'
152
+ f'margin-bottom:6px;list-style:none;">'
153
+ f'<span style="margin-right:4px;">▸</span>Most Common Opinions</summary>'
154
+ f'{inner}'
155
+ f'</details>'
156
+ )
157
 
158
 
159
  def format_divergent_cards(
 
161
  sentence_lists: list,
162
  listener: dict,
163
  speaker: dict,
164
+ ) -> Dict[int, str]:
165
  """
166
+ Most Divergent Opinions — returns per-review HTML dict {review_index: html}.
167
 
168
  For each review, finds the sentences where argmax(L_t(d|s)) points to that
169
  review (i.e., the listener assigns it most strongly to that reviewer) AND
 
172
  Shows the top 2 per reviewer.
173
  """
174
  if not uniqueness or not listener or not speaker:
175
+ return {}
176
 
177
  import numpy as np
178
  num_reviews = len(speaker)
179
  if num_reviews == 0:
180
+ return {}
181
 
182
  median_u = float(np.median(list(uniqueness.values())))
183
  review_labels = [f"R{i+1}" for i in range(num_reviews)]
184
 
185
  # Minimum speaker score to suppress generic filler.
 
 
186
  k = max(sum(len(v) for v in speaker.values()) // max(len(speaker), 1), 1)
187
  min_speaker_score = 2.0 / k
188
 
 
190
  grouped: dict = {label: [] for label in review_labels}
191
  for sent, u_score in uniqueness.items():
192
  if u_score <= median_u:
193
+ continue
194
  if sent not in listener:
195
  continue
196
  dist = listener[sent]
 
200
  if argmax_label not in grouped:
201
  continue
202
  s_score = speaker.get(argmax_label, {}).get(sent, 0.0)
 
203
  if s_score < min_speaker_score:
204
  continue
205
  grouped[argmax_label].append((sent, u_score, s_score))
 
209
  grouped[label].sort(key=lambda x: x[2], reverse=True)
210
  grouped[label] = grouped[label][:2]
211
 
 
 
 
 
 
212
  border_color = "#fca5a5"
213
+ result: Dict[int, str] = {}
214
+
215
+ for i, label in enumerate(review_labels):
216
+ items = grouped[label]
217
+ if not items:
218
+ continue
219
+
220
+ ctx_style = "color:#b0b0b0;font-size:0.85em;font-style:italic;"
221
+ html_parts = [
222
+ '<div style="margin-top:10px;margin-bottom:6px;font-weight:600;font-size:0.82em;color:#7f1d1d;">'
223
+ f'Unique Points in This Review</div>'
224
+ ]
225
  for sent, u_score, s_score in items:
226
  sent_id = _make_sentence_id(sent)
227
  context_before, context_after = _get_context(sent, sentence_lists)
 
 
 
228
 
 
229
  dom_pct = 0
230
+ if sent in listener:
231
  dom_pct = int(round(max(listener[sent].values(), default=0.0) * 100))
232
  uniqueness_badge = (
233
+ f'<span style="background:#fee2e2;color:#991b1b;padding:2px 6px;'
234
+ f'border-radius:4px;font-size:0.7em;font-weight:600;display:inline-block;margin-bottom:3px;">'
235
+ f'{dom_pct}% listener share</span>'
236
  )
237
 
238
  onclick = (
 
242
  f"setTimeout(function(){{el.style.outline='';}},2500);}}}})();"
243
  )
244
 
245
+ before_span = f'<span style="{ctx_style}">...{_html.escape(context_before)} </span>' if context_before else ""
246
+ after_span = f'<span style="{ctx_style}"> {_html.escape(context_after)}...</span>' if context_after else ""
247
+
248
  html_parts.append(
249
  f'<div style="border:1px solid #e5e7eb;border-left:3px solid {border_color};'
250
+ f'border-radius:6px;padding:8px 12px;margin-bottom:5px;cursor:pointer;" '
251
  f'onclick="{_html.escape(onclick)}">'
252
  f'{uniqueness_badge}'
253
+ f'<div style="color:#111827;line-height:1.5;">'
254
+ f'{before_span}'
255
+ f'<span style="font-weight:500;">{_html.escape(sent)}</span>'
256
+ f'{after_span}'
257
+ f'</div>'
258
  f'</div>'
259
  )
260
 
261
+ result[i] = "".join(html_parts)
262
+
263
+ return result
264
 
265
 
266
  def render_agreement_html(
 
349
  else:
350
  tooltip_text = f"Score: {score:+.2f}"
351
 
352
+ # Inline JS positions the tooltip near the cursor using fixed positioning
353
+ hover_js = (
354
+ "var t=this.querySelector('.rsa-tooltip');var r=this.getBoundingClientRect();"
355
+ "t.style.display='block';"
356
+ "t.style.left=Math.min(r.left,window.innerWidth-290)+'px';"
357
+ "t.style.top=(r.top-t.offsetHeight-6)+'px';"
358
+ )
359
+ leave_js = "this.querySelector('.rsa-tooltip').style.display='none';"
360
  parts.append(
361
+ f'<span id="{sent_id}" class="rsa-sentence" style="background:{bg_color};" '
362
+ f'onmouseenter="{hover_js}" onmouseleave="{leave_js}">'
363
  f'{_html.escape(sent)} '
364
  f'<span class="rsa-tooltip">{_html.escape(tooltip_text)}</span>'
365
  f'</span>'
 
627
 
628
  response_parts.append(
629
  f'<div style="padding:10px 14px;">'
 
630
  f'<div style="white-space:pre-wrap;color:#1f2937;font-size:0.85em;line-height:1.5;">{text}</div>'
631
  f'</div>'
632
  )
 
634
  if not response_parts:
635
  return ""
636
 
637
+ return (
638
+ f'<details style="{CARD_STYLE}">'
639
+ f'<summary style="padding:10px 14px;cursor:pointer;font-size:0.75em;color:#92400e;'
640
+ f'font-weight:600;list-style:none;display:flex;align-items:center;gap:6px;">'
641
+ f'<span style="transition:transform 0.2s;">▶</span> Author Response</summary>'
642
+ + "".join(response_parts)
643
+ + "</details>"
644
+ )
645
 
646
  except (json.JSONDecodeError, TypeError, AttributeError):
647
  # Plain text - show under first review only
648
  if review_num == 1:
649
  text = rebuttal.strip()
650
  return (
651
+ f'<details style="{CARD_STYLE}">'
652
+ f'<summary style="padding:10px 14px;cursor:pointer;font-size:0.75em;color:#92400e;'
653
+ f'font-weight:600;list-style:none;display:flex;align-items:center;gap:6px;">'
654
+ f'<span style="transition:transform 0.2s;">▶</span> Author Response</summary>'
655
  f'<div style="padding:10px 14px;">'
 
656
  f'<div style="white-space:pre-wrap;color:#1f2937;font-size:0.85em;line-height:1.5;">{text}</div>'
657
+ f'</div></details>'
658
  )
659
  return ""
660
 
 
697
 
698
  count_label = f"{len(response_parts)} general response{'s' if len(response_parts) > 1 else ''}"
699
  return (
700
+ f'<details style="{CARD_STYLE}">'
701
+ f'<summary style="{HEADER_STYLE}cursor:pointer;list-style:none;">'
702
+ f'<span style="font-size:1.1em;">💬</span>'
703
  f'<span style="{TITLE_STYLE}">General Author Response</span>'
704
+ f'<span style="margin-left:auto;font-size:0.8em;color:#78716c;">{count_label}</span>'
705
+ f'</summary>'
706
  + "".join(response_parts) +
707
+ '</details>'
708
  )
709
  except (json.JSONDecodeError, TypeError, AttributeError):
710
  # Plain text - treat as general response
711
  text = rebuttal.strip()
712
  return (
713
+ f'<details style="{CARD_STYLE}">'
714
+ f'<summary style="{HEADER_STYLE}cursor:pointer;list-style:none;">'
715
+ f'<span style="font-size:1.1em;">💬</span>'
716
+ f'<span style="{TITLE_STYLE}">General Author Response</span></summary>'
717
  f'<div style="padding:14px 16px;background:white;">'
718
  f'<div style="white-space:pre-wrap;color:#1f2937;font-size:0.9em;line-height:1.6;">{text}</div>'
719
+ f'</div></details>'
720
  )
721
 
722
 
 
858
  top_common, uniqueness, sentence_lists, "common",
859
  listener=listener, speaker=speaker,
860
  )
861
+ # --- Most Divergent hub (per-review) ---
862
+ divergent_per_review = format_divergent_cards(
863
  uniqueness, sentence_lists, listener, speaker,
864
  )
865
  else:
866
  most_common_text = ""
867
+ divergent_per_review = {}
868
 
869
  progress(0.90, desc="Formatting agreement results...")
870
 
 
878
  num_reviews=num_reviews,
879
  label=f"Agreement in Review {i + 1}",
880
  )
881
+ # Append this review's divergent cards below the agreement text
882
+ if i in divergent_per_review:
883
+ html_val += divergent_per_review[i]
884
  agree_out.append(gr.update(visible=show_agreement, value=html_val))
885
  else:
886
  agree_out.append(gr.update(visible=False, value=""))
887
 
888
  progress(1.0, desc="Agreement computation complete!")
889
 
890
+ # most_divergent gets empty string — divergent cards are now per-review
891
+ return (*agree_out, most_common_text, "")
892
 
893
  except Exception as e:
894
  print(f"[RSA ERROR] {type(e).__name__}: {str(e)}")
 
912
  margin-top: 16px;
913
  }
914
 
915
+ /* RSA sentence tooltip styles — uses JS positioning via onmouseenter */
916
  .rsa-sentence {
917
  position: relative;
918
  cursor: help;
 
921
  display: inline;
922
  }
923
  .rsa-tooltip {
924
+ display: none;
925
+ position: fixed;
 
 
926
  background: #1f2937;
927
  color: white;
928
  padding: 6px 10px;
929
  border-radius: 6px;
930
  font-size: 0.78em;
931
+ z-index: 10000;
 
932
  pointer-events: none;
933
+ max-width: 280px;
934
+ width: max-content;
935
  white-space: normal;
936
  box-shadow: 0 2px 8px rgba(0,0,0,0.25);
937
  }
938
+
939
+ /* Collapsible author response toggle */
940
+ details summary::-webkit-details-marker { display: none; }
941
+ details[open] summary span:first-child { display: inline-block; transform: rotate(90deg); }
942
  """
943
 
944
  with gr.Blocks(title="ReView", css=CUSTOM_CSS) as demo:
945
  # gr.Markdown("# ReView Interface")
946
 
947
+ # TODO: Uncomment this for home/description tab once finished with testing.
948
+ # with gr.Tab("Introduction"):
949
+ # gr.Markdown(glimpse_description)
950
 
951
  # -----------------------------------
952
  # Pre-processed Tab
 
1049
  review_item = list(review_data["sentences"].items())
1050
  rebuttal = review_data.get("rebuttal", "")
1051
  if rebuttal and rebuttal.strip():
1052
+ # Format rebuttal as collapsible HTML card
1053
  rebuttal_html = (
1054
+ '<details style="margin-top:8px;margin-bottom:12px;border-radius:6px;overflow:hidden;border:1px solid #fde68a;background:#fffef5;">'
1055
+ '<summary style="padding:10px 14px;cursor:pointer;font-size:0.75em;color:#92400e;'
1056
+ 'font-weight:600;list-style:none;display:flex;align-items:center;gap:6px;">'
1057
+ '<span style="transition:transform 0.2s;">▶</span> Author Response</summary>'
1058
  '<div style="padding:10px 14px;">'
 
1059
  f'<div style="white-space:pre-wrap;color:#1f2937;font-size:0.85em;line-height:1.5;">{rebuttal}</div>'
1060
+ '</div></details>'
1061
  )
1062
  else:
1063
  # Backward compatibility with old format
 
1600
  for i in range(MAX_INTERACTIVE_REVIEWS):
1601
  updates.append(gr.update(visible=(mode == effective_focus and i < active_count)))
1602
 
1603
+ # Most common shows in Agreement mode; most_divergent is now per-review (always hidden here)
1604
  show_opinions = effective_focus == "Agreement" and focus != "Agreement (Processing)"
1605
+ updates.append(gr.update(visible=False)) # most_divergent (per-review now, always hidden)
1606
  updates.append(gr.update(visible=show_opinions)) # most_common
1607
 
1608
  return tuple(updates)