HardToFindAGoodUserName commited on
Commit
b5684f8
·
verified ·
1 Parent(s): 0a1a9f9

Benchmark leaderboard by family + author region/institution

Browse files
data/wam.db CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:afa5119bc335d2ad494cd871b265586d52be5b9803ebfd1e9c428e52b48812c7
3
- size 1961984
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:18a23de4441d7a5fb39a67aa4cf714137d9d373f573c5e0119f9796660bfd241
3
+ size 2027520
src/wam/pipeline/extract.py CHANGED
@@ -22,43 +22,62 @@ from wam.store import papers as ps
22
  log = get_logger("pipeline.extract")
23
 
24
  SYSTEM = (
25
- "Extract structured experimental results from this World Action Models paper. Rules:\n"
 
26
  "- Only report numbers ACTUALLY stated in the text — never estimate or invent.\n"
27
- "- Each model/system is identified by (model_name, training_dataset). The SAME name "
28
- "trained/finetuned on a different dataset is a DIFFERENT variant — always capture the "
29
- "training/finetune dataset when stated.\n"
30
- "- For each result give the benchmark, task, metric name+value, and any inference "
31
- "speed/cost with their units and hardware as reported.\n"
32
- "- Set claimed_by_authors=false for numbers quoted from OTHER papers (baselines/"
33
- "comparisons), true for the paper's own results.\n"
34
- "- BE CONCISE: report only this paper's headline results at most ~15 benchmark rows "
35
- "and ~6 model variants (the proposed method + key baselines). Keep every 'notes' field "
36
- "under 12 words; omit notes if not needed.\n"
37
- "- Empty lists are fine if the paper reports no quantitative results.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
 
40
  def run(cfg: Config, client: LLMClient, conn: sqlite3.Connection,
41
  limit: int | None = None) -> int:
 
42
  cap = limit or int(cfg.get("constants.analyze_cap", 40))
43
  todo = ps.needs_extract(conn, limit=cap)
44
- log.info("extracting benchmarks for %d core papers (cap=%d)", len(todo), cap)
 
45
  done = 0
46
- for row in todo:
47
- links = json.loads(row["links_json"] or "{}")
48
- # 35k chars (~9k tokens) comfortably covers intro+results+tables while staying fast.
49
- text = pdf.get_text(cfg, row["id"], links.get("pdf") or "", max_chars=35000)
50
- body = text or row["abstract"] or ""
51
- user = (f"Title: {row['title']}\n\nAbstract: {row['abstract'] or ''}\n\n"
52
- f"Paper text (may be truncated):\n{body}")
53
- try:
54
- res = client.complete_json("extract", SYSTEM, user, ExtractionResult,
55
- label="extract", max_tokens=12000)
56
- except Exception as e: # noqa: BLE001
57
- log.warning("extract failed for %s: %s", row["id"], e)
58
- continue
59
- n_models, n_rows = bm.store_extraction(conn, row["id"], res.models, res.benchmarks)
60
- done += 1
61
- log.debug("%s -> %d variants, %d benchmark rows", row["id"], n_models, n_rows)
62
- conn.commit()
63
  log.info("extracted benchmarks from %d papers", done)
64
  return done
 
22
  log = get_logger("pipeline.extract")
23
 
24
  SYSTEM = (
25
+ "Extract structured experimental results from this embodied-AI / World-Action-Model paper. "
26
+ "Rules:\n"
27
  "- Only report numbers ACTUALLY stated in the text — never estimate or invent.\n"
28
+ "- Use the benchmark's STANDARD NAME and the variant when given, e.g. LIBERO, LIBERO-Long, "
29
+ "LIBERO-Goal, CALVIN (ABC->D), RoboTwin, SimplerEnv, RLBench, Meta-World, ManiSkill, "
30
+ "RoboCasa, Open-X, ALFWorld, VBench/VBench-Long. NEVER use a table caption ('Table 2'), a "
31
+ "section title, or a single task name ('Put Cube into Cup') as the benchmark — those tasks "
32
+ "go in the 'task' field, with the benchmark in 'benchmark'.\n"
33
+ "- Prefer the HEADLINE/aggregate number per (model, benchmark) (e.g. overall success rate "
34
+ "/ average), not every per-task row. Avoid generic names like 'Average' or 'Table N'.\n"
35
+ "- Each model is identified by (model_name, training_dataset). The SAME name trained/"
36
+ "finetuned on a different dataset is a DIFFERENT variant capture the training dataset.\n"
37
+ "- Give metric name+value and any inference speed/cost with units + hardware as reported.\n"
38
+ "- claimed_by_authors=false for numbers quoted from OTHER papers (baselines), true for the "
39
+ "paper's own results.\n"
40
+ "- BE CONCISE: at most ~15 rows and ~8 model variants (proposed method + key baselines on "
41
+ "the main benchmarks). Notes under 12 words or omit. Empty lists are fine.")
42
+
43
+
44
+ def _extract_one(cfg: Config, client: LLMClient, row) -> tuple[str, object | None]:
45
+ """Worker: fetch PDF text + extract. Returns (paper_id, ExtractionResult|None). No DB."""
46
+ links = json.loads(row["links_json"] or "{}")
47
+ text = pdf.get_text(cfg, row["id"], links.get("pdf") or "", max_chars=35000)
48
+ body = text or row["abstract"] or ""
49
+ user = (f"Title: {row['title']}\n\nAbstract: {row['abstract'] or ''}\n\n"
50
+ f"Paper text (may be truncated):\n{body}")
51
+ try:
52
+ res = client.complete_json("extract", SYSTEM, user, ExtractionResult,
53
+ label="extract", max_tokens=6000)
54
+ return row["id"], res
55
+ except Exception as e: # noqa: BLE001
56
+ log.warning("extract failed for %s: %s", row["id"], e)
57
+ return row["id"], None
58
 
59
 
60
  def run(cfg: Config, client: LLMClient, conn: sqlite3.Connection,
61
  limit: int | None = None) -> int:
62
+ from concurrent.futures import ThreadPoolExecutor, as_completed
63
  cap = limit or int(cfg.get("constants.analyze_cap", 40))
64
  todo = ps.needs_extract(conn, limit=cap)
65
+ workers = int(cfg.get("constants.extract_workers", 6))
66
+ log.info("extracting benchmarks for %d papers (%d workers)", len(todo), workers)
67
  done = 0
68
+ # LLM calls run concurrently (I/O-bound); each result is committed in the main thread AS
69
+ # IT COMPLETES (as_completed, not map) so a slow/stuck paper never blocks committing others.
70
+ with ThreadPoolExecutor(max_workers=workers) as ex:
71
+ futs = {ex.submit(_extract_one, cfg, client, r): r["id"] for r in todo}
72
+ for fut in as_completed(futs):
73
+ pid, res = fut.result()
74
+ if res is not None:
75
+ bm.store_extraction(conn, pid, res.models, res.benchmarks)
76
+ else:
77
+ conn.execute("UPDATE papers SET benchmarks_extracted=1 WHERE id=?", (pid,))
78
+ conn.commit()
79
+ done += 1
80
+ if done % 20 == 0:
81
+ log.info("extract progress: %d/%d", done, len(todo))
 
 
 
82
  log.info("extracted benchmarks from %d papers", done)
83
  return done
src/wam/pipeline/people.py CHANGED
@@ -25,9 +25,13 @@ from wam.store.people import slug
25
  log = get_logger("pipeline.people")
26
 
27
 
28
- class Directions(BaseModel):
29
  directions: str = Field(description="1-2 sentences on this author's main WAM-related "
30
  "research directions, grounded in the listed papers")
 
 
 
 
31
 
32
 
33
  def run(cfg: Config, client: LLMClient, conn: sqlite3.Connection,
@@ -72,21 +76,35 @@ def run(cfg: Config, client: LLMClient, conn: sqlite3.Connection,
72
  conn.execute("DELETE FROM authors")
73
  conn.execute("DELETE FROM groups")
74
 
 
75
  kept = set(ranked)
76
  for aid in ranked:
77
  e = influential[aid]
78
  titles = "\n".join(f"- {meta[p]['title']}: {meta[p]['tldr']}" for p in e["pids"]
79
  if p in meta)
 
 
 
 
 
 
 
 
 
 
80
  try:
81
- directions = client.complete_json(
82
- "cheap", "Summarize an author's research directions for a WAM digest. Write "
83
- "in English.", f"Author: {e['name']}\nTracked papers:\n{titles}", Directions,
84
- label="author-directions", max_tokens=2000).directions
 
 
 
 
85
  except Exception as ex: # noqa: BLE001
86
- log.warning("directions failed for %s: %s", e["name"], ex)
87
- directions = None
88
  store.upsert_author(
89
- conn, author_id=aid, name=e["name"], affiliation=e["affiliation"],
90
  s2_url=f"https://www.semanticscholar.org/author/{aid}", citations=e["citations"],
91
  h_index=e["h_index"], paper_ids=sorted(e["pids"]), directions=directions)
92
  conn.commit()
 
25
  log = get_logger("pipeline.people")
26
 
27
 
28
+ class AuthorProfile(BaseModel):
29
  directions: str = Field(description="1-2 sentences on this author's main WAM-related "
30
  "research directions, grounded in the listed papers")
31
+ affiliation: str | None = Field(default=None, description="the author's institution as "
32
+ "stated in the papers' author/affiliation block, else null")
33
+ region: str | None = Field(default=None, description="country/region of that institution "
34
+ "(e.g. 'China', 'USA', 'Singapore'), else null")
35
 
36
 
37
  def run(cfg: Config, client: LLMClient, conn: sqlite3.Connection,
 
76
  conn.execute("DELETE FROM authors")
77
  conn.execute("DELETE FROM groups")
78
 
79
+ from wam.sources import pdf
80
  kept = set(ranked)
81
  for aid in ranked:
82
  e = influential[aid]
83
  titles = "\n".join(f"- {meta[p]['title']}: {meta[p]['tldr']}" for p in e["pids"]
84
  if p in meta)
85
+ # Author/affiliation block lives on the PDF first page — give the model that text
86
+ # (cached only, no download) so institution + region are grounded, not guessed.
87
+ blocks = []
88
+ for p in sorted(e["pids"])[:2]:
89
+ txt = pdf.get_text(cfg, p, "", max_chars=2500)
90
+ if txt:
91
+ blocks.append(f"[{meta.get(p,{}).get('title','')}]\n{txt}")
92
+ ctx = (f"Author: {e['name']}\nTracked papers:\n{titles}\n\n"
93
+ f"Author/affiliation blocks (first pages):\n" + "\n---\n".join(blocks))
94
+ aff, region, directions = e["affiliation"], None, None
95
  try:
96
+ prof = client.complete_json(
97
+ "cheap", "For the given author, summarize their WAM research directions and, "
98
+ "FROM the author/affiliation blocks only, extract their institution and "
99
+ "country/region. If a field isn't in the text, use null. Write in English.",
100
+ ctx, AuthorProfile, label="author-profile", max_tokens=2000)
101
+ directions = prof.directions
102
+ aff = prof.affiliation or e["affiliation"]
103
+ region = prof.region
104
  except Exception as ex: # noqa: BLE001
105
+ log.warning("profile failed for %s: %s", e["name"], ex)
 
106
  store.upsert_author(
107
+ conn, author_id=aid, name=e["name"], affiliation=aff, region=region,
108
  s2_url=f"https://www.semanticscholar.org/author/{aid}", citations=e["citations"],
109
  h_index=e["h_index"], paper_ids=sorted(e["pids"]), directions=directions)
110
  conn.commit()
src/wam/render/readme.py CHANGED
@@ -79,20 +79,43 @@ def _core_table(conn: sqlite3.Connection, limit: int = 50) -> str:
79
  return "\n".join(out) + "\n"
80
 
81
 
82
- def _leaderboard(conn: sqlite3.Connection, limit: int = 40) -> str:
 
 
 
83
  rows = conn.execute(
84
  "SELECT model_name, training_dataset, benchmark, task, metric_name, metric_value, "
85
- "claimed_by_authors FROM benchmarks WHERE metric_value IS NOT NULL "
86
- "ORDER BY benchmark, metric_value DESC LIMIT ?", (limit,)).fetchall()
87
  if not rows:
88
  return "_No benchmark results extracted yet._\n"
89
- out = ["| Benchmark | Task | Model (training data) | Metric | Value | Source |",
90
- "|-----------|------|-----------------------|--------|------:|:------:|"]
91
  for r in rows:
92
- td = f" _({r['training_dataset']})_" if r["training_dataset"] else ""
93
- src = "authors" if r["claimed_by_authors"] else "3rd-party"
94
- out.append(f"| {r['benchmark']} | {r['task'] or '—'} | {r['model_name']}{td} | "
95
- f"{r['metric_name'] or '—'} | {r['metric_value']} | {src} |")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  return "\n".join(out) + "\n"
97
 
98
 
 
79
  return "\n".join(out) + "\n"
80
 
81
 
82
+ def _leaderboard(conn: sqlite3.Connection, top_per: int = 10) -> str:
83
+ """Per-benchmark leaderboards grouped by canonical family (key embodied benchmarks first)."""
84
+ from wam.store.benchmarks import BENCH_FAMILIES, normalize_benchmark
85
+
86
  rows = conn.execute(
87
  "SELECT model_name, training_dataset, benchmark, task, metric_name, metric_value, "
88
+ "claimed_by_authors FROM benchmarks WHERE metric_value IS NOT NULL").fetchall()
 
89
  if not rows:
90
  return "_No benchmark results extracted yet._\n"
91
+ fam: dict[str, list] = {}
 
92
  for r in rows:
93
+ f = normalize_benchmark(r["benchmark"])
94
+ if f:
95
+ fam.setdefault(f, []).append(r)
96
+ key = [f for f in BENCH_FAMILIES if f in fam]
97
+ other = sorted((f for f in fam if f not in BENCH_FAMILIES), key=lambda f: -len(fam[f]))
98
+ out = ["_Model identity = (model, training data); same name on different data is a distinct "
99
+ "row. `authors` = self-reported, `3rd-party` = quoted. Higher is better for "
100
+ "success-rate-style metrics._\n"]
101
+ for f in key + other[:12]:
102
+ seen, items = set(), []
103
+ for r in sorted(fam[f], key=lambda x: x["metric_value"], reverse=True):
104
+ k = (r["model_name"], r["training_dataset"], r["metric_name"])
105
+ if k in seen:
106
+ continue
107
+ seen.add(k)
108
+ items.append(r)
109
+ if len(items) >= top_per:
110
+ break
111
+ out.append(f"\n#### {f} · _{len(fam[f])} results_\n")
112
+ out.append("| Model (training data) | Task | Metric | Value | Source |")
113
+ out.append("|-----------------------|------|--------|------:|:------:|")
114
+ for r in items:
115
+ td = f" _({r['training_dataset']})_" if r["training_dataset"] else ""
116
+ src = "authors" if r["claimed_by_authors"] else "3rd-party"
117
+ out.append(f"| {r['model_name']}{td} | {r['task'] or '—'} | {r['metric_name'] or '—'} "
118
+ f"| {r['metric_value']} | {src} |")
119
  return "\n".join(out) + "\n"
120
 
121
 
src/wam/store/benchmarks.py CHANGED
@@ -15,6 +15,39 @@ from datetime import date, datetime
15
  from wam.pipeline.schemas import BenchmarkRow, ModelVariant
16
 
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  def variant_key(model_name: str, training_dataset: str | None) -> str:
19
  base = f"{model_name}|{training_dataset or 'unknown'}".lower()
20
  return re.sub(r"[^a-z0-9]+", "-", base).strip("-")
 
15
  from wam.pipeline.schemas import BenchmarkRow, ModelVariant
16
 
17
 
18
+ # Canonical embodied-AI benchmark families (raw name -> family via alias substring match).
19
+ BENCH_FAMILIES: dict[str, list[str]] = {
20
+ "LIBERO": ["libero"],
21
+ "CALVIN": ["calvin"],
22
+ "RoboTwin": ["robotwin", "robo twin"],
23
+ "SimplerEnv": ["simpler"],
24
+ "RLBench": ["rlbench"],
25
+ "Meta-World": ["meta-world", "metaworld", "meta world"],
26
+ "ManiSkill": ["maniskill", "mani-skill", "mani skill"],
27
+ "RoboCasa": ["robocasa"],
28
+ "Open-X / RT": ["open-x", "openx", "open x", "rt-1", "rt-2", "rt-x", "bridgedata", "bridge data"],
29
+ "ALFWorld": ["alfworld"],
30
+ "VBench": ["vbench"],
31
+ "AgiBot / GENIE": ["agibot", "genie"],
32
+ "Habitat": ["habitat"],
33
+ "BEHAVIOR": ["behavior-1k", "behavior1k", "behavior "],
34
+ }
35
+ # Junk that the extractor sometimes emits as a "benchmark".
36
+ _BENCH_JUNK = re.compile(r"^(table\s*\d|fig\.?\s*\d|average|overall|search|real[\s-]?robot|"
37
+ r"main results?|ablation|results?|n/?a|simulation|sim)\b", re.I)
38
+
39
+
40
+ def normalize_benchmark(raw: str | None) -> str | None:
41
+ """Map a raw benchmark string to a canonical family; return None for junk/empty."""
42
+ if not raw or not raw.strip() or _BENCH_JUNK.match(raw.strip()):
43
+ return None
44
+ low = raw.lower()
45
+ for family, aliases in BENCH_FAMILIES.items():
46
+ if any(a in low for a in aliases):
47
+ return family
48
+ return raw.strip()
49
+
50
+
51
  def variant_key(model_name: str, training_dataset: str | None) -> str:
52
  base = f"{model_name}|{training_dataset or 'unknown'}".lower()
53
  return re.sub(r"[^a-z0-9]+", "-", base).strip("-")
src/wam/store/db.py CHANGED
@@ -36,6 +36,9 @@ def _migrate(conn: sqlite3.Connection) -> None:
36
  cols = {r["name"] for r in conn.execute("PRAGMA table_info(papers)")}
37
  if "benchmarks_extracted" not in cols:
38
  conn.execute("ALTER TABLE papers ADD COLUMN benchmarks_extracted INTEGER DEFAULT 0")
 
 
 
39
  conn.commit()
40
 
41
 
 
36
  cols = {r["name"] for r in conn.execute("PRAGMA table_info(papers)")}
37
  if "benchmarks_extracted" not in cols:
38
  conn.execute("ALTER TABLE papers ADD COLUMN benchmarks_extracted INTEGER DEFAULT 0")
39
+ acols = {r["name"] for r in conn.execute("PRAGMA table_info(authors)")}
40
+ if "region" not in acols:
41
+ conn.execute("ALTER TABLE authors ADD COLUMN region TEXT")
42
  conn.commit()
43
 
44
 
src/wam/store/papers.py CHANGED
@@ -87,10 +87,11 @@ def needs_innovation(conn: sqlite3.Connection, limit: int | None = None) -> list
87
 
88
 
89
  def needs_extract(conn: sqlite3.Connection, limit: int | None = None) -> list[sqlite3.Row]:
90
- # core papers that have been analyzed but not yet benchmark-extracted
 
91
  return conn.execute(_limit_clause(
92
- "SELECT id, source, title, abstract, links_json FROM papers WHERE track='core' "
93
- "AND analysis_json IS NOT NULL AND benchmarks_extracted=0", limit)).fetchall()
94
 
95
 
96
  # --- stage writers -----------------------------------------------------------
 
87
 
88
 
89
  def needs_extract(conn: sqlite3.Connection, limit: int | None = None) -> list[sqlite3.Row]:
90
+ # core + adjacent papers not yet benchmark-extracted (VLA/world-model papers report
91
+ # standard embodied benchmarks too). No analysis requirement — extraction reads the PDF.
92
  return conn.execute(_limit_clause(
93
+ "SELECT id, source, title, abstract, links_json FROM papers WHERE "
94
+ "track IN ('core','adjacent') AND benchmarks_extracted=0", limit)).fetchall()
95
 
96
 
97
  # --- stage writers -----------------------------------------------------------
src/wam/store/people.py CHANGED
@@ -14,21 +14,23 @@ def slug(name: str) -> str:
14
 
15
  def upsert_author(conn: sqlite3.Connection, *, author_id: str, name: str,
16
  affiliation: str | None, s2_url: str | None, citations: int | None,
17
- h_index: int | None, paper_ids: list[str], directions: str | None) -> None:
 
18
  conn.execute(
19
  """INSERT INTO authors
20
- (id, name, affiliation, s2_url, citations, h_index, paper_ids_json, directions,
21
- updated_at)
22
- VALUES (?,?,?,?,?,?,?,?,?)
23
  ON CONFLICT(id) DO UPDATE SET
24
  affiliation=COALESCE(excluded.affiliation, affiliation),
 
25
  s2_url=COALESCE(excluded.s2_url, s2_url),
26
  citations=COALESCE(excluded.citations, citations),
27
  h_index=COALESCE(excluded.h_index, h_index),
28
  paper_ids_json=excluded.paper_ids_json,
29
  directions=COALESCE(excluded.directions, directions),
30
  updated_at=excluded.updated_at""",
31
- (author_id, name, affiliation, s2_url, citations, h_index, json.dumps(paper_ids),
32
  directions, datetime.now().isoformat(timespec="seconds")),
33
  )
34
 
 
14
 
15
  def upsert_author(conn: sqlite3.Connection, *, author_id: str, name: str,
16
  affiliation: str | None, s2_url: str | None, citations: int | None,
17
+ h_index: int | None, paper_ids: list[str], directions: str | None,
18
+ region: str | None = None) -> None:
19
  conn.execute(
20
  """INSERT INTO authors
21
+ (id, name, affiliation, region, s2_url, citations, h_index, paper_ids_json,
22
+ directions, updated_at)
23
+ VALUES (?,?,?,?,?,?,?,?,?,?)
24
  ON CONFLICT(id) DO UPDATE SET
25
  affiliation=COALESCE(excluded.affiliation, affiliation),
26
+ region=COALESCE(excluded.region, region),
27
  s2_url=COALESCE(excluded.s2_url, s2_url),
28
  citations=COALESCE(excluded.citations, citations),
29
  h_index=COALESCE(excluded.h_index, h_index),
30
  paper_ids_json=excluded.paper_ids_json,
31
  directions=COALESCE(excluded.directions, directions),
32
  updated_at=excluded.updated_at""",
33
+ (author_id, name, affiliation, region, s2_url, citations, h_index, json.dumps(paper_ids),
34
  directions, datetime.now().isoformat(timespec="seconds")),
35
  )
36
 
src/wam/store/schema.sql CHANGED
@@ -75,6 +75,7 @@ CREATE TABLE IF NOT EXISTS authors (
75
  id TEXT PRIMARY KEY, -- semantic scholar id or slug
76
  name TEXT NOT NULL,
77
  affiliation TEXT,
 
78
  s2_url TEXT,
79
  citations INTEGER,
80
  h_index INTEGER,
 
75
  id TEXT PRIMARY KEY, -- semantic scholar id or slug
76
  name TEXT NOT NULL,
77
  affiliation TEXT,
78
+ region TEXT, -- country/region of the affiliation
79
  s2_url TEXT,
80
  citations INTEGER,
81
  h_index INTEGER,
src/wam/webapp/dashboard.py CHANGED
@@ -95,13 +95,14 @@ def render(cfg: Config) -> None:
95
  # --- Authors ---
96
  st.subheader("👥 Influential authors")
97
  arows = con.execute(
98
- "SELECT name, citations, h_index, paper_ids_json, directions, s2_url FROM authors "
99
- "ORDER BY json_array_length(paper_ids_json) DESC").fetchall()
100
  if arows:
101
  adf = pd.DataFrame([{
102
- "author": n, "papers": len(json.loads(pj or "[]")), "citations": cit,
103
- "h_index": h, "directions": (d or "")[:200], "s2": url or ""}
104
- for n, cit, h, pj, d, url in arows])
 
105
  st.dataframe(adf, use_container_width=True, hide_index=True,
106
  column_config={"s2": st.column_config.LinkColumn("s2")})
107
  else:
 
95
  # --- Authors ---
96
  st.subheader("👥 Influential authors")
97
  arows = con.execute(
98
+ "SELECT name, region, affiliation, citations, h_index, paper_ids_json, directions, "
99
+ "s2_url FROM authors ORDER BY json_array_length(paper_ids_json) DESC").fetchall()
100
  if arows:
101
  adf = pd.DataFrame([{
102
+ "author": n, "region": reg or "", "institution": aff or "",
103
+ "papers": len(json.loads(pj or "[]")), "citations": cit, "h_index": h,
104
+ "directions": (d or "")[:200], "s2": url or ""}
105
+ for n, reg, aff, cit, h, pj, d, url in arows])
106
  st.dataframe(adf, use_container_width=True, hide_index=True,
107
  column_config={"s2": st.column_config.LinkColumn("s2")})
108
  else: