suvpande commited on
Commit
25565b5
·
verified ·
1 Parent(s): 2687057

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -944
app.py DELETED
@@ -1,944 +0,0 @@
1
- """
2
- EGM Research Assistant — RAG + Visualisations
3
- Gradio 5.x | sentence-transformers → ChromaDB → Claude + Plotly via HTML
4
- """
5
-
6
- import os, re
7
- import pandas as pd
8
- import chromadb
9
- from chromadb.utils import embedding_functions
10
- from anthropic import Anthropic
11
- import gradio as gr
12
- import plotly.graph_objects as go
13
- from plotly.subplots import make_subplots
14
- from collections import Counter
15
-
16
- # ---------------------------------------------------------------------------
17
- # Config
18
- # ---------------------------------------------------------------------------
19
- EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
20
- COLLECTION = "egm_studies"
21
- MAX_TOKENS = 2000
22
- BATCH_SIZE = 50
23
-
24
- REGION_COLOURS = {
25
- "Sub-Saharan Africa": "#2ecc71",
26
- "Latin America and Caribbean": "#3498db",
27
- "East Asia and Pacific": "#e74c3c",
28
- "South Asia": "#f39c12",
29
- "Middle East and North Africa": "#9b59b6",
30
- "Europe and Central Asia": "#1abc9c",
31
- "Multi-continent": "#95a5a6",
32
- }
33
-
34
- # ---------------------------------------------------------------------------
35
- # Load dataset
36
- # ---------------------------------------------------------------------------
37
- print("Loading dataset...")
38
- df = pd.read_csv("dataset.csv").fillna("")
39
-
40
- # Normalise column name for PSE strategy (long name)
41
- PSE_COL = "pse_strategy_multi_select_separate_values_with_single_vertical_bar"
42
- CONF_COL = "overall_how_much_confidence_do_you_have_in_the_methods_used_to_analyse_the_findings_relative_to_the_primary_question_addressed_in_the_review"
43
-
44
- df["year_of_publication"] = (
45
- df["year_of_publication"].astype(str).str.replace(r"\.0$", "", regex=True)
46
- )
47
- RECORDS = df.to_dict("records")
48
- TOTAL = len(RECORDS)
49
- print(f"Loaded {TOTAL} studies.")
50
-
51
- # ---------------------------------------------------------------------------
52
- # Build embedding text — rich multi-field document
53
- # ---------------------------------------------------------------------------
54
- def make_doc(r):
55
- parts = [
56
- r.get("title", ""),
57
- "Authors: " + r.get("authors", ""),
58
- "Intervention: " + r.get("intervention", ""),
59
- "Intervention detail: " + r.get("intervention_description", "")[:200],
60
- "Intervention category: " + r.get("intervention_category", ""),
61
- "Outcome: " + r.get("outcome", ""),
62
- "Outcome category: " + r.get("outcome_categories_assigned", ""),
63
- "Theme: " + r.get("primary_theme", ""),
64
- "Sub-theme: " + r.get("sub_primary_theme", ""),
65
- "Sector: " + r.get("sector_name", ""),
66
- "SDG: " + r.get("un_sustainable_development_goal", ""),
67
- "Evaluation design: " + r.get("evaluation_design", ""),
68
- "Evaluation method: " + r.get("evaluation_method", ""),
69
- "PSE strategy: " + r.get(PSE_COL, ""),
70
- "Country: " + r.get("country", ""),
71
- "Region: " + r.get("region", ""),
72
- "Income level: " + r.get("income_level", ""),
73
- "Equity focus: " + r.get("equity_focus", ""),
74
- "Population: " + r.get("population_description", "")[:150],
75
- "Record type: " + r.get("record_type", ""),
76
- "Method eval: " + r.get("method_eval", ""),
77
- "Review confidence: " + r.get(CONF_COL, ""),
78
- "Keywords: " + r.get("keywords", ""),
79
- "Other topics: " + r.get("other_topics", ""),
80
- r.get("abstract", "")[:600],
81
- ]
82
- return " | ".join(p for p in parts if p.strip() and not p.strip().endswith(": "))
83
-
84
- DOCS = [make_doc(r) for r in RECORDS]
85
- IDS = [str(i) for i in range(TOTAL)]
86
- METAS = [
87
- {
88
- "title": r["title"][:200],
89
- "authors": r["authors"][:100],
90
- "year": r["year_of_publication"],
91
- "country": r["country"][:80],
92
- "url": r.get("url_link", "")[:200] if str(r.get("url_link","")).startswith("http") else "",
93
- }
94
- for i, r in enumerate(RECORDS)
95
- ]
96
-
97
- # ---------------------------------------------------------------------------
98
- # ChromaDB vector index
99
- # ---------------------------------------------------------------------------
100
- print("Building vector index...")
101
- ef = embedding_functions.SentenceTransformerEmbeddingFunction(model_name=EMBED_MODEL)
102
- chroma = chromadb.Client()
103
-
104
- try:
105
- chroma.delete_collection(COLLECTION)
106
- except Exception:
107
- pass
108
-
109
- col = chroma.create_collection(
110
- name=COLLECTION,
111
- embedding_function=ef,
112
- metadata={"hnsw:space": "cosine"},
113
- )
114
-
115
- for start in range(0, TOTAL, BATCH_SIZE):
116
- end = min(start + BATCH_SIZE, TOTAL)
117
- col.add(ids=IDS[start:end], documents=DOCS[start:end], metadatas=METAS[start:end])
118
- print(f" Indexed {end}/{TOTAL} studies")
119
-
120
- print("Vector index ready ✓")
121
-
122
- # ---------------------------------------------------------------------------
123
- # Anthropic client + system prompt
124
- # ---------------------------------------------------------------------------
125
- anthropic_client = Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY", ""))
126
-
127
- SYSTEM = f"""You are a research synthesis assistant for an Evidence Gap Map (EGM) \
128
- containing {TOTAL} studies on private sector engagement and development interventions.
129
-
130
- STRICT RULES:
131
- 1. Answer ONLY using the studies explicitly provided in each message. Never use outside knowledge.
132
- 2. Only state what is explicitly present in the study data — do not generalise or assume.
133
- 3. Respect the diversity of study designs: studies may be Experimental, Quasi-experimental,
134
- or Qualitative. Never describe the whole dataset as quantitative or experimental.
135
- 4. Every factual claim must have an inline [N] citation to a specific provided study.
136
- 5. If evidence is insufficient, say so and describe what the retrieved studies do cover.
137
-
138
- RESPONSE FORMAT:
139
- **Overview** — 2–3 sentences summarising what the evidence shows, noting study design diversity.
140
- **Key Findings** — bullets grouped by theme/intervention, each with [N] citations.
141
- **Study Designs & Methods** — note the methodological mix (RCTs, DiD, qualitative, etc.)
142
- **Geographic & Context Coverage** — regions, income levels, fragile/conflict-affected states if relevant.
143
- **Equity & Population Focus** — note gender, equity dimensions, and target populations if relevant.
144
- **Gaps & Caveats** — what is not well evidenced in the retrieved studies.
145
- **Key Takeaways** — 2–3 concise conclusions from the provided studies only.
146
-
147
- Use **bold** for key terms. Use [N] inline for every claim."""
148
-
149
- # ---------------------------------------------------------------------------
150
- # Retrieval + context builder
151
- # ---------------------------------------------------------------------------
152
- def retrieve(query, top_k):
153
- top_k = min(int(top_k), TOTAL)
154
- results = col.query(query_texts=[query], n_results=top_k,
155
- include=["metadatas", "distances"])
156
- studies = []
157
- for i, doc_id in enumerate(results["ids"][0]):
158
- rec = RECORDS[int(doc_id)].copy()
159
- rec["_rank"] = i + 1
160
- studies.append(rec)
161
- return studies
162
-
163
-
164
- def build_context(studies):
165
- """
166
- Two-tier context to stay within token limits:
167
- - All studies: compact single-line format (~40 tokens each)
168
- - <=80 studies: also include a short abstract snippet
169
- This lets the full 358-study dataset fit within the 30k token limit.
170
- """
171
- include_abstract = len(studies) <= 80
172
- parts = []
173
- for i, s in enumerate(studies, 1):
174
- line = (
175
- f"[{i}] \"{s['title']}\" | "
176
- f"{s['authors']} ({s['year_of_publication']}) | "
177
- f"{s['country']} | {s['region']} | {s['income_level']} | "
178
- f"{s['record_type']} | {s['method_eval']} | "
179
- f"{s['evaluation_design']} | {s['evaluation_method']} | "
180
- f"Intervention: {s['intervention']} [{s['intervention_category']}] | "
181
- f"Outcome: {s['outcome']} [{s['outcome_categories_assigned']}] | "
182
- f"Theme: {s['primary_theme']} > {s['sub_primary_theme']} | "
183
- f"Sector: {s['sector_name']} | Equity: {s['equity_focus']} | "
184
- f"Confidence: {s[CONF_COL]}"
185
- )
186
- if include_abstract and s["abstract"].strip():
187
- line += f"\n Abstract: {s['abstract'][:250]}"
188
- parts.append(line)
189
- return "\n".join(parts)
190
-
191
-
192
- # ---------------------------------------------------------------------------
193
- # Charts rendered as HTML (avoids gr.Plot version conflicts)
194
- # ---------------------------------------------------------------------------
195
- CHART_BG = "#f8f9fa"
196
- GRID_COLOR = "#e9ecef"
197
-
198
- def make_chart_html(studies):
199
- # Interventions
200
- ivs = []
201
- for s in studies:
202
- for iv in s.get("intervention", "").split("|"):
203
- iv = iv.strip()
204
- if iv and iv.lower() not in ("", "not applicable", "nan"):
205
- ivs.append(iv[:45] + ("…" if len(iv) > 45 else ""))
206
- iv_counts = Counter(ivs).most_common(8)
207
-
208
- # Regions
209
- regions = []
210
- for s in studies:
211
- r = s.get("region", "").split("|")[0].strip()
212
- if r and r.lower() not in ("", "not applicable", "nan"):
213
- regions.append(r)
214
- region_counts = Counter(regions)
215
-
216
- # Study designs
217
- designs = [s.get("evaluation_design","").strip() for s in studies
218
- if s.get("evaluation_design","").strip() not in ("","Not applicable","nan")]
219
- design_counts = Counter(designs)
220
-
221
- # Years
222
- years = []
223
- for s in studies:
224
- try: years.append(int(float(s.get("year_of_publication",""))))
225
- except: pass
226
- sorted_years = sorted(Counter(years).items())
227
-
228
- fig = make_subplots(
229
- rows=2, cols=2,
230
- subplot_titles=("Top interventions","Regional distribution",
231
- "Study designs","Publication years"),
232
- specs=[[{"type":"xy"},{"type":"domain"}],
233
- [{"type":"domain"},{"type":"xy"}]],
234
- vertical_spacing=0.20, horizontal_spacing=0.10,
235
- )
236
-
237
- if iv_counts:
238
- labels = [x[0] for x in iv_counts][::-1]
239
- values = [x[1] for x in iv_counts][::-1]
240
- fig.add_trace(go.Bar(x=values, y=labels, orientation="h",
241
- marker_color="#3498db", marker_line_width=0,
242
- hovertemplate="%{y}: %{x} studies<extra></extra>"), row=1, col=1)
243
-
244
- if region_counts:
245
- rl = list(region_counts.keys())
246
- rv = list(region_counts.values())
247
- fig.add_trace(go.Pie(labels=rl, values=rv, hole=0.45, showlegend=False,
248
- marker_colors=[REGION_COLOURS.get(r,"#bdc3c7") for r in rl],
249
- textinfo="label+percent",
250
- hovertemplate="%{label}: %{value}<extra></extra>"), row=1, col=2)
251
-
252
- if design_counts:
253
- dl = list(design_counts.keys())
254
- dv = list(design_counts.values())
255
- dc = {"Experimental":"#e74c3c","Quasi-experimental":"#3498db","Qualitative":"#2ecc71"}
256
- fig.add_trace(go.Pie(labels=dl, values=dv, hole=0.45, showlegend=False,
257
- marker_colors=[dc.get(d,"#95a5a6") for d in dl],
258
- textinfo="label+percent",
259
- hovertemplate="%{label}: %{value}<extra></extra>"), row=2, col=1)
260
-
261
- if sorted_years:
262
- fig.add_trace(go.Scatter(
263
- x=[x[0] for x in sorted_years], y=[x[1] for x in sorted_years],
264
- mode="lines+markers", fill="tozeroy",
265
- line=dict(color="#e74c3c", width=2.5), marker=dict(size=7, color="#e74c3c"),
266
- fillcolor="rgba(231,76,60,0.10)",
267
- hovertemplate="Year %{x}: %{y} studies<extra></extra>"), row=2, col=2)
268
-
269
- fig.update_layout(height=560, paper_bgcolor=CHART_BG, plot_bgcolor=CHART_BG,
270
- font=dict(family="Inter,sans-serif", size=12, color="#2c3e50"),
271
- margin=dict(t=50, b=20, l=10, r=10), showlegend=False)
272
- fig.update_xaxes(showgrid=True, gridcolor=GRID_COLOR, zeroline=False)
273
- fig.update_yaxes(showgrid=True, gridcolor=GRID_COLOR, zeroline=False)
274
- for ann in fig.layout.annotations:
275
- ann.font = dict(size=12, color="#2c3e50")
276
-
277
- try:
278
- return fig.to_html(full_html=False, include_plotlyjs=False, config={"responsive":True})
279
- except Exception as e:
280
- return f"<p style='color:red'>Chart error: {e}</p>"
281
-
282
-
283
- EMPTY_HTML = """<div style="height:180px;display:flex;align-items:center;justify-content:center;
284
- background:#f8f9fa;border-radius:8px;color:#adb5bd;font-size:14px;font-family:Inter,sans-serif;">
285
- Ask a question to see the evidence visualised here</div>"""
286
-
287
- # ---------------------------------------------------------------------------
288
- # Chat — Gradio 5.x messages format
289
- # ---------------------------------------------------------------------------
290
- def chat(user_message, history, top_k):
291
- if not user_message.strip():
292
- yield history, EMPTY_HTML
293
- return
294
-
295
- studies = retrieve(user_message, top_k)
296
- context = build_context(studies)
297
- augmented = (
298
- f"RETRIEVED STUDIES ({len(studies)} of {TOTAL} total):\n\n{context}\n\n---\n"
299
- f"Using ONLY the studies above, answer:\n{user_message}"
300
- )
301
-
302
- api_messages = []
303
- for turn in history:
304
- role = turn.get("role", "")
305
- content = turn.get("content", "")
306
- if isinstance(content, list):
307
- content = " ".join(b.get("text","") if isinstance(b,dict) else str(b) for b in content)
308
- if role in ("user","assistant") and content:
309
- api_messages.append({"role": role, "content": content})
310
- api_messages.append({"role": "user", "content": augmented})
311
-
312
- history = history + [
313
- {"role": "user", "content": user_message},
314
- {"role": "assistant", "content": ""},
315
- ]
316
-
317
- partial = ""
318
- try:
319
- stream_ctx = anthropic_client.messages.stream(
320
- model="claude-sonnet-4-20250514",
321
- max_tokens=MAX_TOKENS,
322
- system=SYSTEM,
323
- messages=api_messages,
324
- )
325
- with stream_ctx as stream:
326
- for text in stream.text_stream:
327
- partial += text
328
- history[-1]["content"] = partial
329
- yield history, EMPTY_HTML
330
- except Exception as e:
331
- err = str(e)
332
- if "rate_limit" in err or "429" in err:
333
- history[-1]["content"] = "⚠️ **Rate limit reached.** Too many studies were sent at once. Please reduce the **Studies retrieved** slider to 20–25 and try again."
334
- else:
335
- history[-1]["content"] = f"⚠️ **API error:** {err[:300]}"
336
- yield history, EMPTY_HTML
337
- return
338
-
339
- # Append reference list
340
- cited = sorted(set(int(n) for n in re.findall(r"\[(\d+)\]", partial)))
341
- if cited:
342
- refs = ["\n\n---\n**Studies cited:**"]
343
- for n in cited:
344
- if 1 <= n <= len(studies):
345
- s = studies[n-1]
346
- url = s.get("url_link","")
347
- lnk = f"[{s['title']}]({url})" if url and url.startswith("http") else s["title"]
348
- refs.append(
349
- f"**[{n}]** {lnk} \n"
350
- f"*{s['authors']} ({s['year_of_publication']}) · "
351
- f"{s['country']} · {s['evaluation_design']}*"
352
- )
353
- history[-1]["content"] += "\n".join(refs)
354
-
355
- try:
356
- chart_html = make_chart_html(studies)
357
- except Exception as e:
358
- chart_html = f"<p style='color:#e74c3c;font-family:sans-serif'>Chart could not be generated: {e}</p>"
359
- yield history, chart_html
360
-
361
-
362
- def clear_chat():
363
- return [], EMPTY_HTML
364
-
365
- # ---------------------------------------------------------------------------
366
- # Gradio 5.x UI
367
- # ---------------------------------------------------------------------------
368
-
369
- # ---------------------------------------------------------------------------
370
- # Dataset Summary — direct column counts, no RAG/LLM needed
371
- # ---------------------------------------------------------------------------
372
-
373
- # Columns available for summary, with friendly labels
374
- SUMMARY_COLS = {
375
- "Intervention Category": "intervention_category",
376
- "Outcome Category": "outcome_categories_assigned",
377
- "Primary Theme": "primary_theme",
378
- "Sub-theme": "sub_primary_theme",
379
- "Sector": "sector_name",
380
- "Evaluation Design": "evaluation_design",
381
- "Method / Study Type": "method_eval",
382
- "Region": "region",
383
- "Country": "country",
384
- "Income Level": "income_level",
385
- "Equity Focus": "equity_focus",
386
- "Record Type": "record_type",
387
- "Publication Type": "publication_type",
388
- "SDG": "un_sustainable_development_goal",
389
- "Funding Agency": "program_funding_agency",
390
- "Implementation Agency": "implementation_agencies",
391
- }
392
-
393
- SUMMARY_COLOURS = [
394
- "#3498db","#2ecc71","#e74c3c","#f39c12","#9b59b6",
395
- "#1abc9c","#e67e22","#34495e","#e91e63","#00bcd4",
396
- "#8bc34a","#ff5722","#607d8b","#795548","#9c27b0",
397
- ]
398
-
399
- def make_summary_chart(col_label):
400
- col = SUMMARY_COLS.get(col_label)
401
- if not col:
402
- return "<p>Select a column to summarise.</p>"
403
-
404
- series = df[col].replace("", pd.NA).dropna()
405
- if series.empty:
406
- return f"<p>No data available for <b>{col_label}</b>.</p>"
407
-
408
- all_vals = []
409
- for v in series:
410
- for part in str(v).split("|"):
411
- part = part.strip()
412
- if part and part.lower() not in ("not applicable", "nan", "n/a", "multi-country"):
413
- all_vals.append(part)
414
-
415
- counts = Counter(all_vals).most_common(20)
416
- if not counts:
417
- return f"<p>No data available for <b>{col_label}</b>.</p>"
418
-
419
- total = sum(v for _, v in counts)
420
- n_cats = len(Counter(all_vals))
421
- coverage = len(series)
422
- missing = TOTAL - coverage
423
- top1 = counts[0]
424
- top2 = counts[1] if len(counts) > 1 else None
425
- top3 = counts[2] if len(counts) > 2 else None
426
- top3_sum = sum(v for _, v in counts[:3])
427
-
428
- # Full paragraph description
429
- cat_word = "category" if n_cats == 1 else "categories"
430
- rem_word = "category" if n_cats - 3 == 1 else "categories"
431
- para = (
432
- f"Across {coverage} studies with available data, "
433
- f"<b>{col_label}</b> spans <b>{n_cats} distinct {"category" if n_cats == 1 else "categories"}</b>. "
434
- f"The most represented is <b>{top1[0]}</b> with {top1[1]} studies ({top1[1]/total*100:.1f}% of all values)"
435
- )
436
- if top2:
437
- para += f", followed by <b>{top2[0]}</b> ({top2[1]} studies, {top2[1]/total*100:.1f}%)"
438
- if top3:
439
- para += f" and <b>{top3[0]}</b> ({top3[1]} studies, {top3[1]/total*100:.1f}%)"
440
- para += f". Together, the top 3 categories account for <b>{top3_sum/total*100:.0f}%</b> of all values"
441
- if n_cats > 3:
442
- para += f", while the remaining {n_cats - 3} {"category" if n_cats - 3 == 1 else "categories"} collectively represent {(total - top3_sum)/total*100:.0f}%"
443
- para += ". "
444
- if missing > 0:
445
- para += f"Note: {missing} of {TOTAL} studies have no value recorded for this field."
446
- else:
447
- para += f"Data is complete — all {TOTAL} studies have a value for this field."
448
-
449
- desc_html = f"""<div style="background:linear-gradient(135deg,#0a2342 0%,#1a3a5c 100%);
450
- border-radius:10px;padding:18px 22px;margin-bottom:14px;
451
- font-family:Inter,sans-serif;font-size:14px;line-height:1.8;color:#ffffff;
452
- box-shadow:0 4px 16px rgba(10,35,66,0.25);">
453
- <div style="font-size:12px;font-weight:700;color:rgba(255,255,255,0.7);
454
- margin-bottom:8px;text-transform:uppercase;letter-spacing:0.8px;">
455
- {col_label} &nbsp;·&nbsp; {n_cats} categories &nbsp;·&nbsp; {coverage} studies
456
- </div>
457
- <span style="color:#ffffff;">{para}</span>
458
- </div>"""
459
-
460
- # World map for Country
461
- if col == "country":
462
- country_counts = Counter(all_vals)
463
- name_map = {
464
- "Egypt, Arab Rep.": "Egypt", "Iran, Islamic Rep.": "Iran",
465
- "Korea, Rep.": "South Korea", "Congo, Dem. Rep.": "Democratic Republic of the Congo",
466
- "Congo, Rep.": "Republic of the Congo", "Gambia, The": "Gambia",
467
- "Yemen, Rep.": "Yemen", "West Bank and Gaza": "Palestine",
468
- "Russian Federation": "Russia", "Côte d’Ivoire": "Ivory Coast",
469
- }
470
- map_c = [name_map.get(c, c) for c in country_counts.keys()]
471
- map_v = list(country_counts.values())
472
-
473
- fig = go.Figure(go.Choropleth(
474
- locations=map_c, locationmode="country names", z=map_v,
475
- colorscale=[[0.0,"#d4e6f1"],[0.15,"#85c1e9"],[0.35,"#3498db"],
476
- [0.60,"#1a5276"],[1.0,"#0a2342"]],
477
- colorbar=dict(title="Studies", thickness=14, len=0.6, tickfont=dict(size=11)),
478
- hovertemplate="<b>%{location}</b><br>Studies: %{z}<extra></extra>",
479
- marker_line_color="white", marker_line_width=0.5,
480
- ))
481
- fig.update_layout(
482
- height=460, paper_bgcolor="#f8f9fa",
483
- geo=dict(showframe=False, showcoastlines=True, coastlinecolor="#ccc",
484
- showland=True, landcolor="#f0f0f0", showocean=True,
485
- oceancolor="#eaf4fb", showlakes=False,
486
- projection_type="natural earth", bgcolor="#f8f9fa"),
487
- margin=dict(t=10, b=10, l=0, r=0),
488
- font=dict(family="Inter,sans-serif", size=12),
489
- )
490
- try:
491
- return desc_html + fig.to_html(full_html=False, include_plotlyjs=False,
492
- config={"responsive": True})
493
- except Exception as e:
494
- return desc_html + f"<p style='color:red'>Map error: {e}</p>"
495
-
496
- # Bar chart for all other columns
497
- labels = [x[0] for x in counts][::-1]
498
- values = [x[1] for x in counts][::-1]
499
- pcts = [f"{v/total*100:.1f}%" for v in values]
500
- colors = [SUMMARY_COLOURS[i % len(SUMMARY_COLOURS)] for i in range(len(labels))]
501
-
502
- fig = go.Figure()
503
- fig.add_trace(go.Bar(
504
- x=values, y=labels, orientation="h",
505
- marker_color=colors, marker_line_width=0,
506
- text=pcts, textposition="outside",
507
- hovertemplate="%{y}: %{x} studies (%{text})<extra></extra>",
508
- ))
509
- fig.update_layout(
510
- height=max(300, len(labels) * 36 + 80),
511
- paper_bgcolor="#f8f9fa", plot_bgcolor="#f8f9fa",
512
- font=dict(family="Inter,sans-serif", size=12, color="#2c3e50"),
513
- margin=dict(t=10, b=20, l=10, r=90),
514
- xaxis=dict(showgrid=True, gridcolor="#e9ecef", zeroline=False, title="Number of studies"),
515
- yaxis=dict(showgrid=False),
516
- )
517
- try:
518
- return desc_html + fig.to_html(full_html=False, include_plotlyjs=False,
519
- config={"responsive": True})
520
- except Exception as e:
521
- return desc_html + f"<p style='color:red'>Chart error: {e}</p>"
522
-
523
-
524
- # ---------------------------------------------------------------------------
525
- # Evidence Gap Analysis — intervention x outcome and SDG x intervention
526
- # ---------------------------------------------------------------------------
527
-
528
- INTERVENTIONS = [
529
- "Capacity development of private, public, or hybrid sector actors",
530
- "Public-private partnerships (PPPs)",
531
- "Multicomponent",
532
- "Financing with private sector actors",
533
- "Financing of private sector actors",
534
- "Matchmaking and consulting",
535
- "Structured dialogue",
536
- ]
537
-
538
- OUTCOMES = [
539
- "Work-Labor/Social", "Jobs and Income", "Market/Sector Growth",
540
- "Coordination/Reforms", "Environmental", "Energy/Extractives",
541
- ]
542
-
543
- SDGS = [
544
- "Decent Work and Economic Growth", "Good Health and Well-being",
545
- "Quality Education", "No Poverty",
546
- "Industry, Innovation and Infrastructure", "Reduced Inequality",
547
- "Gender Equality", "Zero Hunger", "Climate Action",
548
- ]
549
-
550
- # Short labels for axes
551
- INT_SHORT = {
552
- "Capacity development of private, public, or hybrid sector actors": "Capacity Dev.",
553
- "Public-private partnerships (PPPs)": "PPPs",
554
- "Multicomponent": "Multicomponent",
555
- "Financing with private sector actors": "Financing (with PSA)",
556
- "Financing of private sector actors": "Financing (of PSA)",
557
- "Matchmaking and consulting": "Matchmaking",
558
- "Structured dialogue": "Structured Dialogue",
559
- }
560
- SDG_SHORT = {
561
- "Decent Work and Economic Growth": "SDG 8: Decent Work",
562
- "Good Health and Well-being": "SDG 3: Health",
563
- "Quality Education": "SDG 4: Education",
564
- "No Poverty": "SDG 1: No Poverty",
565
- "Industry, Innovation and Infrastructure": "SDG 9: Industry",
566
- "Reduced Inequality": "SDG 10: Inequality",
567
- "Gender Equality": "SDG 5: Gender",
568
- "Zero Hunger": "SDG 2: Zero Hunger",
569
- "Climate Action": "SDG 13: Climate",
570
- }
571
-
572
- def build_matrix(row_keys, col_keys, row_col, col_col, split_col=False, dff=None):
573
- if dff is None:
574
- dff = df
575
- mat = pd.DataFrame(0, index=row_keys, columns=col_keys)
576
- for _, row in dff.iterrows():
577
- rval = str(row[row_col]).strip()
578
- if not rval or rval not in row_keys:
579
- continue
580
- cvals = str(row[col_col]).split("|") if split_col else [str(row[col_col])]
581
- for cv in cvals:
582
- cv = cv.strip()
583
- if cv in col_keys:
584
- mat.loc[rval, cv] += 1
585
- return mat
586
-
587
-
588
- def make_gap_heatmap(matrix, row_labels, col_labels, title, height=420):
589
- import numpy as np
590
- z = matrix.values.tolist()
591
- rows = [row_labels.get(r, r) for r in matrix.index]
592
- cols = [col_labels.get(c, c) for c in matrix.columns]
593
-
594
- # Custom colour: 0 = red (gap), low = amber, high = dark navy
595
- colorscale = [
596
- [0.00, "#e74c3c"], # 0 studies — clear gap
597
- [0.01, "#f39c12"], # 1–2 studies — limited
598
- [0.10, "#f1c40f"], # some evidence
599
- [0.35, "#3498db"], # moderate
600
- [1.00, "#0a2342"], # strong evidence
601
- ]
602
-
603
- # Annotations — show count or "GAP"
604
- annotations = []
605
- max_val = max(max(row) for row in z) or 1
606
- for i, row in enumerate(z):
607
- for j, val in enumerate(row):
608
- text = "GAP" if val == 0 else str(val)
609
- textcolor = "white" if val == 0 or val/max_val > 0.3 else "#2c3e50"
610
- annotations.append(dict(
611
- x=j, y=i, text=text,
612
- font=dict(size=11, color=textcolor, family="Inter,sans-serif"),
613
- showarrow=False,
614
- ))
615
-
616
- fig = go.Figure(go.Heatmap(
617
- z=z, x=cols, y=rows,
618
- colorscale=colorscale,
619
- showscale=True,
620
- colorbar=dict(
621
- title="Studies",
622
- thickness=14, len=0.7,
623
- tickfont=dict(size=10),
624
- ),
625
- hovertemplate="<b>%{y}</b> × <b>%{x}</b><br>Studies: %{z}<extra></extra>",
626
- ))
627
-
628
- fig.update_layout(
629
- title=dict(text=title, font=dict(size=14, color="#0a2342", family="Inter,sans-serif")),
630
- height=height,
631
- paper_bgcolor="#f8f9fa", plot_bgcolor="#f8f9fa",
632
- font=dict(family="Inter,sans-serif", size=11, color="#2c3e50"),
633
- margin=dict(t=50, b=120, l=160, r=20),
634
- xaxis=dict(tickangle=-35, side="bottom", tickfont=dict(size=10)),
635
- yaxis=dict(tickfont=dict(size=10), autorange="reversed"),
636
- annotations=annotations,
637
- )
638
- return fig
639
-
640
-
641
- def make_gap_summary(matrix, row_labels, col_labels, type_label):
642
- """Generate a navy-card paragraph summarising the gap analysis."""
643
- total_cells = matrix.size
644
- gap_cells = int((matrix == 0).sum().sum())
645
- strong_cells = int((matrix >= 10).sum().sum())
646
- limited_cells = int(((matrix >= 1) & (matrix < 5)).sum().sum())
647
-
648
- # Identify top gaps
649
- gaps = []
650
- for r in matrix.index:
651
- for c in matrix.columns:
652
- if matrix.loc[r, c] == 0:
653
- gaps.append((row_labels.get(r, r), col_labels.get(c, c)))
654
-
655
- # Identify strongest combinations
656
- flat = matrix.stack().reset_index()
657
- flat.columns = ["intervention", "other", "count"]
658
- flat = flat[flat["count"] > 0].sort_values("count", ascending=False)
659
- top3 = flat.head(3)
660
-
661
- gap_pct = gap_cells / total_cells * 100
662
- strong_pct = strong_cells / total_cells * 100
663
-
664
- para = (
665
- f"The {type_label} matrix covers <b>{total_cells} possible combinations</b> "
666
- f"({len(matrix.index)} intervention categories × {len(matrix.columns)} {type_label.lower().split(' x ')[1]}). "
667
- f"Of these, <b>{gap_cells} combinations ({gap_pct:.0f}%) have no evidence</b> in the dataset — "
668
- f"representing clear evidence gaps where future research is needed. "
669
- f"Only <b>{strong_cells} combinations ({strong_pct:.0f}%)</b> have 10 or more studies, "
670
- f"indicating strong evidence. "
671
- )
672
- if not top3.empty:
673
- t = top3.iloc[0]
674
- para += (
675
- f"The best-evidenced combination is "
676
- f"<b>{row_labels.get(t['intervention'], t['intervention'])}</b> × "
677
- f"<b>{col_labels.get(t['other'], t['other'])}</b> "
678
- f"({int(t['count'])} studies). "
679
- )
680
- if gaps:
681
- gap_examples = "; ".join(f"{r} × {c}" for r, c in gaps[:3])
682
- para += f"Key gaps include: {gap_examples}."
683
-
684
- return f"""<div style="background:linear-gradient(135deg,#0a2342 0%,#1a3a5c 100%);
685
- border-radius:10px;padding:18px 22px;margin-bottom:14px;
686
- font-family:Inter,sans-serif;font-size:14px;line-height:1.8;color:#ffffff;
687
- box-shadow:0 4px 16px rgba(10,35,66,0.25);">
688
- <div style="font-size:12px;font-weight:700;color:rgba(255,255,255,0.7);
689
- margin-bottom:8px;text-transform:uppercase;letter-spacing:0.8px;">
690
- Evidence Gap Analysis &nbsp;·&nbsp; {type_label}
691
- </div>
692
- {para}
693
- </div>
694
- <div style="display:flex;gap:12px;margin-bottom:14px;flex-wrap:wrap;">
695
- <div style="background:#e74c3c;color:white;border-radius:8px;padding:10px 16px;
696
- font-family:Inter,sans-serif;font-size:13px;font-weight:600;">
697
- 🔴 {gap_cells} Gaps (no evidence)
698
- </div>
699
- <div style="background:#f39c12;color:white;border-radius:8px;padding:10px 16px;
700
- font-family:Inter,sans-serif;font-size:13px;font-weight:600;">
701
- 🟡 {limited_cells} Limited (&lt;5 studies)
702
- </div>
703
- <div style="background:#0a2342;color:white;border-radius:8px;padding:10px 16px;
704
- font-family:Inter,sans-serif;font-size:13px;font-weight:600;">
705
- 🔵 {strong_cells} Strong (10+ studies)
706
- </div>
707
- </div>"""
708
-
709
-
710
- def make_gap_tab_content(analysis_type, method_filter="All"):
711
- # Filter dataframe by method type
712
- if method_filter == "IE Quantitative":
713
- dff = df[df["method_eval"] == "IE Quantitative"]
714
- elif method_filter == "IE Qualitative":
715
- dff = df[df["method_eval"] == "IE Qualitative"]
716
- elif method_filter == "SR (Systematic Review)":
717
- dff = df[df["method_eval"] == "SR"]
718
- else:
719
- dff = df
720
-
721
- n_filtered = len(dff)
722
- method_note = "" if method_filter == "All" else f" [{method_filter}, {n_filtered} studies]"
723
-
724
- if analysis_type == "Intervention × Outcome":
725
- mat = build_matrix(INTERVENTIONS, OUTCOMES,
726
- "intervention_category", "outcome_categories_assigned",
727
- split_col=True, dff=dff)
728
- row_lab = INT_SHORT
729
- col_lab = {o: o for o in OUTCOMES}
730
- title = f"Evidence Map: Intervention × Outcome{method_note}"
731
- type_label = "Intervention × Outcome"
732
- height = 380
733
- else:
734
- mat = build_matrix(INTERVENTIONS, SDGS,
735
- "intervention_category", "un_sustainable_development_goal",
736
- split_col=True, dff=dff)
737
- row_lab = INT_SHORT
738
- col_lab = SDG_SHORT
739
- title = f"Evidence Map: Intervention × SDG{method_note}"
740
- type_label = "Intervention × SDG"
741
- height = 400
742
-
743
- summary_html = make_gap_summary(mat, row_lab, col_lab, type_label)
744
- fig = make_gap_heatmap(mat, row_lab, col_lab, title, height=height)
745
- try:
746
- chart_html = fig.to_html(full_html=False, include_plotlyjs=False,
747
- config={"responsive": True})
748
- except Exception as e:
749
- chart_html = f"<p style='color:red'>Chart error: {e}</p>"
750
- return summary_html + chart_html
751
-
752
-
753
- # ---------------------------------------------------------------------------
754
- # Custom CSS — 3ie professional dark navy theme
755
- # ---------------------------------------------------------------------------
756
- CUSTOM_CSS = """
757
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
758
-
759
- * { font-family: 'Inter', sans-serif !important; }
760
-
761
- /* Main background */
762
- .gradio-container { background: #f0f2f5 !important; max-width: 1200px !important; margin: 0 auto !important; }
763
-
764
- /* Header banner */
765
- .egm-header {
766
- background: linear-gradient(135deg, #0a2342 0%, #1a3a5c 60%, #1e5280 100%);
767
- color: white;
768
- padding: 28px 32px 22px;
769
- border-radius: 12px;
770
- margin-bottom: 20px;
771
- box-shadow: 0 4px 20px rgba(10,35,66,0.25);
772
- }
773
- .egm-header h1 { font-size: 24px; font-weight: 700; margin: 0 0 4px; letter-spacing: -0.3px; }
774
- .egm-header p { font-size: 13px; opacity: 0.75; margin: 0; font-weight: 400; }
775
- .egm-badges { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
776
- .egm-badge {
777
- background: rgba(255,255,255,0.12);
778
- border: 1px solid rgba(255,255,255,0.2);
779
- border-radius: 20px;
780
- padding: 3px 12px;
781
- font-size: 11px;
782
- font-weight: 500;
783
- color: rgba(255,255,255,0.9);
784
- }
785
-
786
- /* Tabs */
787
- .tab-nav button {
788
- font-weight: 600 !important;
789
- font-size: 13px !important;
790
- color: #4a5568 !important;
791
- border-radius: 8px 8px 0 0 !important;
792
- }
793
- .tab-nav button.selected {
794
- color: #0a2342 !important;
795
- border-bottom: 3px solid #0a2342 !important;
796
- background: white !important;
797
- }
798
-
799
- /* Chat bubbles */
800
- .message.user > div { background: #0a2342 !important; color: white !important; border-radius: 14px 14px 4px 14px !important; }
801
- .message.bot > div { background: white !important; border: 1px solid #e2e8f0 !important; border-radius: 14px 14px 14px 4px !important; box-shadow: 0 1px 4px rgba(0,0,0,0.06) !important; }
802
-
803
- /* Input box */
804
- textarea, input[type=text] {
805
- border: 1.5px solid #cbd5e0 !important;
806
- border-radius: 10px !important;
807
- background: white !important;
808
- font-size: 14px !important;
809
- transition: border-color 0.2s !important;
810
- }
811
- textarea:focus, input[type=text]:focus { border-color: #0a2342 !important; box-shadow: 0 0 0 3px rgba(10,35,66,0.08) !important; }
812
-
813
- /* Buttons */
814
- .gr-button-primary {
815
- background: #0a2342 !important;
816
- border: none !important;
817
- border-radius: 10px !important;
818
- font-weight: 600 !important;
819
- letter-spacing: 0.2px !important;
820
- transition: background 0.2s !important;
821
- }
822
- .gr-button-primary:hover { background: #1a3a5c !important; }
823
- .gr-button-secondary { border-color: #cbd5e0 !important; border-radius: 10px !important; font-weight: 500 !important; }
824
-
825
- /* Panels / blocks */
826
- .gr-panel, .gr-box { border-radius: 12px !important; border: 1px solid #e2e8f0 !important; box-shadow: 0 1px 6px rgba(0,0,0,0.05) !important; }
827
-
828
- /* Slider */
829
- .gr-slider input[type=range]::-webkit-slider-thumb { background: #0a2342 !important; }
830
-
831
- /* Dropdown */
832
- .gr-dropdown { border-radius: 10px !important; }
833
-
834
- /* Footer */
835
- .egm-footer { font-size: 11px; color: #94a3b8; text-align: center; padding: 12px 0 4px; }
836
- """
837
-
838
- with gr.Blocks(title="EGM Research Assistant", theme=gr.themes.Base(), css=CUSTOM_CSS) as demo:
839
- gr.HTML(f"""
840
- <div class="egm-header">
841
- <h1>📚 EGM Research Assistant</h1>
842
- <p>Evidence Gap Map · AI-powered research synthesis · Dataset-only responses</p>
843
- <div class="egm-badges">
844
- <span class="egm-badge">📚 {TOTAL} studies</span>
845
- <span class="egm-badge">🔍 Semantic RAG</span>
846
- <span class="egm-badge">✅ Dataset-only responses</span>
847
- <span class="egm-badge">📊 Evidence visualised</span>
848
- </div>
849
- </div>
850
- """)
851
-
852
- with gr.Tabs():
853
-
854
- # ── Tab 1: Chat ──────────────────────────────────────────────────
855
- with gr.TabItem("💬 Research Chat"):
856
- with gr.Row():
857
- with gr.Column(scale=4):
858
- chatbot = gr.Chatbot(label="Research conversation",
859
- height=480, type="messages")
860
- with gr.Row():
861
- msg = gr.Textbox(
862
- placeholder="e.g. What does the evidence say about cash transfers on education outcomes?",
863
- label="Your question", lines=2, scale=5)
864
- send_btn = gr.Button("Send ↗", variant="primary", scale=1)
865
-
866
- with gr.Column(scale=1, min_width=220):
867
- gr.Markdown("### ⚙️ Settings")
868
- top_k = gr.Slider(minimum=10, maximum=358, value=50, step=10,
869
- label="Studies retrieved per query",
870
- info="Full dataset = 358 studies")
871
- clear_btn = gr.Button("🗑 Clear", variant="secondary")
872
- gr.Markdown("### 💡 Try asking")
873
- gr.Examples(examples=[
874
- ["What interventions improve employment outcomes?"],
875
- ["Evidence on cash transfers for education in Sub-Saharan Africa"],
876
- ["How effective is microfinance for women's empowerment?"],
877
- ["Which studies focus on fragile or conflict-affected states?"],
878
- ["What qualitative studies exist in the dataset?"],
879
- ], inputs=msg)
880
-
881
- gr.HTML('<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>')
882
- chat_chart = gr.HTML(value=EMPTY_HTML, label="Evidence snapshot")
883
-
884
- send_btn.click(fn=chat, inputs=[msg, chatbot, top_k],
885
- outputs=[chatbot, chat_chart]).then(lambda: "", outputs=msg)
886
- msg.submit(fn=chat, inputs=[msg, chatbot, top_k],
887
- outputs=[chatbot, chat_chart]).then(lambda: "", outputs=msg)
888
- clear_btn.click(fn=clear_chat, outputs=[chatbot, chat_chart])
889
-
890
- # ── Tab 2: Dataset Summary ────────────────────────────────────────
891
- with gr.TabItem("📊 Dataset Summary"):
892
- # Load Plotly JS once — shared by all charts in this tab
893
- gr.HTML('<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>')
894
- gr.Markdown("""
895
- ### Explore the spread of any column in the dataset
896
- Select a column below to instantly see how studies are distributed across categories.
897
- No AI involved — this reads directly from the dataset.
898
- """)
899
- col_dropdown = gr.Dropdown(
900
- choices=list(SUMMARY_COLS.keys()),
901
- value="Intervention Category",
902
- label="Select column to summarise",
903
- interactive=True,
904
- )
905
- summary_chart = gr.HTML(
906
- value=make_summary_chart("Intervention Category"),
907
- label="Column distribution"
908
- )
909
- col_dropdown.change(
910
- fn=make_summary_chart,
911
- inputs=col_dropdown,
912
- outputs=summary_chart,
913
- )
914
-
915
-
916
- # ── Tab 3: Evidence Gaps ─────────────────────────────────────────
917
- with gr.TabItem("🔴 Evidence Gaps"):
918
- gr.HTML('<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>')
919
- gr.Markdown("""
920
- ### Evidence Gap Analysis
921
- Heatmaps showing where evidence exists and where it is missing.
922
- 🔴 **GAP** = no studies &nbsp;|&nbsp; 🟡 amber = limited evidence &nbsp;|&nbsp; 🔵 dark navy = strong evidence
923
- """)
924
- with gr.Row():
925
- gap_type = gr.Radio(
926
- choices=["Intervention × Outcome", "Intervention × SDG"],
927
- value="Intervention × Outcome",
928
- label="Analysis type",
929
- interactive=True,
930
- )
931
- gap_method = gr.Radio(
932
- choices=["All", "IE Quantitative", "IE Qualitative", "SR (Systematic Review)"],
933
- value="All",
934
- label="Filter by method type",
935
- interactive=True,
936
- )
937
- gap_output = gr.HTML(value=make_gap_tab_content("Intervention × Outcome", "All"))
938
- gap_type.change(fn=make_gap_tab_content, inputs=[gap_type, gap_method], outputs=gap_output)
939
- gap_method.change(fn=make_gap_tab_content, inputs=[gap_type, gap_method], outputs=gap_output)
940
-
941
- gr.HTML('<div class="egm-footer">Responses grounded exclusively in the EGM dataset · sentence-transformers + ChromaDB + Claude </div>')
942
-
943
- if __name__ == "__main__":
944
- demo.launch()