cjc0013 commited on
Commit
2053154
·
verified ·
1 Parent(s): 1549ae8

Add deterministic exports, tooltips, and experimental relative ranking

Browse files
Files changed (1) hide show
  1. public_space_app.py +416 -79
public_space_app.py CHANGED
@@ -1,9 +1,13 @@
1
  from __future__ import annotations
2
 
 
3
  import html
4
  import json
 
5
  import os
6
  import re
 
 
7
  import urllib.request
8
  from pathlib import Path
9
  from typing import Any, Dict, Tuple
@@ -21,6 +25,12 @@ try:
21
  except ImportError as exc: # pragma: no cover - runtime dependency
22
  raise RuntimeError("pyvis is required to run this Space bundle") from exc
23
 
 
 
 
 
 
 
24
 
25
  def _read_json(source: str) -> Dict[str, Any]:
26
  if source.startswith("http://") or source.startswith("https://"):
@@ -246,15 +256,16 @@ def _space_css() -> str:
246
  margin: 0 auto !important;
247
  padding-bottom: 48px !important;
248
  }
249
- .hero-panel {
250
  background: linear-gradient(135deg, #161c24 0%, #202733 100%);
251
- border: 1px solid rgba(212, 162, 74, 0.34);
252
  border-radius: 24px;
253
  padding: 28px;
254
  margin: 6px 0 20px 0;
255
  box-shadow: 0 14px 34px rgba(0, 0, 0, 0.34);
 
256
  }
257
- .hero-eyebrow {
258
  font-size: 0.82rem;
259
  font-weight: 700;
260
  letter-spacing: 0.08em;
@@ -262,21 +273,21 @@ def _space_css() -> str:
262
  color: #d4a24a;
263
  margin-bottom: 8px;
264
  }
265
- .hero-title {
266
  font-size: 2.2rem;
267
  line-height: 1.1;
268
  font-weight: 800;
269
  color: #fff4e1;
270
  margin: 0 0 12px 0;
271
  }
272
- .hero-lede {
273
  font-size: 1.05rem;
274
  line-height: 1.6;
275
  color: #e2dacd;
276
  margin: 0 0 10px 0;
277
  max-width: 900px;
278
  }
279
- .hero-note {
280
  font-size: 0.98rem;
281
  line-height: 1.5;
282
  color: #eee4d5;
@@ -296,24 +307,29 @@ def _space_css() -> str:
296
  display: inline-block;
297
  text-shadow: none !important;
298
  }
299
- .stat-grid, .story-grid {
300
  display: grid;
301
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
302
  gap: 14px;
303
  margin-top: 18px;
304
  }
305
- .story-grid {
306
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
307
  margin: 10px 0 22px 0;
308
  }
309
- .stat-card, .story-card, .source-card, .glossary-card, .result-card {
 
 
 
 
310
  background: #151b22;
311
- border: 1px solid rgba(212, 162, 74, 0.22);
312
  border-radius: 18px;
313
  padding: 16px 18px;
314
  box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22);
 
315
  }
316
- .stat-label {
317
  font-size: 0.82rem;
318
  font-weight: 700;
319
  text-transform: uppercase;
@@ -321,62 +337,64 @@ def _space_css() -> str:
321
  color: #d4a24a;
322
  margin-bottom: 8px;
323
  }
324
- .stat-value {
325
  font-size: 1.9rem;
326
  font-weight: 800;
327
  color: #fff4e1;
328
  line-height: 1;
329
  margin-bottom: 6px;
330
  }
331
- .stat-help {
332
  font-size: 0.92rem;
333
  color: #d8cfbf;
334
  line-height: 1.45;
335
  }
336
- .story-title, .source-title, .glossary-title {
337
  font-size: 1rem;
338
  font-weight: 800;
339
  color: #fff4e1;
340
  margin-bottom: 6px;
341
  }
342
- .story-body, .source-body, .glossary-body {
343
  font-size: 0.95rem;
344
  line-height: 1.55;
345
  color: #ddd5c8;
346
  }
347
- .source-table {
348
  width: 100%;
349
  border-collapse: collapse;
350
  margin-top: 8px;
351
  font-size: 0.95rem;
 
352
  }
353
- .source-table th, .source-table td {
354
  border-top: 1px solid rgba(212, 162, 74, 0.16);
355
  padding: 12px 10px;
356
  text-align: left;
357
  vertical-align: top;
 
358
  }
359
- .source-table th {
360
  color: #d4a24a;
361
  font-size: 0.82rem;
362
  text-transform: uppercase;
363
  letter-spacing: 0.06em;
364
  width: 32%;
365
  }
366
- .source-table td {
367
  color: #ddd5c8;
368
  }
369
- .glossary-list {
370
  display: grid;
371
  gap: 10px;
372
  margin-top: 8px;
373
  }
374
- .glossary-item strong {
375
  display: block;
376
  color: #fff4e1;
377
  margin-bottom: 2px;
378
  }
379
- .section-kicker {
380
  color: #d4a24a;
381
  font-size: 0.84rem;
382
  font-weight: 700;
@@ -384,19 +402,19 @@ def _space_css() -> str:
384
  text-transform: uppercase;
385
  margin-bottom: 6px;
386
  }
387
- .result-list {
388
  display: flex;
389
  flex-direction: column;
390
  gap: 12px;
391
  margin-top: 10px;
392
  }
393
- .result-head {
394
  display: flex;
395
  justify-content: space-between;
396
  align-items: flex-start;
397
  gap: 12px;
398
  }
399
- .result-rank {
400
  font-size: 0.78rem;
401
  font-weight: 700;
402
  color: #d4a24a;
@@ -404,24 +422,24 @@ def _space_css() -> str:
404
  letter-spacing: 0.06em;
405
  margin-bottom: 4px;
406
  }
407
- .result-title {
408
  font-size: 1.12rem;
409
  font-weight: 800;
410
  color: #fff4e1;
411
  line-height: 1.2;
412
  margin-bottom: 4px;
413
  }
414
- .result-subtitle {
415
  color: #d5cbbb;
416
  font-size: 0.93rem;
417
  }
418
- .metric-stack {
419
  display: flex;
420
  gap: 8px;
421
  flex-wrap: wrap;
422
  justify-content: flex-end;
423
  }
424
- .score-pill, .strength-pill, .chip {
425
  display: inline-block;
426
  border-radius: 999px;
427
  padding: 5px 10px;
@@ -429,26 +447,26 @@ def _space_css() -> str:
429
  font-weight: 700;
430
  white-space: nowrap;
431
  }
432
- .score-pill {
433
  background: #1f5f5b;
434
- color: white;
435
  }
436
- .strength-pill {
437
  background: rgba(212, 162, 74, 0.18);
438
  color: #ffd47a;
439
  border: 1px solid rgba(212, 162, 74, 0.32);
440
  }
441
- .chip-row {
442
  display: flex;
443
  flex-wrap: wrap;
444
  gap: 8px;
445
  margin: 12px 0 10px 0;
446
  }
447
- .chip {
448
  background: rgba(255,255,255,0.08);
449
  color: #ece3d5;
450
  }
451
- .meta-grid {
452
  display: grid;
453
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
454
  gap: 10px;
@@ -456,7 +474,7 @@ def _space_css() -> str:
456
  font-size: 0.9rem;
457
  color: #d6cec2;
458
  }
459
- .meta-grid strong {
460
  display: block;
461
  color: #fff4e1;
462
  margin-bottom: 2px;
@@ -464,19 +482,28 @@ def _space_css() -> str:
464
  text-transform: uppercase;
465
  letter-spacing: 0.04em;
466
  }
467
- .result-hint {
468
  margin-top: 12px;
469
  font-size: 0.88rem;
470
  color: #d4a24a;
471
  }
472
- .panel-note {
473
  background: #151b22;
474
- border: 1px solid rgba(212, 162, 74, 0.22);
475
  border-radius: 18px;
476
  padding: 14px 16px;
477
  color: #ddd5c8;
478
  margin-bottom: 12px;
479
  }
 
 
 
 
 
 
 
 
 
480
  .gradio-container .prose,
481
  .gradio-container .prose p,
482
  .gradio-container .prose li,
@@ -831,6 +858,44 @@ def _confidence_label(value: str) -> str:
831
  }.get(normalized, normalized.title() or "Confidence not labeled")
832
 
833
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
834
  def _edge_evidence_chips(row: Dict[str, Any]) -> list[str]:
835
  urls = _split_pipe_values(row.get("source_urls", ""), limit=12)
836
  reason_codes = set(_split_pipe_values(row.get("reason_codes", ""), limit=20))
@@ -871,14 +936,28 @@ def _window_overlap_text(row: Dict[str, Any]) -> str:
871
  return "not explicit in this row"
872
 
873
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
874
  def _relationship_score(row: Dict[str, Any]) -> int:
875
  status = str(row.get("relationship_status", "") or "")
876
- family = str(row.get("relationship_family", "") or "")
877
- stronger_support = int(
878
- row.get("linked_count", 0) or 0
879
- if family == "recipient"
880
- else row.get("strong_event_count", 0) or 0
881
- )
882
  status_base = {
883
  "linked": 78,
884
  "release_ok": 74,
@@ -894,13 +973,34 @@ def _relationship_score(row: Dict[str, Any]) -> int:
894
  return max(0, min(100, score))
895
 
896
 
897
- def _rank_relationships(edges: pd.DataFrame) -> pd.DataFrame:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
898
  columns = [
899
  "rank",
900
  "relationship_id",
901
  "member",
902
  "counterparty / sector",
903
  "overall score",
 
 
904
  "strength",
905
  "evidence",
906
  "time-window overlap",
@@ -912,26 +1012,30 @@ def _rank_relationships(edges: pd.DataFrame) -> pd.DataFrame:
912
  ]
913
  if edges.empty:
914
  return pd.DataFrame(columns=columns)
 
 
915
  rows: list[dict[str, Any]] = []
916
  for row in edges.to_dict("records"):
917
  family = str(row.get("relationship_family", "") or "")
918
- stronger_support = int(
919
- row.get("linked_count", 0) or 0
920
- if family == "recipient"
921
- else row.get("strong_event_count", 0) or 0
922
- )
923
  caution_support = int(
924
  row.get("review_count", 0) or 0
925
  if family == "recipient"
926
  else row.get("weak_event_count", 0) or 0
927
  )
928
  chips = _edge_evidence_chips(row)
 
 
 
929
  rows.append(
930
  {
931
  "relationship_id": str(row.get("edge_id") or ""),
932
  "member": str(row.get("member_name") or row.get("member_slug") or ""),
933
  "counterparty / sector": str(row.get("target_label") or ""),
934
- "overall score": _relationship_score(row),
 
 
 
935
  "strength": _plain_status_label(str(row.get("relationship_status", "") or "")),
936
  "evidence": " | ".join(chips) if chips else "published source support",
937
  "time-window overlap": _window_overlap_text(row),
@@ -957,6 +1061,7 @@ def _overview_summary_markdown(
957
  family: str,
958
  only_strong_links: bool,
959
  top_n: int,
 
960
  ) -> str:
961
  if ranked.empty:
962
  return "\n".join(
@@ -975,6 +1080,7 @@ def _overview_summary_markdown(
975
  "",
976
  f"- Showing the top `{min(int(top_n), len(ranked))}` `{_plain_family_label(family).lower()}` for `{focus_label}`.",
977
  f"- Filtered to stronger links only: `{str(bool(only_strong_links)).lower()}`.",
 
978
  f"- Highest score in this view: `{int(ranked['overall score'].max())}`.",
979
  "- Pick one relationship below to see the evidence breakdown and coarse evidence window.",
980
  ]
@@ -990,6 +1096,7 @@ def _overview_cards_html(
990
  family: str,
991
  only_strong_links: bool,
992
  top_n: int,
 
993
  ) -> str:
994
  if ranked.empty:
995
  return (
@@ -1005,17 +1112,24 @@ def _overview_cards_html(
1005
  f"<strong>Showing the top {min(int(top_n), len(ranked))} {_plain_family_label(family).lower()}</strong> "
1006
  f"for <strong>{html.escape(focus_label)}</strong>. "
1007
  f"Filtered to stronger links only: <strong>{'yes' if bool(only_strong_links) else 'no'}</strong>. "
1008
- "Pick one relationship below to open the plain-English explanation and evidence window."
 
1009
  "</div>"
1010
  )
1011
  cards: list[str] = []
1012
  for row in ranked.head(int(top_n)).to_dict("records"):
1013
  evidence_chips = [item.strip() for item in str(row.get("evidence", "") or "").split("|") if item.strip()]
1014
- chip_html = "".join(f"<span class=\"chip\">{html.escape(chip)}</span>" for chip in evidence_chips[:6])
 
 
 
1015
  supporting_rows = int(row.get("supporting rows", 0) or 0)
1016
  stronger_support = int(row.get("stronger support", 0) or 0)
1017
  needs_caution = int(row.get("needs caution", 0) or 0)
1018
  unresolved_refs = int(row.get("unresolved refs", 0) or 0)
 
 
 
1019
  cards.append(
1020
  f"""
1021
  <div class="result-card">
@@ -1026,8 +1140,8 @@ def _overview_cards_html(
1026
  <div class="result-subtitle">For {html.escape(str(row.get("member", "") or ""))} in the {_plain_family_label(family).lower()} view.</div>
1027
  </div>
1028
  <div class="metric-stack">
1029
- <span class="score-pill">Score {int(row.get("overall score", 0) or 0)}</span>
1030
- <span class="strength-pill">{html.escape(str(row.get("strength", "") or ""))}</span>
1031
  </div>
1032
  </div>
1033
  <div class="chip-row">{chip_html or '<span class="chip">published source support</span>'}</div>
@@ -1037,8 +1151,10 @@ def _overview_cards_html(
1037
  <div><strong>Stronger support</strong>{stronger_support}</div>
1038
  <div><strong>Needs caution</strong>{needs_caution}</div>
1039
  <div><strong>Unresolved refs</strong>{unresolved_refs}</div>
 
 
1040
  </div>
1041
- <div class="result-hint">Use Explain this link below to open the detailed breakdown for this relationship.</div>
1042
  </div>
1043
  """
1044
  )
@@ -1055,10 +1171,7 @@ def _relationship_options(ranked: pd.DataFrame) -> list[tuple[str, str]]:
1055
  return []
1056
  options: list[tuple[str, str]] = []
1057
  for row in ranked.to_dict("records"):
1058
- label = (
1059
- f"#{int(row['rank'])} {row['counterparty / sector']} "
1060
- f"— {row['strength']} (score {row['overall score']})"
1061
- )
1062
  options.append((label, str(row["relationship_id"])))
1063
  return options
1064
 
@@ -1072,7 +1185,7 @@ def _select_edge_row(edges: pd.DataFrame, relationship_id: str) -> Dict[str, Any
1072
  return matched.head(1).to_dict("records")[0]
1073
 
1074
 
1075
- def _relationship_detail_markdown(edges: pd.DataFrame, relationship_id: str) -> str:
1076
  row = _select_edge_row(edges, relationship_id)
1077
  if not row:
1078
  return "Select a relationship to inspect why it appears in this released slice."
@@ -1080,12 +1193,17 @@ def _relationship_detail_markdown(edges: pd.DataFrame, relationship_id: str) ->
1080
  chips = _edge_evidence_chips(row)
1081
  reason_codes = [_plain_reason_code(item) for item in _split_pipe_values(row.get("reason_codes", ""), limit=8)]
1082
  urls = _split_pipe_values(row.get("source_urls", ""), limit=5)
 
 
 
1083
  lines = [
1084
  f"### {row.get('member_name') or row.get('member_slug')} -> {row.get('target_label')}",
1085
  "",
1086
  f"- Relationship view: `{_plain_family_label(family)}`",
1087
  f"- Strength label: `{_plain_status_label(str(row.get('relationship_status', '') or ''))}`",
1088
- f"- Overall score: `{_relationship_score(row)}`",
 
 
1089
  f"- Supporting relationship rows: `{int(row.get('link_count', 0) or 0)}`",
1090
  f"- Stronger-support rows: `{int(row.get('linked_count', 0) or 0) if family == 'recipient' else int(row.get('strong_event_count', 0) or 0)}`",
1091
  f"- Caution / weaker rows: `{int(row.get('review_count', 0) or 0) if family == 'recipient' else int(row.get('weak_event_count', 0) or 0)}`",
@@ -1108,9 +1226,189 @@ def _relationship_detail_markdown(edges: pd.DataFrame, relationship_id: str) ->
1108
  "- `Integrity-checked` means the release includes a cryptographic fingerprint to help show a published record has not been altered.",
1109
  ]
1110
  )
 
 
 
 
 
 
 
 
 
1111
  return "\n".join(lines)
1112
 
1113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1114
  def _timeline_window_from_url(url: str) -> tuple[int, str, str]:
1115
  normalized = str(url or "").strip()
1116
  if not normalized:
@@ -1517,12 +1815,20 @@ def build_app(copy_path: str | Path):
1517
  overview_member_limit,
1518
  )
1519
 
1520
- def _update_overview(member_query: str, family: str, only_strong: bool, top_n: int, relationship_id: str | None = None):
 
 
 
 
 
 
 
1521
  filtered_edges = _overview_edges(member_query, family, only_strong, int(top_n))
1522
- ranked = _rank_relationships(filtered_edges)
1523
  options = _relationship_options(ranked)
1524
  valid_ids = {value for _, value in options}
1525
  selected = relationship_id if relationship_id in valid_ids else (options[0][1] if options else None)
 
1526
  return (
1527
  _overview_summary_markdown(
1528
  ranked,
@@ -1530,6 +1836,7 @@ def build_app(copy_path: str | Path):
1530
  family=family,
1531
  only_strong_links=only_strong,
1532
  top_n=int(top_n),
 
1533
  ),
1534
  _overview_cards_html(
1535
  ranked,
@@ -1537,15 +1844,33 @@ def build_app(copy_path: str | Path):
1537
  family=family,
1538
  only_strong_links=only_strong,
1539
  top_n=int(top_n),
 
1540
  ),
1541
  gr.update(choices=options, value=selected),
1542
- _relationship_detail_markdown(filtered_edges, selected or ""),
1543
  _relationship_timeline_html(filtered_edges, selected or ""),
 
 
 
1544
  )
1545
 
1546
- def _update_overview_detail(member_query: str, family: str, only_strong: bool, top_n: int, relationship_id: str):
 
 
 
 
 
 
 
1547
  filtered_edges = _overview_edges(member_query, family, only_strong, int(top_n))
1548
- return _relationship_detail_markdown(filtered_edges, relationship_id), _relationship_timeline_html(filtered_edges, relationship_id)
 
 
 
 
 
 
 
1549
 
1550
  def _update_graph(member_query: str, family: str, only_strong: bool, top_n: int):
1551
  review_status = "stronger" if only_strong else "all"
@@ -1660,6 +1985,14 @@ def build_app(copy_path: str | Path):
1660
  choices=[("Sectors", "sector"), ("Funding recipients", "recipient")],
1661
  value="sector",
1662
  )
 
 
 
 
 
 
 
 
1663
  overview_only_strong = gr.Checkbox(label="Only stronger links", value=True)
1664
  overview_top_n = gr.Dropdown(label="Show top results", choices=[5, 10, 15, 20], value=10)
1665
  if example_member_choices:
@@ -1672,27 +2005,31 @@ def build_app(copy_path: str | Path):
1672
  with gr.Row():
1673
  overview_detail_md = gr.Markdown()
1674
  overview_timeline_html = gr.HTML()
 
 
 
 
1675
 
1676
  search_button.click(
1677
  _update_overview,
1678
- [overview_member, overview_family, overview_only_strong, overview_top_n, relationship_choice],
1679
- [overview_summary_md, overview_cards, relationship_choice, overview_detail_md, overview_timeline_html],
1680
  )
1681
  overview_member.submit(
1682
  _update_overview,
1683
- [overview_member, overview_family, overview_only_strong, overview_top_n, relationship_choice],
1684
- [overview_summary_md, overview_cards, relationship_choice, overview_detail_md, overview_timeline_html],
1685
  )
1686
- for control in (overview_family, overview_only_strong, overview_top_n):
1687
  control.change(
1688
  _update_overview,
1689
- [overview_member, overview_family, overview_only_strong, overview_top_n, relationship_choice],
1690
- [overview_summary_md, overview_cards, relationship_choice, overview_detail_md, overview_timeline_html],
1691
  )
1692
  relationship_choice.change(
1693
  _update_overview_detail,
1694
- [overview_member, overview_family, overview_only_strong, overview_top_n, relationship_choice],
1695
- [overview_detail_md, overview_timeline_html],
1696
  )
1697
 
1698
  with gr.Accordion("Explore the network map (optional)", open=False):
@@ -1789,8 +2126,8 @@ def build_app(copy_path: str | Path):
1789
 
1790
  app.load(
1791
  _update_overview,
1792
- [overview_member, overview_family, overview_only_strong, overview_top_n, relationship_choice],
1793
- [overview_summary_md, overview_cards, relationship_choice, overview_detail_md, overview_timeline_html],
1794
  )
1795
  app.load(
1796
  _update_graph,
 
1
  from __future__ import annotations
2
 
3
+ import csv
4
  import html
5
  import json
6
+ import math
7
  import os
8
  import re
9
+ import tempfile
10
+ import textwrap
11
  import urllib.request
12
  from pathlib import Path
13
  from typing import Any, Dict, Tuple
 
25
  except ImportError as exc: # pragma: no cover - runtime dependency
26
  raise RuntimeError("pyvis is required to run this Space bundle") from exc
27
 
28
+ try:
29
+ from reportlab.lib.pagesizes import LETTER
30
+ from reportlab.pdfgen import canvas
31
+ except ImportError as exc: # pragma: no cover - runtime dependency
32
+ raise RuntimeError("reportlab is required to run relationship evidence exports") from exc
33
+
34
 
35
  def _read_json(source: str) -> Dict[str, Any]:
36
  if source.startswith("http://") or source.startswith("https://"):
 
256
  margin: 0 auto !important;
257
  padding-bottom: 48px !important;
258
  }
259
+ .gradio-container .hero-panel {
260
  background: linear-gradient(135deg, #161c24 0%, #202733 100%);
261
+ border: 1px solid rgba(212, 162, 74, 0.34) !important;
262
  border-radius: 24px;
263
  padding: 28px;
264
  margin: 6px 0 20px 0;
265
  box-shadow: 0 14px 34px rgba(0, 0, 0, 0.34);
266
+ color: #ddd5c8 !important;
267
  }
268
+ .gradio-container .hero-eyebrow {
269
  font-size: 0.82rem;
270
  font-weight: 700;
271
  letter-spacing: 0.08em;
 
273
  color: #d4a24a;
274
  margin-bottom: 8px;
275
  }
276
+ .gradio-container .hero-title {
277
  font-size: 2.2rem;
278
  line-height: 1.1;
279
  font-weight: 800;
280
  color: #fff4e1;
281
  margin: 0 0 12px 0;
282
  }
283
+ .gradio-container .hero-lede {
284
  font-size: 1.05rem;
285
  line-height: 1.6;
286
  color: #e2dacd;
287
  margin: 0 0 10px 0;
288
  max-width: 900px;
289
  }
290
+ .gradio-container .hero-note {
291
  font-size: 0.98rem;
292
  line-height: 1.5;
293
  color: #eee4d5;
 
307
  display: inline-block;
308
  text-shadow: none !important;
309
  }
310
+ .gradio-container .stat-grid, .gradio-container .story-grid {
311
  display: grid;
312
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
313
  gap: 14px;
314
  margin-top: 18px;
315
  }
316
+ .gradio-container .story-grid {
317
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
318
  margin: 10px 0 22px 0;
319
  }
320
+ .gradio-container .stat-card,
321
+ .gradio-container .story-card,
322
+ .gradio-container .source-card,
323
+ .gradio-container .glossary-card,
324
+ .gradio-container .result-card {
325
  background: #151b22;
326
+ border: 1px solid rgba(212, 162, 74, 0.22) !important;
327
  border-radius: 18px;
328
  padding: 16px 18px;
329
  box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22);
330
+ color: #ddd5c8 !important;
331
  }
332
+ .gradio-container .stat-label {
333
  font-size: 0.82rem;
334
  font-weight: 700;
335
  text-transform: uppercase;
 
337
  color: #d4a24a;
338
  margin-bottom: 8px;
339
  }
340
+ .gradio-container .stat-value {
341
  font-size: 1.9rem;
342
  font-weight: 800;
343
  color: #fff4e1;
344
  line-height: 1;
345
  margin-bottom: 6px;
346
  }
347
+ .gradio-container .stat-help {
348
  font-size: 0.92rem;
349
  color: #d8cfbf;
350
  line-height: 1.45;
351
  }
352
+ .gradio-container .story-title, .gradio-container .source-title, .gradio-container .glossary-title {
353
  font-size: 1rem;
354
  font-weight: 800;
355
  color: #fff4e1;
356
  margin-bottom: 6px;
357
  }
358
+ .gradio-container .story-body, .gradio-container .source-body, .gradio-container .glossary-body {
359
  font-size: 0.95rem;
360
  line-height: 1.55;
361
  color: #ddd5c8;
362
  }
363
+ .gradio-container .source-table {
364
  width: 100%;
365
  border-collapse: collapse;
366
  margin-top: 8px;
367
  font-size: 0.95rem;
368
+ background: transparent !important;
369
  }
370
+ .gradio-container .source-table th, .gradio-container .source-table td {
371
  border-top: 1px solid rgba(212, 162, 74, 0.16);
372
  padding: 12px 10px;
373
  text-align: left;
374
  vertical-align: top;
375
+ background: transparent !important;
376
  }
377
+ .gradio-container .source-table th {
378
  color: #d4a24a;
379
  font-size: 0.82rem;
380
  text-transform: uppercase;
381
  letter-spacing: 0.06em;
382
  width: 32%;
383
  }
384
+ .gradio-container .source-table td {
385
  color: #ddd5c8;
386
  }
387
+ .gradio-container .glossary-list {
388
  display: grid;
389
  gap: 10px;
390
  margin-top: 8px;
391
  }
392
+ .gradio-container .glossary-item strong {
393
  display: block;
394
  color: #fff4e1;
395
  margin-bottom: 2px;
396
  }
397
+ .gradio-container .section-kicker {
398
  color: #d4a24a;
399
  font-size: 0.84rem;
400
  font-weight: 700;
 
402
  text-transform: uppercase;
403
  margin-bottom: 6px;
404
  }
405
+ .gradio-container .result-list {
406
  display: flex;
407
  flex-direction: column;
408
  gap: 12px;
409
  margin-top: 10px;
410
  }
411
+ .gradio-container .result-head {
412
  display: flex;
413
  justify-content: space-between;
414
  align-items: flex-start;
415
  gap: 12px;
416
  }
417
+ .gradio-container .result-rank {
418
  font-size: 0.78rem;
419
  font-weight: 700;
420
  color: #d4a24a;
 
422
  letter-spacing: 0.06em;
423
  margin-bottom: 4px;
424
  }
425
+ .gradio-container .result-title {
426
  font-size: 1.12rem;
427
  font-weight: 800;
428
  color: #fff4e1;
429
  line-height: 1.2;
430
  margin-bottom: 4px;
431
  }
432
+ .gradio-container .result-subtitle {
433
  color: #d5cbbb;
434
  font-size: 0.93rem;
435
  }
436
+ .gradio-container .metric-stack {
437
  display: flex;
438
  gap: 8px;
439
  flex-wrap: wrap;
440
  justify-content: flex-end;
441
  }
442
+ .gradio-container .score-pill, .gradio-container .strength-pill, .gradio-container .chip {
443
  display: inline-block;
444
  border-radius: 999px;
445
  padding: 5px 10px;
 
447
  font-weight: 700;
448
  white-space: nowrap;
449
  }
450
+ .gradio-container .score-pill {
451
  background: #1f5f5b;
452
+ color: white !important;
453
  }
454
+ .gradio-container .strength-pill {
455
  background: rgba(212, 162, 74, 0.18);
456
  color: #ffd47a;
457
  border: 1px solid rgba(212, 162, 74, 0.32);
458
  }
459
+ .gradio-container .chip-row {
460
  display: flex;
461
  flex-wrap: wrap;
462
  gap: 8px;
463
  margin: 12px 0 10px 0;
464
  }
465
+ .gradio-container .chip {
466
  background: rgba(255,255,255,0.08);
467
  color: #ece3d5;
468
  }
469
+ .gradio-container .meta-grid {
470
  display: grid;
471
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
472
  gap: 10px;
 
474
  font-size: 0.9rem;
475
  color: #d6cec2;
476
  }
477
+ .gradio-container .meta-grid strong {
478
  display: block;
479
  color: #fff4e1;
480
  margin-bottom: 2px;
 
482
  text-transform: uppercase;
483
  letter-spacing: 0.04em;
484
  }
485
+ .gradio-container .result-hint {
486
  margin-top: 12px;
487
  font-size: 0.88rem;
488
  color: #d4a24a;
489
  }
490
+ .gradio-container .panel-note {
491
  background: #151b22;
492
+ border: 1px solid rgba(212, 162, 74, 0.22) !important;
493
  border-radius: 18px;
494
  padding: 14px 16px;
495
  color: #ddd5c8;
496
  margin-bottom: 12px;
497
  }
498
+ .gradio-container .hero-panel *,
499
+ .gradio-container .stat-card *,
500
+ .gradio-container .story-card *,
501
+ .gradio-container .source-card *,
502
+ .gradio-container .glossary-card *,
503
+ .gradio-container .result-card *,
504
+ .gradio-container .panel-note * {
505
+ text-shadow: none !important;
506
+ }
507
  .gradio-container .prose,
508
  .gradio-container .prose p,
509
  .gradio-container .prose li,
 
858
  }.get(normalized, normalized.title() or "Confidence not labeled")
859
 
860
 
861
+ def _evidence_chip_help(label: str) -> str:
862
+ normalized = str(label or "").strip().lower()
863
+ mapping = {
864
+ "trade disclosure": "Public periodic transaction reports or trade disclosures support this relationship.",
865
+ "annual disclosure": "Annual financial disclosure records support this relationship.",
866
+ "bill record": "Bill-status records help show legislative activity in the same topic area.",
867
+ "funding award": "Published federal award records support a funding-recipient link in this slice.",
868
+ "committee roster": "Committee records show committee context related to the same topic area.",
869
+ "vote activity": "Roll-call vote records add legislative activity in the same topic window.",
870
+ "lobbying activity": "Lobbying filings add public activity in the same issue area.",
871
+ "member profile": "Member-published profile or committee context contributes to this relationship summary.",
872
+ "published source support": "This relationship has published source support in the released slice.",
873
+ }
874
+ return mapping.get(normalized, "This chip names one kind of public-record support attached to this relationship.")
875
+
876
+
877
+ def _score_help_text(ranking_mode: str) -> str:
878
+ normalized = str(ranking_mode or "raw").strip().lower()
879
+ if normalized == "relative":
880
+ return (
881
+ "Experimental relative score. It compares this relationship with the same member's other visible "
882
+ "relationships in the current view so unusually strong links stand out against that member's baseline activity."
883
+ )
884
+ return (
885
+ "Raw score. It favors clearer public support, more supporting rows, more integrity-checked records, "
886
+ "and fewer unresolved references."
887
+ )
888
+
889
+
890
+ def _stronger_support_count(row: Dict[str, Any]) -> int:
891
+ family = str(row.get("relationship_family", "") or "")
892
+ return int(
893
+ row.get("linked_count", 0) or 0
894
+ if family == "recipient"
895
+ else row.get("strong_event_count", 0) or 0
896
+ )
897
+
898
+
899
  def _edge_evidence_chips(row: Dict[str, Any]) -> list[str]:
900
  urls = _split_pipe_values(row.get("source_urls", ""), limit=12)
901
  reason_codes = set(_split_pipe_values(row.get("reason_codes", ""), limit=20))
 
936
  return "not explicit in this row"
937
 
938
 
939
+ def _member_activity_baselines(edges: pd.DataFrame) -> Dict[str, Dict[str, float]]:
940
+ if edges.empty:
941
+ return {}
942
+ baselines: Dict[str, Dict[str, float]] = {}
943
+ for member_slug, group in edges.groupby("member_slug", dropna=False):
944
+ slug = str(member_slug or "")
945
+ records = group.to_dict("records")
946
+ raw_scores = [_relationship_score(row) for row in records]
947
+ stronger_counts = [_stronger_support_count(row) for row in records]
948
+ support_counts = [int(row.get("link_count", 0) or 0) for row in records]
949
+ count = max(len(records), 1)
950
+ baselines[slug] = {
951
+ "mean_raw_score": float(sum(raw_scores) / count),
952
+ "mean_stronger_support": float(sum(stronger_counts) / count),
953
+ "mean_support_count": float(sum(support_counts) / count),
954
+ }
955
+ return baselines
956
+
957
+
958
  def _relationship_score(row: Dict[str, Any]) -> int:
959
  status = str(row.get("relationship_status", "") or "")
960
+ stronger_support = _stronger_support_count(row)
 
 
 
 
 
961
  status_base = {
962
  "linked": 78,
963
  "release_ok": 74,
 
973
  return max(0, min(100, score))
974
 
975
 
976
+ def _relative_relationship_score(row: Dict[str, Any], baselines: Dict[str, Dict[str, float]]) -> int:
977
+ member_slug = str(row.get("member_slug") or "")
978
+ baseline = baselines.get(member_slug) or {}
979
+ raw_score = _relationship_score(row)
980
+ mean_raw_score = float(baseline.get("mean_raw_score", raw_score) or raw_score)
981
+ mean_stronger_support = float(
982
+ baseline.get("mean_stronger_support", _stronger_support_count(row)) or _stronger_support_count(row)
983
+ )
984
+ mean_support_count = float(
985
+ baseline.get("mean_support_count", int(row.get("link_count", 0) or 0)) or int(row.get("link_count", 0) or 0)
986
+ )
987
+ relative = 50.0
988
+ relative += (raw_score - mean_raw_score) * 1.2
989
+ relative += (_stronger_support_count(row) - mean_stronger_support) * 5.0
990
+ relative += (int(row.get("link_count", 0) or 0) - mean_support_count) * 2.0
991
+ relative -= min(int(row.get("unresolved_source_ref_count", 0) or 0), 10) * 1.2
992
+ return max(0, min(100, int(round(relative))))
993
+
994
+
995
+ def _rank_relationships(edges: pd.DataFrame, ranking_mode: str = "raw") -> pd.DataFrame:
996
  columns = [
997
  "rank",
998
  "relationship_id",
999
  "member",
1000
  "counterparty / sector",
1001
  "overall score",
1002
+ "raw score",
1003
+ "relative score",
1004
  "strength",
1005
  "evidence",
1006
  "time-window overlap",
 
1012
  ]
1013
  if edges.empty:
1014
  return pd.DataFrame(columns=columns)
1015
+ baselines = _member_activity_baselines(edges)
1016
+ normalized_mode = str(ranking_mode or "raw").strip().lower()
1017
  rows: list[dict[str, Any]] = []
1018
  for row in edges.to_dict("records"):
1019
  family = str(row.get("relationship_family", "") or "")
1020
+ stronger_support = _stronger_support_count(row)
 
 
 
 
1021
  caution_support = int(
1022
  row.get("review_count", 0) or 0
1023
  if family == "recipient"
1024
  else row.get("weak_event_count", 0) or 0
1025
  )
1026
  chips = _edge_evidence_chips(row)
1027
+ raw_score = _relationship_score(row)
1028
+ relative_score = _relative_relationship_score(row, baselines)
1029
+ display_score = relative_score if normalized_mode == "relative" else raw_score
1030
  rows.append(
1031
  {
1032
  "relationship_id": str(row.get("edge_id") or ""),
1033
  "member": str(row.get("member_name") or row.get("member_slug") or ""),
1034
  "counterparty / sector": str(row.get("target_label") or ""),
1035
+ "overall score": display_score,
1036
+ "raw score": raw_score,
1037
+ "relative score": relative_score,
1038
+ "status_code": str(row.get("relationship_status", "") or ""),
1039
  "strength": _plain_status_label(str(row.get("relationship_status", "") or "")),
1040
  "evidence": " | ".join(chips) if chips else "published source support",
1041
  "time-window overlap": _window_overlap_text(row),
 
1061
  family: str,
1062
  only_strong_links: bool,
1063
  top_n: int,
1064
+ ranking_mode: str,
1065
  ) -> str:
1066
  if ranked.empty:
1067
  return "\n".join(
 
1080
  "",
1081
  f"- Showing the top `{min(int(top_n), len(ranked))}` `{_plain_family_label(family).lower()}` for `{focus_label}`.",
1082
  f"- Filtered to stronger links only: `{str(bool(only_strong_links)).lower()}`.",
1083
+ f"- Ranking mode: `{'experimental relative to this member baseline' if str(ranking_mode or 'raw').strip().lower() == 'relative' else 'raw score'}`.",
1084
  f"- Highest score in this view: `{int(ranked['overall score'].max())}`.",
1085
  "- Pick one relationship below to see the evidence breakdown and coarse evidence window.",
1086
  ]
 
1096
  family: str,
1097
  only_strong_links: bool,
1098
  top_n: int,
1099
+ ranking_mode: str,
1100
  ) -> str:
1101
  if ranked.empty:
1102
  return (
 
1112
  f"<strong>Showing the top {min(int(top_n), len(ranked))} {_plain_family_label(family).lower()}</strong> "
1113
  f"for <strong>{html.escape(focus_label)}</strong>. "
1114
  f"Filtered to stronger links only: <strong>{'yes' if bool(only_strong_links) else 'no'}</strong>. "
1115
+ f"Ranking mode: <strong>{'experimental relative to this member baseline' if str(ranking_mode or 'raw').strip().lower() == 'relative' else 'raw score'}</strong>. "
1116
+ "Hover over score badges and evidence chips for why they matter. Pick one relationship below to open the plain-English explanation and evidence window."
1117
  "</div>"
1118
  )
1119
  cards: list[str] = []
1120
  for row in ranked.head(int(top_n)).to_dict("records"):
1121
  evidence_chips = [item.strip() for item in str(row.get("evidence", "") or "").split("|") if item.strip()]
1122
+ chip_html = "".join(
1123
+ f"<span class=\"chip\" title=\"{html.escape(_evidence_chip_help(chip))}\">{html.escape(chip)}</span>"
1124
+ for chip in evidence_chips[:6]
1125
+ )
1126
  supporting_rows = int(row.get("supporting rows", 0) or 0)
1127
  stronger_support = int(row.get("stronger support", 0) or 0)
1128
  needs_caution = int(row.get("needs caution", 0) or 0)
1129
  unresolved_refs = int(row.get("unresolved refs", 0) or 0)
1130
+ raw_score = int(row.get("raw score", 0) or 0)
1131
+ relative_score = int(row.get("relative score", 0) or 0)
1132
+ score_note = _score_help_text(ranking_mode)
1133
  cards.append(
1134
  f"""
1135
  <div class="result-card">
 
1140
  <div class="result-subtitle">For {html.escape(str(row.get("member", "") or ""))} in the {_plain_family_label(family).lower()} view.</div>
1141
  </div>
1142
  <div class="metric-stack">
1143
+ <span class="score-pill" title="{html.escape(score_note)}">Score {int(row.get("overall score", 0) or 0)}</span>
1144
+ <span class="strength-pill" title="{html.escape(_plain_status_explainer(str(row.get('status_code', '') or '')))}">{html.escape(str(row.get("strength", "") or ""))}</span>
1145
  </div>
1146
  </div>
1147
  <div class="chip-row">{chip_html or '<span class="chip">published source support</span>'}</div>
 
1151
  <div><strong>Stronger support</strong>{stronger_support}</div>
1152
  <div><strong>Needs caution</strong>{needs_caution}</div>
1153
  <div><strong>Unresolved refs</strong>{unresolved_refs}</div>
1154
+ <div><strong>Raw score</strong>{raw_score}</div>
1155
+ <div><strong>Relative score</strong>{relative_score}</div>
1156
  </div>
1157
+ <div class="result-hint">Use Explain this link below to open the detailed breakdown and export files for this relationship.</div>
1158
  </div>
1159
  """
1160
  )
 
1171
  return []
1172
  options: list[tuple[str, str]] = []
1173
  for row in ranked.to_dict("records"):
1174
+ label = f"#{int(row['rank'])} {row['counterparty / sector']} - {row['strength']} (score {row['overall score']})"
 
 
 
1175
  options.append((label, str(row["relationship_id"])))
1176
  return options
1177
 
 
1185
  return matched.head(1).to_dict("records")[0]
1186
 
1187
 
1188
+ def _relationship_detail_markdown(edges: pd.DataFrame, relationship_id: str, ranking_mode: str = "raw") -> str:
1189
  row = _select_edge_row(edges, relationship_id)
1190
  if not row:
1191
  return "Select a relationship to inspect why it appears in this released slice."
 
1193
  chips = _edge_evidence_chips(row)
1194
  reason_codes = [_plain_reason_code(item) for item in _split_pipe_values(row.get("reason_codes", ""), limit=8)]
1195
  urls = _split_pipe_values(row.get("source_urls", ""), limit=5)
1196
+ raw_score = _relationship_score(row)
1197
+ relative_score = _relative_relationship_score(row, _member_activity_baselines(edges))
1198
+ display_score = relative_score if str(ranking_mode or "raw").strip().lower() == "relative" else raw_score
1199
  lines = [
1200
  f"### {row.get('member_name') or row.get('member_slug')} -> {row.get('target_label')}",
1201
  "",
1202
  f"- Relationship view: `{_plain_family_label(family)}`",
1203
  f"- Strength label: `{_plain_status_label(str(row.get('relationship_status', '') or ''))}`",
1204
+ f"- Displayed score in this view: `{display_score}`",
1205
+ f"- Raw score: `{raw_score}`",
1206
+ f"- Relative-to-baseline score (experimental): `{relative_score}`",
1207
  f"- Supporting relationship rows: `{int(row.get('link_count', 0) or 0)}`",
1208
  f"- Stronger-support rows: `{int(row.get('linked_count', 0) or 0) if family == 'recipient' else int(row.get('strong_event_count', 0) or 0)}`",
1209
  f"- Caution / weaker rows: `{int(row.get('review_count', 0) or 0) if family == 'recipient' else int(row.get('weak_event_count', 0) or 0)}`",
 
1226
  "- `Integrity-checked` means the release includes a cryptographic fingerprint to help show a published record has not been altered.",
1227
  ]
1228
  )
1229
+ if str(ranking_mode or "raw").strip().lower() == "relative":
1230
+ lines.extend(
1231
+ [
1232
+ "",
1233
+ "#### Ranking note",
1234
+ "",
1235
+ "- This view is using the experimental relative score, which compares this relationship to the same member's other visible links in the current filtered view.",
1236
+ ]
1237
+ )
1238
  return "\n".join(lines)
1239
 
1240
 
1241
+ def _safe_export_stem(value: str) -> str:
1242
+ slug = re.sub(r"[^a-z0-9]+", "-", str(value or "").strip().lower()).strip("-")
1243
+ return slug or "relationship-export"
1244
+
1245
+
1246
+ def _relationship_export_rows(edges: pd.DataFrame, relationship_id: str, ranking_mode: str) -> list[dict[str, Any]]:
1247
+ row = _select_edge_row(edges, relationship_id)
1248
+ if not row:
1249
+ return []
1250
+ raw_score = _relationship_score(row)
1251
+ relative_score = _relative_relationship_score(row, _member_activity_baselines(edges))
1252
+ display_score = relative_score if str(ranking_mode or "raw").strip().lower() == "relative" else raw_score
1253
+ reason_codes = [_plain_reason_code(item) for item in _split_pipe_values(row.get("reason_codes", ""), limit=8)]
1254
+ urls = _split_pipe_values(row.get("source_urls", ""), limit=8)
1255
+ export_rows: list[dict[str, Any]] = [
1256
+ {
1257
+ "relationship_id": str(row.get("edge_id") or ""),
1258
+ "member_name": str(row.get("member_name") or row.get("member_slug") or ""),
1259
+ "target_label": str(row.get("target_label") or ""),
1260
+ "relationship_family": _plain_family_label(str(row.get("relationship_family", "") or "")),
1261
+ "strength_label": _plain_status_label(str(row.get("relationship_status", "") or "")),
1262
+ "ranking_mode": str(ranking_mode or "raw"),
1263
+ "displayed_score": display_score,
1264
+ "raw_score": raw_score,
1265
+ "relative_score": relative_score,
1266
+ "item_type": "summary",
1267
+ "item_label": "relationship summary",
1268
+ "item_detail": "Top-level relationship summary for export.",
1269
+ }
1270
+ ]
1271
+ for chip in _edge_evidence_chips(row):
1272
+ export_rows.append(
1273
+ {
1274
+ "relationship_id": str(row.get("edge_id") or ""),
1275
+ "member_name": str(row.get("member_name") or row.get("member_slug") or ""),
1276
+ "target_label": str(row.get("target_label") or ""),
1277
+ "relationship_family": _plain_family_label(str(row.get("relationship_family", "") or "")),
1278
+ "strength_label": _plain_status_label(str(row.get("relationship_status", "") or "")),
1279
+ "ranking_mode": str(ranking_mode or "raw"),
1280
+ "displayed_score": display_score,
1281
+ "raw_score": raw_score,
1282
+ "relative_score": relative_score,
1283
+ "item_type": "evidence_chip",
1284
+ "item_label": chip,
1285
+ "item_detail": _evidence_chip_help(chip),
1286
+ }
1287
+ )
1288
+ for reason in sorted(reason_codes):
1289
+ export_rows.append(
1290
+ {
1291
+ "relationship_id": str(row.get("edge_id") or ""),
1292
+ "member_name": str(row.get("member_name") or row.get("member_slug") or ""),
1293
+ "target_label": str(row.get("target_label") or ""),
1294
+ "relationship_family": _plain_family_label(str(row.get("relationship_family", "") or "")),
1295
+ "strength_label": _plain_status_label(str(row.get("relationship_status", "") or "")),
1296
+ "ranking_mode": str(ranking_mode or "raw"),
1297
+ "displayed_score": display_score,
1298
+ "raw_score": raw_score,
1299
+ "relative_score": relative_score,
1300
+ "item_type": "reason",
1301
+ "item_label": reason,
1302
+ "item_detail": reason,
1303
+ }
1304
+ )
1305
+ for item in sorted(_plain_strengthener(value) for value in _split_pipe_values(row.get("missing_to_strengthen", ""), limit=12)):
1306
+ export_rows.append(
1307
+ {
1308
+ "relationship_id": str(row.get("edge_id") or ""),
1309
+ "member_name": str(row.get("member_name") or row.get("member_slug") or ""),
1310
+ "target_label": str(row.get("target_label") or ""),
1311
+ "relationship_family": _plain_family_label(str(row.get("relationship_family", "") or "")),
1312
+ "strength_label": _plain_status_label(str(row.get("relationship_status", "") or "")),
1313
+ "ranking_mode": str(ranking_mode or "raw"),
1314
+ "displayed_score": display_score,
1315
+ "raw_score": raw_score,
1316
+ "relative_score": relative_score,
1317
+ "item_type": "what_would_strengthen",
1318
+ "item_label": "What would strengthen it",
1319
+ "item_detail": item,
1320
+ }
1321
+ )
1322
+ for url in sorted(urls):
1323
+ export_rows.append(
1324
+ {
1325
+ "relationship_id": str(row.get("edge_id") or ""),
1326
+ "member_name": str(row.get("member_name") or row.get("member_slug") or ""),
1327
+ "target_label": str(row.get("target_label") or ""),
1328
+ "relationship_family": _plain_family_label(str(row.get("relationship_family", "") or "")),
1329
+ "strength_label": _plain_status_label(str(row.get("relationship_status", "") or "")),
1330
+ "ranking_mode": str(ranking_mode or "raw"),
1331
+ "displayed_score": display_score,
1332
+ "raw_score": raw_score,
1333
+ "relative_score": relative_score,
1334
+ "item_type": "source_url",
1335
+ "item_label": "Published source URL",
1336
+ "item_detail": url,
1337
+ }
1338
+ )
1339
+ return export_rows
1340
+
1341
+
1342
+ def _write_relationship_export_bundle(edges: pd.DataFrame, relationship_id: str, ranking_mode: str) -> tuple[str, str | None, str | None]:
1343
+ export_rows = _relationship_export_rows(edges, relationship_id, ranking_mode)
1344
+ if not export_rows:
1345
+ return "Pick one relationship to generate exportable evidence files.", None, None
1346
+ relationship_id_value = str(export_rows[0]["relationship_id"] or relationship_id)
1347
+ export_dir = Path(tempfile.gettempdir()) / "cmp_space_exports"
1348
+ export_dir.mkdir(parents=True, exist_ok=True)
1349
+ stem = _safe_export_stem(f"{relationship_id_value}-{ranking_mode}")
1350
+ csv_path = export_dir / f"{stem}.csv"
1351
+ pdf_path = export_dir / f"{stem}.pdf"
1352
+
1353
+ fieldnames = [
1354
+ "relationship_id",
1355
+ "member_name",
1356
+ "target_label",
1357
+ "relationship_family",
1358
+ "strength_label",
1359
+ "ranking_mode",
1360
+ "displayed_score",
1361
+ "raw_score",
1362
+ "relative_score",
1363
+ "item_type",
1364
+ "item_label",
1365
+ "item_detail",
1366
+ ]
1367
+ with csv_path.open("w", encoding="utf-8", newline="") as handle:
1368
+ writer = csv.DictWriter(handle, fieldnames=fieldnames)
1369
+ writer.writeheader()
1370
+ for export_row in export_rows:
1371
+ writer.writerow({name: export_row.get(name, "") for name in fieldnames})
1372
+
1373
+ title = f"{export_rows[0]['member_name']} -> {export_rows[0]['target_label']}"
1374
+ pdf = canvas.Canvas(str(pdf_path), pagesize=LETTER, invariant=1)
1375
+ width, height = LETTER
1376
+ left = 54
1377
+ top = height - 54
1378
+ pdf.setTitle("Congress public records relationship export")
1379
+ pdf.setAuthor("Congress Public Records Slice")
1380
+ pdf.setSubject("Deterministic relationship evidence export")
1381
+ pdf.setFont("Helvetica-Bold", 14)
1382
+ pdf.drawString(left, top, title[:95])
1383
+ cursor_y = top - 24
1384
+ pdf.setFont("Helvetica", 10)
1385
+ wrapped_lines: list[str] = [
1386
+ f"Strength label: {export_rows[0]['strength_label']}",
1387
+ f"Ranking mode: {export_rows[0]['ranking_mode']}",
1388
+ f"Displayed score: {export_rows[0]['displayed_score']}",
1389
+ f"Raw score: {export_rows[0]['raw_score']}",
1390
+ f"Relative score: {export_rows[0]['relative_score']}",
1391
+ "",
1392
+ "Export rows included below in deterministic order:",
1393
+ ]
1394
+ for export_row in export_rows:
1395
+ wrapped_lines.append(f"[{export_row['item_type']}] {export_row['item_label']}: {export_row['item_detail']}")
1396
+ for line in wrapped_lines:
1397
+ for wrapped in textwrap.wrap(str(line), width=98) or [""]:
1398
+ if cursor_y < 54:
1399
+ pdf.showPage()
1400
+ cursor_y = height - 54
1401
+ pdf.setFont("Helvetica", 10)
1402
+ pdf.drawString(left, cursor_y, wrapped)
1403
+ cursor_y -= 14
1404
+ pdf.save()
1405
+ note = (
1406
+ f"Prepared deterministic export files for `{relationship_id_value}`. "
1407
+ "The CSV keeps one row per exported evidence item, and the PDF mirrors the same content in a fixed order."
1408
+ )
1409
+ return note, str(csv_path), str(pdf_path)
1410
+
1411
+
1412
  def _timeline_window_from_url(url: str) -> tuple[int, str, str]:
1413
  normalized = str(url or "").strip()
1414
  if not normalized:
 
1815
  overview_member_limit,
1816
  )
1817
 
1818
+ def _update_overview(
1819
+ member_query: str,
1820
+ family: str,
1821
+ only_strong: bool,
1822
+ top_n: int,
1823
+ ranking_mode: str,
1824
+ relationship_id: str | None = None,
1825
+ ):
1826
  filtered_edges = _overview_edges(member_query, family, only_strong, int(top_n))
1827
+ ranked = _rank_relationships(filtered_edges, ranking_mode=ranking_mode)
1828
  options = _relationship_options(ranked)
1829
  valid_ids = {value for _, value in options}
1830
  selected = relationship_id if relationship_id in valid_ids else (options[0][1] if options else None)
1831
+ export_note, export_csv, export_pdf = _write_relationship_export_bundle(filtered_edges, selected or "", ranking_mode)
1832
  return (
1833
  _overview_summary_markdown(
1834
  ranked,
 
1836
  family=family,
1837
  only_strong_links=only_strong,
1838
  top_n=int(top_n),
1839
+ ranking_mode=ranking_mode,
1840
  ),
1841
  _overview_cards_html(
1842
  ranked,
 
1844
  family=family,
1845
  only_strong_links=only_strong,
1846
  top_n=int(top_n),
1847
+ ranking_mode=ranking_mode,
1848
  ),
1849
  gr.update(choices=options, value=selected),
1850
+ _relationship_detail_markdown(filtered_edges, selected or "", ranking_mode),
1851
  _relationship_timeline_html(filtered_edges, selected or ""),
1852
+ export_note,
1853
+ export_csv,
1854
+ export_pdf,
1855
  )
1856
 
1857
+ def _update_overview_detail(
1858
+ member_query: str,
1859
+ family: str,
1860
+ only_strong: bool,
1861
+ top_n: int,
1862
+ ranking_mode: str,
1863
+ relationship_id: str,
1864
+ ):
1865
  filtered_edges = _overview_edges(member_query, family, only_strong, int(top_n))
1866
+ export_note, export_csv, export_pdf = _write_relationship_export_bundle(filtered_edges, relationship_id, ranking_mode)
1867
+ return (
1868
+ _relationship_detail_markdown(filtered_edges, relationship_id, ranking_mode),
1869
+ _relationship_timeline_html(filtered_edges, relationship_id),
1870
+ export_note,
1871
+ export_csv,
1872
+ export_pdf,
1873
+ )
1874
 
1875
  def _update_graph(member_query: str, family: str, only_strong: bool, top_n: int):
1876
  review_status = "stronger" if only_strong else "all"
 
1985
  choices=[("Sectors", "sector"), ("Funding recipients", "recipient")],
1986
  value="sector",
1987
  )
1988
+ overview_ranking_mode = gr.Radio(
1989
+ label="Rank by",
1990
+ choices=[
1991
+ ("Raw score", "raw"),
1992
+ ("Experimental: relative to this member baseline", "relative"),
1993
+ ],
1994
+ value="raw",
1995
+ )
1996
  overview_only_strong = gr.Checkbox(label="Only stronger links", value=True)
1997
  overview_top_n = gr.Dropdown(label="Show top results", choices=[5, 10, 15, 20], value=10)
1998
  if example_member_choices:
 
2005
  with gr.Row():
2006
  overview_detail_md = gr.Markdown()
2007
  overview_timeline_html = gr.HTML()
2008
+ export_note_md = gr.Markdown()
2009
+ with gr.Row():
2010
+ export_csv_file = gr.File(label="Evidence breakdown CSV", interactive=False)
2011
+ export_pdf_file = gr.File(label="Evidence breakdown PDF", interactive=False)
2012
 
2013
  search_button.click(
2014
  _update_overview,
2015
+ [overview_member, overview_family, overview_only_strong, overview_top_n, overview_ranking_mode, relationship_choice],
2016
+ [overview_summary_md, overview_cards, relationship_choice, overview_detail_md, overview_timeline_html, export_note_md, export_csv_file, export_pdf_file],
2017
  )
2018
  overview_member.submit(
2019
  _update_overview,
2020
+ [overview_member, overview_family, overview_only_strong, overview_top_n, overview_ranking_mode, relationship_choice],
2021
+ [overview_summary_md, overview_cards, relationship_choice, overview_detail_md, overview_timeline_html, export_note_md, export_csv_file, export_pdf_file],
2022
  )
2023
+ for control in (overview_family, overview_ranking_mode, overview_only_strong, overview_top_n):
2024
  control.change(
2025
  _update_overview,
2026
+ [overview_member, overview_family, overview_only_strong, overview_top_n, overview_ranking_mode, relationship_choice],
2027
+ [overview_summary_md, overview_cards, relationship_choice, overview_detail_md, overview_timeline_html, export_note_md, export_csv_file, export_pdf_file],
2028
  )
2029
  relationship_choice.change(
2030
  _update_overview_detail,
2031
+ [overview_member, overview_family, overview_only_strong, overview_top_n, overview_ranking_mode, relationship_choice],
2032
+ [overview_detail_md, overview_timeline_html, export_note_md, export_csv_file, export_pdf_file],
2033
  )
2034
 
2035
  with gr.Accordion("Explore the network map (optional)", open=False):
 
2126
 
2127
  app.load(
2128
  _update_overview,
2129
+ [overview_member, overview_family, overview_only_strong, overview_top_n, overview_ranking_mode, relationship_choice],
2130
+ [overview_summary_md, overview_cards, relationship_choice, overview_detail_md, overview_timeline_html, export_note_md, export_csv_file, export_pdf_file],
2131
  )
2132
  app.load(
2133
  _update_graph,