Spaces:
Sleeping
Sleeping
| """ | |
| EGM Research Assistant β RAG + Visualisations | |
| Gradio 5.x | sentence-transformers β ChromaDB β Claude + Plotly via HTML | |
| """ | |
| import os, re, io, base64 | |
| import pandas as pd | |
| import chromadb | |
| from chromadb.utils import embedding_functions | |
| from anthropic import Anthropic | |
| import gradio as gr | |
| from collections import Counter | |
| import matplotlib | |
| matplotlib.use('Agg') | |
| import matplotlib.pyplot as plt | |
| import matplotlib.patches as mpatches | |
| import numpy as np | |
| # --------------------------------------------------------------------------- | |
| # Config | |
| # --------------------------------------------------------------------------- | |
| EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2" | |
| COLLECTION = "egm_studies" | |
| MAX_TOKENS = 2000 | |
| BATCH_SIZE = 50 | |
| REGION_COLOURS = { | |
| "Sub-Saharan Africa": "#2ecc71", | |
| "Latin America and Caribbean": "#3498db", | |
| "East Asia and Pacific": "#e74c3c", | |
| "South Asia": "#f39c12", | |
| "Middle East and North Africa": "#9b59b6", | |
| "Europe and Central Asia": "#1abc9c", | |
| "Multi-continent": "#95a5a6", | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Load dataset | |
| # --------------------------------------------------------------------------- | |
| print("Loading dataset...") | |
| df = pd.read_csv("dataset.csv").fillna("") | |
| # Normalise column name for PSE strategy (long name) | |
| PSE_COL = "pse_strategy_multi_select_separate_values_with_single_vertical_bar" | |
| 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" | |
| df["year_of_publication"] = ( | |
| df["year_of_publication"].astype(str).str.replace(r"\.0$", "", regex=True) | |
| ) | |
| RECORDS = df.to_dict("records") | |
| TOTAL = len(RECORDS) | |
| print(f"Loaded {TOTAL} studies.") | |
| # --------------------------------------------------------------------------- | |
| # Build embedding text β rich multi-field document | |
| # --------------------------------------------------------------------------- | |
| def make_doc(r): | |
| parts = [ | |
| r.get("title", ""), | |
| "Authors: " + r.get("authors", ""), | |
| "Intervention: " + r.get("intervention", ""), | |
| "Intervention detail: " + r.get("intervention_description", "")[:200], | |
| "Intervention category: " + r.get("intervention_category", ""), | |
| "Intervention group (PSE): "+ r.get("interv_PSE", ""), | |
| "Outcome: " + r.get("outcome", ""), | |
| "Outcome category: " + r.get("outcome_categories_assigned", ""), | |
| "Outcome group (PSE): " + r.get("outc_PSE", ""), | |
| "Theme: " + r.get("primary_theme", ""), | |
| "Sub-theme: " + r.get("sub_primary_theme", ""), | |
| "Sector: " + r.get("sector_name", ""), | |
| "SDG: " + r.get("un_sustainable_development_goal", ""), | |
| "Evaluation design: " + r.get("evaluation_design", ""), | |
| "Evaluation method: " + r.get("evaluation_method", ""), | |
| "PSE strategy: " + r.get(PSE_COL, ""), | |
| "Country: " + r.get("country", ""), | |
| "Region: " + r.get("region", ""), | |
| "Income level: " + r.get("income_level", ""), | |
| "GPI fragility rank: " + str(r.get("gpi_rank", "") or ""), | |
| "Equity focus: " + r.get("equity_focus", ""), | |
| "Population: " + r.get("population_description", "")[:150], | |
| "Record type: " + r.get("record_type", ""), | |
| "Method eval: " + r.get("method_eval", ""), | |
| "Review confidence: " + r.get(CONF_COL, ""), | |
| "Keywords: " + r.get("keywords", ""), | |
| "Other topics: " + r.get("other_topics", ""), | |
| r.get("abstract", "")[:600], | |
| ] | |
| return " | ".join(p for p in parts if p.strip() and not p.strip().endswith(": ")) | |
| DOCS = [make_doc(r) for r in RECORDS] | |
| IDS = [str(i) for i in range(TOTAL)] | |
| METAS = [ | |
| { | |
| "title": r["title"][:200], | |
| "authors": r["authors"][:100], | |
| "year": r["year_of_publication"], | |
| "country": r["country"][:80], | |
| "url": r.get("url_link", "")[:200] if str(r.get("url_link","")).startswith("http") else "", | |
| } | |
| for i, r in enumerate(RECORDS) | |
| ] | |
| # --------------------------------------------------------------------------- | |
| # ChromaDB vector index | |
| # --------------------------------------------------------------------------- | |
| print("Building vector index...") | |
| ef = embedding_functions.SentenceTransformerEmbeddingFunction(model_name=EMBED_MODEL) | |
| chroma = chromadb.Client() | |
| try: | |
| chroma.delete_collection(COLLECTION) | |
| except Exception: | |
| pass | |
| col = chroma.create_collection( | |
| name=COLLECTION, | |
| embedding_function=ef, | |
| metadata={"hnsw:space": "cosine"}, | |
| ) | |
| for start in range(0, TOTAL, BATCH_SIZE): | |
| end = min(start + BATCH_SIZE, TOTAL) | |
| col.add(ids=IDS[start:end], documents=DOCS[start:end], metadatas=METAS[start:end]) | |
| print(f" Indexed {end}/{TOTAL} studies") | |
| print("Vector index ready β") | |
| # --------------------------------------------------------------------------- | |
| # Anthropic client + system prompt | |
| # --------------------------------------------------------------------------- | |
| anthropic_client = Anthropic(api_key=os.environ.get("ANTHROPIC_API_KEY", "")) | |
| SYSTEM = f"""You are a research synthesis assistant for an Evidence Gap Map (EGM) \ | |
| containing {TOTAL} studies on private sector engagement and development interventions. | |
| STRICT RULES: | |
| 1. Answer ONLY using the studies explicitly provided in each message. Never use outside knowledge. | |
| 2. Only state what is explicitly present in the study data β do not generalise or assume. | |
| 3. Respect the diversity of study designs: studies may be Experimental, Quasi-experimental, | |
| or Qualitative. Never describe the whole dataset as quantitative or experimental. | |
| 4. Every factual claim must have an inline [N] citation to a specific provided study. | |
| 5. If evidence is insufficient, say so and describe what the retrieved studies do cover. | |
| RESPONSE FORMAT: | |
| **Overview** β 2β3 sentences summarising what the evidence shows, noting study design diversity. | |
| **Key Findings** β bullets grouped by theme/intervention, each with [N] citations. | |
| **Study Designs & Methods** β note the methodological mix (RCTs, DiD, qualitative, etc.) | |
| **Geographic & Context Coverage** β regions, income levels, GPI fragility rank (lower = more fragile), fragile/conflict-affected states if relevant. | |
| **Equity & Population Focus** β note gender, equity dimensions, and target populations if relevant. | |
| **Gaps & Caveats** β what is not well evidenced in the retrieved studies. | |
| **Key Takeaways** β 2β3 concise conclusions from the provided studies only. | |
| Use **bold** for key terms. Use [N] inline for every claim.""" | |
| # --------------------------------------------------------------------------- | |
| # Retrieval + context builder | |
| # --------------------------------------------------------------------------- | |
| def retrieve(query, top_k): | |
| top_k = min(int(top_k), TOTAL) | |
| results = col.query(query_texts=[query], n_results=top_k, | |
| include=["metadatas", "distances"]) | |
| studies = [] | |
| for i, doc_id in enumerate(results["ids"][0]): | |
| rec = RECORDS[int(doc_id)].copy() | |
| rec["_rank"] = i + 1 | |
| studies.append(rec) | |
| return studies | |
| def build_context(studies): | |
| """ | |
| Two-tier context: | |
| - All studies: structured fields including PSE groups, keywords, GPI rank | |
| - <=80 studies: also include abstract | |
| """ | |
| include_abstract = len(studies) <= 80 | |
| parts = [] | |
| for i, s in enumerate(studies, 1): | |
| line = ( | |
| f"[{i}] \"{s['title']}\" | " | |
| f"{s['authors']} ({s['year_of_publication']}) | " | |
| f"{s['country']} | {s['region']} | Income: {s['income_level']} | " | |
| f"GPI rank: {s.get('gpi_rank','')} | " | |
| f"{s['record_type']} | {s['method_eval']} | " | |
| f"{s['evaluation_design']} | {s['evaluation_method']} | " | |
| f"Intervention: {s['intervention']} [{s['intervention_category']}] | " | |
| f"PSE intervention group: {s.get('interv_PSE','')} | " | |
| f"Outcome: {s['outcome']} [{s['outcome_categories_assigned']}] | " | |
| f"PSE outcome group: {s.get('outc_PSE','')} | " | |
| f"Theme: {s['primary_theme']} > {s['sub_primary_theme']} | " | |
| f"Sector: {s['sector_name']} | Equity: {s['equity_focus']} | " | |
| f"Keywords: {s.get('keywords','')} | Other topics: {s.get('other_topics','')} | " | |
| f"Confidence: {s[CONF_COL]}" | |
| ) | |
| if include_abstract and s.get("abstract","").strip(): | |
| line += f"\n Abstract: {s['abstract'][:250]}" | |
| parts.append(line) | |
| return "\n".join(parts) | |
| # --------------------------------------------------------------------------- | |
| # Charts rendered as HTML (avoids gr.Plot version conflicts) | |
| # --------------------------------------------------------------------------- | |
| CHART_BG = "#f8f9fa" | |
| def detect_chart_topics(query): | |
| """Return exactly 1-2 most relevant chart types for the question.""" | |
| q = query.lower() | |
| # --- SR appraisal check FIRST β if any SR keyword present, always show sr_appraisal --- | |
| sr_triggers = [ | |
| "systematic review", "critical appraisal", "appraisal", "review quality", | |
| "reliability of review", "reliability of the review", "srr", "amstar", "prisma", | |
| "cochrane", "meta-analysis", "narrative synthesis", "review methodology", | |
| "confidence in the methods", "rate the reliability", | |
| ] | |
| if any(w in q for w in sr_triggers): | |
| return {"sr_appraisal"} | |
| # --- Non-SR scoring --- | |
| scores = { | |
| "interventions": 0, | |
| "outcomes": 0, | |
| "geo": 0, | |
| "methods": 0, | |
| "themes": 0, | |
| "timeline": 0, | |
| } | |
| # Remove "systematic review" from method_words β handled above | |
| geo_words = ["country","countries","region","africa","asia","latin","europe","pacific", | |
| "middle east","where","location","fragil","conflict","gpi","income", | |
| "low income","middle income","global","south asia","sub-saharan"] | |
| method_words = ["method","design","rct","randomis","experiment","qualitative","quasi", | |
| "evaluation","evidence type","how studied","quantitative","approach", | |
| "study type","study design"] | |
| theme_words = ["theme","sector","health","education","finance","economic","social", | |
| "environment","energy","urban","rural","topic","area"] | |
| interv_words = ["intervention","program","programme","activity","what works","pse","ppp", | |
| "cash transfer","voucher","training","tvet","microfinance","credit", | |
| "matchmaking","subsidy","capacity","financing"] | |
| outcome_words = ["outcome","impact","effect","result","employment","income","job","market", | |
| "growth","food","nutrition","wellbeing","welfare","earnings"] | |
| time_words = ["year","recent","latest","trend","over time","timeline", | |
| "2020","2021","2022","2023","2024","when","publish"] | |
| for w in geo_words: scores["geo"] += q.count(w) | |
| for w in method_words: scores["methods"] += q.count(w) | |
| for w in theme_words: scores["themes"] += q.count(w) | |
| for w in interv_words: scores["interventions"] += q.count(w) | |
| for w in outcome_words: scores["outcomes"] += q.count(w) | |
| for w in time_words: scores["timeline"] += q.count(w) | |
| ranked = sorted(scores.items(), key=lambda x: -x[1]) | |
| top = [k for k, v in ranked if v > 0][:2] | |
| if not top: | |
| top = ["interventions", "geo"] | |
| elif len(top) == 1: | |
| pairs = { | |
| "interventions": "outcomes", | |
| "outcomes": "interventions", | |
| "geo": "interventions", | |
| "methods": "interventions", | |
| "themes": "interventions", | |
| "timeline": "interventions", | |
| } | |
| top.append(pairs[top[0]]) | |
| return set(top[:2]) | |
| def make_single_chart(kind, studies): | |
| """Make one matplotlib chart and return as HTML img tag.""" | |
| NAVY = "#0a2342" | |
| BLUE = "#3498db" | |
| RED = "#e74c3c" | |
| AMBER = "#f39c12" | |
| PURPLE = "#9b59b6" | |
| GREEN = "#2ecc71" | |
| GREY = "#95a5a6" | |
| BG = "#f8f9fa" | |
| fig, ax = plt.subplots(figsize=(9, 4), facecolor=BG) | |
| ax.set_facecolor(BG) | |
| for spine in ax.spines.values(): | |
| spine.set_color("#e2e8f0") | |
| if kind == "interventions": | |
| ivs = [] | |
| for s in studies: | |
| for iv in s.get("interv_PSE","").split("|"): | |
| iv = iv.strip() | |
| if iv and iv.lower() not in ("","not applicable","nan"): | |
| ivs.append(iv) | |
| if not ivs: | |
| for s in studies: | |
| iv = s.get("intervention_category","").strip() | |
| if iv and iv.lower() not in ("","not applicable","nan"): | |
| ivs.append(iv) | |
| counts = Counter(ivs).most_common(10) | |
| if not counts: return None | |
| labels = [x[0][:40] for x in counts][::-1] | |
| values = [x[1] for x in counts][::-1] | |
| bars = ax.barh(labels, values, color=BLUE, edgecolor="white", height=0.6) | |
| for bar, val in zip(bars, values): | |
| ax.text(bar.get_width()+0.1, bar.get_y()+bar.get_height()/2, | |
| str(val), va="center", ha="left", fontsize=8, color="#444") | |
| ax.set_xlabel("Studies", fontsize=9, color="#666") | |
| ax.set_xlim(0, max(values)*1.15) | |
| ax.set_title("PSE Intervention Groups in retrieved studies", fontsize=11, fontweight="bold", color=NAVY, pad=8) | |
| elif kind == "outcomes": | |
| ocs = [] | |
| for s in studies: | |
| for oc in s.get("outc_PSE","").split("|"): | |
| oc = oc.strip() | |
| if oc and oc.lower() not in ("","not applicable","nan"): | |
| ocs.append(oc) | |
| if not ocs: | |
| for s in studies: | |
| for oc in s.get("outcome_categories_assigned","").split("|"): | |
| oc = oc.strip() | |
| if oc and oc.lower() not in ("","not applicable","nan"): | |
| ocs.append(oc) | |
| counts = Counter(ocs).most_common(10) | |
| if not counts: return None | |
| labels = [x[0][:40] for x in counts][::-1] | |
| values = [x[1] for x in counts][::-1] | |
| colors = [GREEN if i % 2 == 0 else "#1abc9c" for i in range(len(labels))] | |
| bars = ax.barh(labels, values, color=colors, edgecolor="white", height=0.6) | |
| for bar, val in zip(bars, values): | |
| ax.text(bar.get_width()+0.1, bar.get_y()+bar.get_height()/2, | |
| str(val), va="center", ha="left", fontsize=8, color="#444") | |
| ax.set_xlabel("Studies", fontsize=9, color="#666") | |
| ax.set_xlim(0, max(values)*1.15) | |
| ax.set_title("PSE Outcome Groups in retrieved studies", fontsize=11, fontweight="bold", color=NAVY, pad=8) | |
| elif kind == "geo": | |
| regions = [] | |
| for s in studies: | |
| r = s.get("region","").split("|")[0].strip() | |
| if r and r.lower() not in ("","not applicable","nan"): | |
| regions.append(r) | |
| counts = Counter(regions) | |
| if not counts: return None | |
| rl = list(counts.keys()) | |
| rv = list(counts.values()) | |
| rcols = [REGION_COLOURS.get(r, GREY) for r in rl] | |
| wedges, _, autotexts = ax.pie( | |
| rv, labels=None, colors=rcols, autopct="%1.0f%%", | |
| startangle=90, wedgeprops=dict(width=0.55, edgecolor="white"), | |
| pctdistance=0.75, | |
| ) | |
| for t in autotexts: | |
| t.set_fontsize(9); t.set_color("white") | |
| ax.legend(wedges, [r[:28] for r in rl], loc="center left", | |
| bbox_to_anchor=(1.0, 0.5), fontsize=8, frameon=False) | |
| ax.set_title("Regional distribution of retrieved studies", fontsize=11, fontweight="bold", color=NAVY, pad=8) | |
| elif kind == "methods": | |
| designs = [s.get("evaluation_design","").strip() for s in studies | |
| if s.get("evaluation_design","").strip() not in ("","Not applicable","nan")] | |
| counts = Counter(designs) | |
| if not counts: return None | |
| dl = list(counts.keys()); dv = list(counts.values()) | |
| dc = {"Experimental":RED, "Quasi-experimental":BLUE, "Qualitative":GREEN} | |
| dcols = [dc.get(d, GREY) for d in dl] | |
| wedges2, _, autotexts2 = ax.pie( | |
| dv, labels=None, colors=dcols, autopct="%1.0f%%", | |
| startangle=90, wedgeprops=dict(width=0.55, edgecolor="white"), | |
| pctdistance=0.75, | |
| ) | |
| for t in autotexts2: | |
| t.set_fontsize(9); t.set_color("white") | |
| ax.legend(wedges2, dl, loc="center left", | |
| bbox_to_anchor=(1.0, 0.5), fontsize=8, frameon=False) | |
| ax.set_title("Study designs in retrieved studies", fontsize=11, fontweight="bold", color=NAVY, pad=8) | |
| elif kind == "themes": | |
| themes = [] | |
| for s in studies: | |
| t = s.get("primary_theme","").strip() | |
| if t and t.lower() not in ("","not applicable","nan"): | |
| themes.append(t) | |
| counts = Counter(themes).most_common(8) | |
| if not counts: return None | |
| labels = [x[0][:35] for x in counts][::-1] | |
| values = [x[1] for x in counts][::-1] | |
| palette = ["#0a2342","#1a3a5c","#1e5280","#3498db","#5dade2","#85c1e9","#aed6f1","#d6eaf8"] | |
| colors = [palette[i % len(palette)] for i in range(len(labels))] | |
| bars = ax.barh(labels, values, color=colors, edgecolor="white", height=0.6) | |
| for bar, val in zip(bars, values): | |
| ax.text(bar.get_width()+0.1, bar.get_y()+bar.get_height()/2, | |
| str(val), va="center", ha="left", fontsize=8, color="#444") | |
| ax.set_xlabel("Studies", fontsize=9, color="#666") | |
| ax.set_xlim(0, max(values)*1.15) | |
| ax.set_title("Themes in retrieved studies", fontsize=11, fontweight="bold", color=NAVY, pad=8) | |
| elif kind == "timeline": | |
| years = [] | |
| for s in studies: | |
| try: years.append(int(float(s.get("year_of_publication","")))) | |
| except: pass | |
| if not years: return None | |
| yc = sorted(Counter(years).items()) | |
| ax.plot([x[0] for x in yc], [x[1] for x in yc], | |
| color=RED, linewidth=2.5, marker="o", markersize=5) | |
| ax.fill_between([x[0] for x in yc], [x[1] for x in yc], alpha=0.12, color=RED) | |
| ax.set_xlabel("Year", fontsize=9, color="#666") | |
| ax.set_ylabel("Studies", fontsize=9, color="#666") | |
| ax.set_title("Publication years of retrieved studies", fontsize=11, fontweight="bold", color=NAVY, pad=8) | |
| elif kind == "sr_appraisal": | |
| plt.close(fig) | |
| _N="#0a2342"; _G="#2ecc71"; _A="#f39c12"; _R="#e74c3c"; _BG2="#f8f9fa" | |
| # Always use ALL SR studies from the full dataset for the appraisal chart | |
| sr_studies = [r for r in RECORDS if r.get("record_type","") == "srr"] | |
| if not sr_studies: | |
| fig2, ax2 = plt.subplots(figsize=(8, 3), facecolor=_BG2) | |
| ax2.text(0.5, 0.5, "No systematic reviews found in dataset", | |
| ha="center", va="center", transform=ax2.transAxes, color="#aaa", fontsize=11) | |
| ax2.axis("off") | |
| return fig_to_html(fig2) | |
| n_sr = len(sr_studies) | |
| # All appraisal criteria | |
| criteria = { | |
| "Reliability of review": "sr_reliability", | |
| "Confidence: analysis methods": "sr_confidence_analysis", | |
| "Confidence: inclusion methods": "sr_confidence_inclusion", | |
| "Quality/bias criteria used": "sr_quality_criteria", | |
| "Inclusion criteria reported": "sr_inclusion_criteria", | |
| "Comprehensive search": "sr_comprehensive_search", | |
| "Selection bias avoided": "sr_selection_bias", | |
| "Independent screening": "sr_independent_screening", | |
| "Independent RoB assessment": "sr_independent_rob", | |
| "Independent data extraction": "sr_data_extraction", | |
| "Language bias avoided": "sr_language_bias", | |
| "Grey literature included": "sr_grey_literature", | |
| "Relevant databases searched": "sr_databases_searched", | |
| } | |
| YES_VALS = {"yes","high","low risk","adequate"} | |
| PARTIAL_VALS = {"partially","partial","moderate","unclear","can't tell"} | |
| NO_VALS = {"no","low","high risk","inadequate","not applicable"} | |
| labels_list, yes_c, partial_c, no_c, missing_c = [], [], [], [], [] | |
| for label, col in criteria.items(): | |
| vals = [str(s.get(col,"")).strip().lower() for s in sr_studies] | |
| y = sum(1 for v in vals if v in YES_VALS) | |
| p = sum(1 for v in vals if v in PARTIAL_VALS) | |
| n = sum(1 for v in vals if v in NO_VALS) | |
| m = n_sr - y - p - n | |
| labels_list.append(label) | |
| yes_c.append(y); partial_c.append(p) | |
| no_c.append(n); missing_c.append(m) | |
| fig2, ax2 = plt.subplots(figsize=(11, 6), facecolor=_BG2) | |
| ax2.set_facecolor(_BG2) | |
| y_pos = list(range(len(labels_list))) | |
| ax2.barh(y_pos, yes_c, color=_G, edgecolor="white", label="Yes / High", height=0.6) | |
| ax2.barh(y_pos, partial_c, color=_A, edgecolor="white", label="Partial", height=0.6, | |
| left=yes_c) | |
| ax2.barh(y_pos, no_c, color=_R, edgecolor="white", label="No / Low", height=0.6, | |
| left=[y+p for y,p in zip(yes_c, partial_c)]) | |
| ax2.barh(y_pos, missing_c, color="#d5d8dc", edgecolor="white", label="Not recorded", height=0.6, | |
| left=[y+p+n for y,p,n in zip(yes_c, partial_c, no_c)]) | |
| ax2.set_yticks(y_pos) | |
| ax2.set_yticklabels(labels_list, fontsize=9, color="#333") | |
| ax2.set_xlabel(f"Number of systematic reviews (n={n_sr})", fontsize=9, color="#666") | |
| ax2.set_xlim(0, n_sr + 0.5) | |
| ax2.set_xticks(range(0, n_sr + 1)) | |
| ax2.set_title(f"Critical Appraisal of {n_sr} Systematic Reviews", | |
| fontsize=12, fontweight="bold", color=_N, pad=10) | |
| ax2.legend(loc="lower right", fontsize=8, frameon=False, ncol=2) | |
| for spine in ax2.spines.values(): spine.set_color("#e2e8f0") | |
| ax2.tick_params(colors="#666") | |
| plt.tight_layout() | |
| return fig_to_html(fig2) | |
| ax.tick_params(labelsize=8, colors="#444") | |
| plt.tight_layout() | |
| return fig_to_html(fig) | |
| def fig_to_html(fig): | |
| """Convert matplotlib figure to base64 PNG embedded as HTML img tag.""" | |
| buf = io.BytesIO() | |
| fig.savefig(buf, format="png", dpi=130, bbox_inches="tight", | |
| facecolor=fig.get_facecolor()) | |
| plt.close(fig) | |
| buf.seek(0) | |
| b64 = base64.b64encode(buf.read()).decode() | |
| return f'<img src="data:image/png;base64,{b64}" style="width:100%;border-radius:8px;margin-top:8px;">' | |
| def make_chart_html(studies, query=""): | |
| """Generate exactly 1-2 charts most relevant to the query.""" | |
| topics = detect_chart_topics(query) # always returns exactly 2 | |
| html_parts = [] | |
| for topic in ["interventions","outcomes","geo","methods","themes","timeline","sr_appraisal"]: | |
| if topic in topics: | |
| try: | |
| chart = make_single_chart(topic, studies) | |
| if chart: | |
| html_parts.append(chart) | |
| except Exception as e: | |
| html_parts.append( | |
| f"<p style='color:#e74c3c;font-size:12px;'>Chart error ({topic}): {e}</p>" | |
| ) | |
| if not html_parts: | |
| return EMPTY_HTML | |
| label_map = { | |
| "interventions": "Interventions", | |
| "outcomes": "Outcomes", | |
| "geo": "Geography", | |
| "methods": "Study designs", | |
| "themes": "Themes", | |
| "timeline": "Timeline", | |
| "sr_appraisal": "SR Critical Appraisal", | |
| } | |
| topic_labels = " + ".join(label_map[t] for t in topics if t in label_map) | |
| header = ( | |
| f'''<div style="font-family:Inter,sans-serif;font-size:12px;color:#888; | |
| padding:4px 0 8px;">π <b style="color:#0a2342;">{topic_labels}</b> | |
| β based on {len(studies)} retrieved studies</div>''' | |
| ) | |
| return header + "".join(html_parts) | |
| EMPTY_HTML = """<div style="height:180px;display:flex;align-items:center;justify-content:center; | |
| background:#f8f9fa;border-radius:8px;color:#adb5bd;font-size:14px;font-family:Inter,sans-serif;"> | |
| Ask a question β charts will show the profile of studies retrieved for that specific question</div>""" | |
| # --------------------------------------------------------------------------- | |
| # Chat β Gradio 5.x messages format | |
| # --------------------------------------------------------------------------- | |
| def chat(user_message, history, top_k): | |
| if not user_message.strip(): | |
| yield history, EMPTY_HTML | |
| return | |
| studies = retrieve(user_message, top_k) | |
| context = build_context(studies) | |
| augmented = ( | |
| f"RETRIEVED STUDIES ({len(studies)} of {TOTAL} total):\n\n{context}\n\n---\n" | |
| f"Using ONLY the studies above, answer:\n{user_message}" | |
| ) | |
| api_messages = [] | |
| for turn in history: | |
| role = turn.get("role", "") | |
| content = turn.get("content", "") | |
| if isinstance(content, list): | |
| content = " ".join(b.get("text","") if isinstance(b,dict) else str(b) for b in content) | |
| if role in ("user","assistant") and content: | |
| api_messages.append({"role": role, "content": content}) | |
| api_messages.append({"role": "user", "content": augmented}) | |
| history = history + [ | |
| {"role": "user", "content": user_message}, | |
| {"role": "assistant", "content": ""}, | |
| ] | |
| partial = "" | |
| try: | |
| stream_ctx = anthropic_client.messages.stream( | |
| model="claude-sonnet-4-6", | |
| max_tokens=MAX_TOKENS, | |
| system=SYSTEM, | |
| messages=api_messages, | |
| ) | |
| with stream_ctx as stream: | |
| for text in stream.text_stream: | |
| partial += text | |
| history[-1]["content"] = partial | |
| yield history, EMPTY_HTML | |
| except Exception as e: | |
| err = str(e) | |
| if "rate_limit" in err or "429" in err: | |
| 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." | |
| else: | |
| history[-1]["content"] = f"β οΈ **API error:** {err[:300]}" | |
| yield history, EMPTY_HTML | |
| return | |
| # Append reference list | |
| cited = sorted(set(int(n) for n in re.findall(r"\[(\d+)\]", partial))) | |
| if cited: | |
| refs = ["\n\n---\n**Studies cited:**"] | |
| for n in cited: | |
| if 1 <= n <= len(studies): | |
| s = studies[n-1] | |
| url = s.get("url_link","") | |
| lnk = f"[{s['title']}]({url})" if url and url.startswith("http") else s["title"] | |
| refs.append( | |
| f"**[{n}]** {lnk} \n" | |
| f"*{s['authors']} ({s['year_of_publication']}) Β· " | |
| f"{s['country']} Β· {s['evaluation_design']}*" | |
| ) | |
| history[-1]["content"] += "\n".join(refs) | |
| try: | |
| chart_html = make_chart_html(studies, query=user_message) | |
| except Exception as e: | |
| chart_html = f"<p style='color:#e74c3c;font-family:sans-serif'>Chart could not be generated: {e}</p>" | |
| yield history, chart_html | |
| def clear_chat(): | |
| return [], EMPTY_HTML | |
| # --------------------------------------------------------------------------- | |
| # Gradio 5.x UI | |
| # --------------------------------------------------------------------------- | |
| # --------------------------------------------------------------------------- | |
| # Dataset Summary β direct column counts, no RAG/LLM needed | |
| # --------------------------------------------------------------------------- | |
| # Columns available for summary, with friendly labels | |
| SUMMARY_COLS = { | |
| "Intervention Category": "intervention_category", | |
| "PSE Intervention Group": "interv_PSE", | |
| "Outcome Category": "outcome_categories_assigned", | |
| "PSE Outcome Group": "outc_PSE", | |
| "Primary Theme": "primary_theme", | |
| "Sub-theme": "sub_primary_theme", | |
| "Sector": "sector_name", | |
| "Evaluation Design": "evaluation_design", | |
| "Method / Study Type": "method_eval", | |
| "Region": "region", | |
| "Country": "country", | |
| "Income Level": "income_level", | |
| "GPI Fragility Rank": "gpi_rank", | |
| "Equity Focus": "equity_focus", | |
| "Record Type": "record_type", | |
| "Publication Type": "publication_type", | |
| "SDG": "un_sustainable_development_goal", | |
| "Funding Agency": "program_funding_agency", | |
| "Implementation Agency": "implementation_agencies", | |
| "Critical Appraisal of SRs": "sr_appraisal_summary", | |
| } | |
| SUMMARY_COLOURS = [ | |
| "#3498db","#2ecc71","#e74c3c","#f39c12","#9b59b6", | |
| "#1abc9c","#e67e22","#34495e","#e91e63","#00bcd4", | |
| "#8bc34a","#ff5722","#607d8b","#795548","#9c27b0", | |
| ] | |
| def make_summary_chart(col_label): | |
| col = SUMMARY_COLS.get(col_label) | |
| if not col: | |
| return "<p>Select a column to summarise.</p>" | |
| # Virtual key β delegate to SR appraisal chart directly | |
| if col == "sr_appraisal_summary": | |
| para_white = "Critical appraisal ratings across all <b style=\"color:#ffffff;font-weight:700;\">13 systematic reviews</b> in the dataset, assessed across 13 quality criteria. Bars show <b style=\"color:#ffffff;font-weight:700;\">Yes/High</b> (green), <b style=\"color:#ffffff;font-weight:700;\">Partial</b> (amber), <b style=\"color:#ffffff;font-weight:700;\">No/Low</b> (red), and not recorded (grey). Most reviews are rated <b style=\"color:#ffffff;font-weight:700;\">Low</b> on reliability and confidence." | |
| desc_html = f"""<div style="background:linear-gradient(135deg,#0a2342 0%,#1a3a5c 100%);border-radius:10px;padding:18px 22px;margin-bottom:14px;font-family:Inter,sans-serif;font-size:14px;line-height:1.8;box-shadow:0 4px 16px rgba(10,35,66,0.25);"><div style="font-size:12px;font-weight:700;color:rgba(255,255,255,0.65);margin-bottom:8px;text-transform:uppercase;letter-spacing:0.8px;">Critical Appraisal of Systematic Reviews Β· 13 studies Β· 13 criteria</div><span style="color:#ffffff;display:block;">{para_white}</span></div>""" | |
| chart = make_single_chart("sr_appraisal", []) | |
| return desc_html + (chart or "<p>No SR data found.</p>") | |
| series = df[col].replace("", pd.NA).dropna() | |
| if series.empty: | |
| return f"<p>No data available for <b>{col_label}</b>.</p>" | |
| all_vals = [] | |
| for v in series: | |
| for part in str(v).split("|"): | |
| part = part.strip() | |
| if part and part.lower() not in ("not applicable", "nan", "n/a", "multi-country"): | |
| all_vals.append(part) | |
| counts = Counter(all_vals).most_common(20) | |
| if not counts: | |
| return f"<p>No data available for <b>{col_label}</b>.</p>" | |
| total = sum(v for _, v in counts) | |
| n_cats = len(Counter(all_vals)) | |
| coverage = len(series) | |
| missing = TOTAL - coverage | |
| top1 = counts[0] | |
| top2 = counts[1] if len(counts) > 1 else None | |
| top3 = counts[2] if len(counts) > 2 else None | |
| top3_sum = sum(v for _, v in counts[:3]) | |
| # Full paragraph description | |
| cat_word = "category" if n_cats == 1 else "categories" | |
| rem_word = "category" if n_cats - 3 == 1 else "categories" | |
| para = ( | |
| f"Across {coverage} studies with available data, " | |
| f"<b>{col_label}</b> spans <b>{n_cats} distinct {cat_word}</b>. " | |
| f"The most represented is <b>{top1[0]}</b> with {top1[1]} studies ({top1[1]/total*100:.1f}% of all values)" | |
| ) | |
| if top2: | |
| para += f", followed by <b>{top2[0]}</b> ({top2[1]} studies, {top2[1]/total*100:.1f}%)" | |
| if top3: | |
| para += f" and <b>{top3[0]}</b> ({top3[1]} studies, {top3[1]/total*100:.1f}%)" | |
| para += f". Together, the top 3 categories account for <b>{top3_sum/total*100:.0f}%</b> of all values" | |
| if n_cats > 3: | |
| para += f", while the remaining {n_cats - 3} {rem_word} collectively represent {(total - top3_sum)/total*100:.0f}%" | |
| para += ". " | |
| if missing > 0: | |
| para += f"Note: {missing} of {TOTAL} studies have no value recorded for this field." | |
| else: | |
| para += f"Data is complete β all {TOTAL} studies have a value for this field." | |
| # Replace <b> tags with white inline spans so browser defaults don't override | |
| para_white = para.replace("<b>", '<b style="color:#ffffff;font-weight:700;">').replace("</b>", "</b>") | |
| desc_html = f"""<div style="background:linear-gradient(135deg,#0a2342 0%,#1a3a5c 100%); | |
| border-radius:10px;padding:18px 22px;margin-bottom:14px; | |
| font-family:Inter,sans-serif;font-size:14px;line-height:1.8; | |
| box-shadow:0 4px 16px rgba(10,35,66,0.25);"> | |
| <div style="font-size:12px;font-weight:700;color:rgba(255,255,255,0.65); | |
| margin-bottom:8px;text-transform:uppercase;letter-spacing:0.8px;"> | |
| {col_label} Β· {n_cats} categories Β· {coverage} studies | |
| </div> | |
| <span style="color:#ffffff;display:block;">{para_white}</span> | |
| </div>""" | |
| # GPI Rank β histogram | |
| if col == "gpi_rank": | |
| gpi_vals = [] | |
| for v in series: | |
| try: | |
| fv = float(str(v).replace(",","")) | |
| if fv > 0: | |
| gpi_vals.append(fv) | |
| except: pass | |
| if not gpi_vals: | |
| return desc_html + "<p style='color:#888;'>No GPI rank data available.</p>" | |
| fig2, ax = plt.subplots(figsize=(10, 4), facecolor="#f8f9fa") | |
| ax.set_facecolor("#f8f9fa") | |
| ax.hist(gpi_vals, bins=20, color="#9b59b6", edgecolor="white", linewidth=0.5) | |
| ax.set_xlabel("GPI Rank (β more fragile | less fragile β)", fontsize=10, color="#666") | |
| ax.set_ylabel("Number of studies", fontsize=10, color="#666") | |
| ax.tick_params(labelsize=9, colors="#444") | |
| for spine in ax.spines.values(): spine.set_color("#e2e8f0") | |
| plt.tight_layout() | |
| return desc_html + fig_to_html(fig2) | |
| # World map for Country | |
| if col == "country": | |
| try: | |
| import geopandas as gpd | |
| except ImportError: | |
| # Fallback: bar chart of top countries | |
| country_counts = Counter(all_vals) | |
| top_c = country_counts.most_common(25) | |
| fig2, ax = plt.subplots(figsize=(10, 8), facecolor="#f8f9fa") | |
| ax.set_facecolor("#f8f9fa") | |
| c_labels = [x[0][:25] for x in top_c][::-1] | |
| c_values = [x[1] for x in top_c][::-1] | |
| c_colors = ["#0a2342" if v == max(c_values) else | |
| "#1a5276" if v >= max(c_values)*0.5 else | |
| "#3498db" if v >= max(c_values)*0.2 else | |
| "#85c1e9" for v in c_values] | |
| bars = ax.barh(c_labels, c_values, color=c_colors, edgecolor="white", height=0.7) | |
| for bar, val in zip(bars, c_values): | |
| ax.text(bar.get_width() + 0.1, bar.get_y() + bar.get_height()/2, | |
| str(val), va="center", ha="left", fontsize=8, color="#444") | |
| ax.set_xlabel("Studies", fontsize=10, color="#666") | |
| ax.set_title("Top 25 Countries by Study Count", fontsize=11, | |
| fontweight="bold", color="#0a2342", pad=8) | |
| ax.tick_params(labelsize=8, colors="#444") | |
| for spine in ax.spines.values(): spine.set_color("#e2e8f0") | |
| plt.tight_layout() | |
| return desc_html + fig_to_html(fig2) | |
| # Bar chart for all other columns | |
| bar_labels = [x[0][:45] for x in counts][::-1] | |
| bar_values = [x[1] for x in counts][::-1] | |
| bar_pcts = [f"{v/total*100:.1f}%" for v in bar_values] | |
| bar_colors = [SUMMARY_COLOURS[i % len(SUMMARY_COLOURS)] for i in range(len(bar_labels))] | |
| fig2, ax = plt.subplots(figsize=(10, max(4, len(bar_labels) * 0.5 + 1)), | |
| facecolor="#f8f9fa") | |
| ax.set_facecolor("#f8f9fa") | |
| bars = ax.barh(bar_labels, bar_values, color=bar_colors, edgecolor="white", height=0.65) | |
| for bar, val, pct in zip(bars, bar_values, bar_pcts): | |
| ax.text(bar.get_width() + 0.1, bar.get_y() + bar.get_height()/2, | |
| f"{val} ({pct})", va="center", ha="left", fontsize=8, color="#444") | |
| ax.set_xlabel("Number of studies", fontsize=10, color="#666") | |
| ax.set_xlim(0, max(bar_values) * 1.25) | |
| ax.tick_params(labelsize=9, colors="#444") | |
| for spine in ax.spines.values(): spine.set_color("#e2e8f0") | |
| plt.tight_layout() | |
| return desc_html + fig_to_html(fig2) | |
| # --------------------------------------------------------------------------- | |
| # --------------------------------------------------------------------------- | |
| # Evidence Gap Analysis | |
| # --------------------------------------------------------------------------- | |
| INTERVENTIONS = [ | |
| "Capacity development of private, public, or hybrid sector actors", | |
| "Public-private partnerships (PPPs)", | |
| "Multicomponent", | |
| "Financing with private sector actors", | |
| "Financing of private sector actors", | |
| "Matchmaking and consulting", | |
| "Structured dialogue", | |
| ] | |
| OUTCOMES = ["Work-Labor/Social","Jobs and Income","Market/Sector Growth", | |
| "Coordination/Reforms","Environmental","Energy/Extractives"] | |
| SDGS = [ | |
| "Decent Work and Economic Growth","Good Health and Well-being","Quality Education", | |
| "No Poverty","Industry, Innovation and Infrastructure","Reduced Inequality", | |
| "Gender Equality","Zero Hunger","Climate Action", | |
| ] | |
| INTERV_PSE = [ | |
| "Technical and vocational training", | |
| "Capacity strengthening to enhance private sector engagement", | |
| "Service management and delivery PPP", | |
| "Direct financing of private sector actors", | |
| "Payments by results", | |
| "Matchmaking", | |
| "Infrastructure development and financing", | |
| "Advisory support", | |
| "Multi-stakeholder platforms", | |
| "Blended finance", | |
| ] | |
| OUTC_PSE = [ | |
| "Employment","Income","Food security, health, and nutrition","Education", | |
| "Domestic business growth and market development", | |
| "Training, knowledge, and technology transfer", | |
| "Social factors","Productivity gains","Financial inclusion","International trade", | |
| ] | |
| INT_SHORT = { | |
| "Capacity development of private, public, or hybrid sector actors": "Capacity Dev.", | |
| "Public-private partnerships (PPPs)": "PPPs", | |
| "Multicomponent": "Multicomponent", | |
| "Financing with private sector actors": "Financing (with)", | |
| "Financing of private sector actors": "Financing (of)", | |
| "Matchmaking and consulting": "Matchmaking", | |
| "Structured dialogue": "Structured Dialogue", | |
| } | |
| SDG_SHORT = { | |
| "Decent Work and Economic Growth": "SDG8: Decent Work", | |
| "Good Health and Well-being": "SDG3: Health", | |
| "Quality Education": "SDG4: Education", | |
| "No Poverty": "SDG1: No Poverty", | |
| "Industry, Innovation and Infrastructure": "SDG9: Industry", | |
| "Reduced Inequality": "SDG10: Inequality", | |
| "Gender Equality": "SDG5: Gender", | |
| "Zero Hunger": "SDG2: Zero Hunger", | |
| "Climate Action": "SDG13: Climate", | |
| } | |
| def build_matrix(row_keys, col_keys, row_col, col_col, split_col=False, dff=None): | |
| if dff is None: | |
| dff = df | |
| mat = pd.DataFrame(0, index=row_keys, columns=col_keys) | |
| for _, row in dff.iterrows(): | |
| rval = str(row.get(row_col, "")).strip() | |
| if not rval or rval not in row_keys: | |
| # also try splitting row value | |
| for rp in rval.split("|"): | |
| rp = rp.strip() | |
| if rp in row_keys: | |
| cvals = str(row.get(col_col, "")).split("|") if split_col else [str(row.get(col_col,""))] | |
| for cv in cvals: | |
| cv = cv.strip() | |
| if cv in col_keys: | |
| mat.loc[rp, cv] += 1 | |
| continue | |
| cvals = str(row.get(col_col, "")).split("|") if split_col else [str(row.get(col_col,""))] | |
| for cv in cvals: | |
| cv = cv.strip() | |
| if cv in col_keys: | |
| mat.loc[rval, cv] += 1 | |
| return mat | |
| def make_gap_heatmap(matrix, row_labels, col_labels, title, height=420): | |
| """Render gap heatmap as PNG using matplotlib.""" | |
| z = matrix.values | |
| rows = [row_labels.get(r, r) for r in matrix.index] | |
| cols = [col_labels.get(c, c) for c in matrix.columns] | |
| nrows, ncols = z.shape | |
| fig, ax = plt.subplots(figsize=(max(10, ncols * 1.4), max(5, nrows * 0.8)), | |
| facecolor="#f8f9fa") | |
| ax.set_facecolor("#f8f9fa") | |
| # Custom colormap: red (0) β amber β yellow β blue β dark navy | |
| import matplotlib.colors as mcolors | |
| cmap = mcolors.LinearSegmentedColormap.from_list("egm", [ | |
| "#e74c3c", "#f39c12", "#f1c40f", "#3498db", "#0a2342" | |
| ]) | |
| norm = mcolors.Normalize(vmin=0, vmax=max(z.max(), 1)) | |
| im = ax.imshow(z, cmap=cmap, norm=norm, aspect="auto") | |
| # Annotations | |
| max_val = z.max() or 1 | |
| for i in range(nrows): | |
| for j in range(ncols): | |
| val = z[i, j] | |
| txt = "GAP" if val == 0 else str(int(val)) | |
| col = "white" if (val == 0 or val / max_val > 0.3) else "#2c3e50" | |
| ax.text(j, i, txt, ha="center", va="center", | |
| fontsize=9, color=col, fontweight="bold" if val == 0 else "normal") | |
| ax.set_xticks(range(ncols)) | |
| ax.set_xticklabels([c[:30] for c in cols], rotation=40, ha="right", fontsize=8, color="#444") | |
| ax.set_yticks(range(nrows)) | |
| ax.set_yticklabels(rows, fontsize=8, color="#444") | |
| ax.set_title(title, fontsize=12, fontweight="bold", color="#0a2342", pad=10) | |
| cbar = fig.colorbar(im, ax=ax, fraction=0.03, pad=0.02) | |
| cbar.set_label("Studies", fontsize=9, color="#666") | |
| cbar.ax.tick_params(labelsize=8) | |
| for spine in ax.spines.values(): | |
| spine.set_visible(False) | |
| plt.tight_layout() | |
| return fig | |
| def make_gap_summary(matrix, row_labels, col_labels, type_label): | |
| total_cells = matrix.size | |
| gap_cells = int((matrix == 0).sum().sum()) | |
| strong_cells = int((matrix >= 10).sum().sum()) | |
| limited_cells = int(((matrix >= 1) & (matrix < 5)).sum().sum()) | |
| flat = matrix.stack().reset_index() | |
| flat.columns = ["row", "col", "count"] | |
| flat = flat[flat["count"] > 0].sort_values("count", ascending=False) | |
| top_combo = "" | |
| if not flat.empty: | |
| t = flat.iloc[0] | |
| top_combo = ( | |
| f"The best-evidenced combination is " | |
| f"<b>{row_labels.get(t['row'], t['row'])}</b> Γ " | |
| f"<b>{col_labels.get(t['col'], t['col'])}</b> ({int(t['count'])} studies). " | |
| ) | |
| gaps = [(row_labels.get(r,""), col_labels.get(c,"")) | |
| for r in matrix.index for c in matrix.columns if matrix.loc[r,c] == 0] | |
| gap_examples = "; ".join(f"{r} Γ {c}" for r, c in gaps[:3]) | |
| parts = type_label.split(" Γ ") | |
| dim2 = parts[1] if len(parts) > 1 else type_label | |
| para = ( | |
| f"The matrix covers <b>{total_cells} possible combinations</b> " | |
| f"({len(matrix.index)} rows Γ {len(matrix.columns)} {dim2} values). " | |
| f"<b>{gap_cells} combinations ({gap_cells/total_cells*100:.0f}%) have no evidence</b> β " | |
| f"clear gaps where research is needed. " | |
| f"<b>{strong_cells} combinations ({strong_cells/total_cells*100:.0f}%)</b> have 10+ studies. " | |
| f"{top_combo}" | |
| + (f"Key gaps include: {gap_examples}." if gaps else "") | |
| ) | |
| badges = ( | |
| f'''<div style="display:flex;gap:10px;margin-top:12px;flex-wrap:wrap;"> | |
| <span style="background:#e74c3c;color:white;border-radius:6px;padding:5px 12px;font-size:12px;font-weight:600;">π΄ {gap_cells} Gaps</span> | |
| <span style="background:#e67e22;color:white;border-radius:6px;padding:5px 12px;font-size:12px;font-weight:600;">π‘ {limited_cells} Limited (<5)</span> | |
| <span style="background:#1a5276;color:white;border-radius:6px;padding:5px 12px;font-size:12px;font-weight:600;">π΅ {strong_cells} Strong (10+)</span> | |
| </div>''' | |
| ) | |
| para_white = para.replace("<b>", '<b style="color:#ffffff;font-weight:700;">').replace("</b>", "</b>") | |
| return f'''<div style="background:linear-gradient(135deg,#0a2342 0%,#1a3a5c 100%); | |
| border-radius:10px;padding:18px 22px;margin-bottom:14px; | |
| font-family:Inter,sans-serif;font-size:14px;line-height:1.8; | |
| box-shadow:0 4px 16px rgba(10,35,66,0.25);"> | |
| <div style="font-size:12px;font-weight:700;color:rgba(255,255,255,0.65); | |
| margin-bottom:8px;text-transform:uppercase;letter-spacing:0.8px;"> | |
| Evidence Gap Analysis Β· {type_label} | |
| </div> | |
| <span style="color:#ffffff;display:block;">{para_white}</span> | |
| {badges} | |
| </div>''' | |
| def make_gap_tab_content(analysis_type, method_filter="All"): | |
| if method_filter == "IE Quantitative": | |
| dff = df[df["method_eval"] == "IE Quantitative"] | |
| elif method_filter == "IE Qualitative": | |
| dff = df[df["method_eval"] == "IE Qualitative"] | |
| elif method_filter == "SR (Systematic Review)": | |
| dff = df[df["method_eval"] == "SR"] | |
| else: | |
| dff = df | |
| n = len(dff) | |
| sfx = f" [{method_filter}, {n} studies]" if method_filter != "All" else "" | |
| configs = { | |
| "Intervention Category Γ Outcome Category": dict( | |
| rows=INTERVENTIONS, cols=OUTCOMES, | |
| rc="intervention_category", cc="outcome_categories_assigned", | |
| rl=INT_SHORT, cl={o: o for o in OUTCOMES}, h=400, | |
| ), | |
| "Intervention Category Γ SDG": dict( | |
| rows=INTERVENTIONS, cols=SDGS, | |
| rc="intervention_category", cc="un_sustainable_development_goal", | |
| rl=INT_SHORT, cl=SDG_SHORT, h=420, | |
| ), | |
| "Outcome Category Γ SDG": dict( | |
| rows=OUTCOMES, cols=SDGS, | |
| rc="outcome_categories_assigned", cc="un_sustainable_development_goal", | |
| rl={o: o for o in OUTCOMES}, cl=SDG_SHORT, h=380, | |
| ), | |
| "PSE Intervention Γ PSE Outcome": dict( | |
| rows=INTERV_PSE, cols=OUTC_PSE, | |
| rc="interv_PSE", cc="outc_PSE", | |
| rl={i: i for i in INTERV_PSE}, cl={o: o for o in OUTC_PSE}, h=480, | |
| ), | |
| "PSE Intervention Γ SDG": dict( | |
| rows=INTERV_PSE, cols=SDGS, | |
| rc="interv_PSE", cc="un_sustainable_development_goal", | |
| rl={i: i for i in INTERV_PSE}, cl=SDG_SHORT, h=480, | |
| ), | |
| "PSE Outcome Γ SDG": dict( | |
| rows=OUTC_PSE, cols=SDGS, | |
| rc="outc_PSE", cc="un_sustainable_development_goal", | |
| rl={o: o for o in OUTC_PSE}, cl=SDG_SHORT, h=460, | |
| ), | |
| } | |
| cfg = configs.get(analysis_type, configs["Intervention Category Γ Outcome Category"]) | |
| mat = build_matrix(cfg["rows"], cfg["cols"], cfg["rc"], cfg["cc"], | |
| split_col=True, dff=dff) | |
| summary_html = make_gap_summary(mat, cfg["rl"], cfg["cl"], analysis_type + sfx) | |
| fig = make_gap_heatmap(mat, cfg["rl"], cfg["cl"], | |
| f"Evidence Map: {analysis_type}{sfx}", height=cfg["h"]) | |
| try: | |
| chart_html = fig_to_html(fig) | |
| except Exception as e: | |
| chart_html = f"<p style='color:red'>Chart error: {e}</p>" | |
| return summary_html + chart_html | |
| # --------------------------------------------------------------------------- | |
| # Custom CSS β 3ie professional dark navy theme | |
| # --------------------------------------------------------------------------- | |
| CUSTOM_CSS = """ | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); | |
| * { font-family: 'Inter', sans-serif; } | |
| /* Main background */ | |
| .gradio-container { background: #f0f2f5; max-width: 1200px; margin: 0 auto; } | |
| /* Header banner */ | |
| .egm-header { | |
| background: linear-gradient(135deg, #0a2342 0%, #1a3a5c 60%, #1e5280 100%); | |
| color: white; | |
| padding: 28px 32px 22px; | |
| border-radius: 12px; | |
| margin-bottom: 20px; | |
| box-shadow: 0 4px 20px rgba(10,35,66,0.25); | |
| } | |
| .egm-header * { color: #ffffff !important; } | |
| .egm-header h1 { font-size: 24px; font-weight: 700; margin: 0 0 4px; letter-spacing: -0.3px; color: #ffffff !important; } | |
| .egm-header p { font-size: 13px; opacity: 0.75; margin: 0; font-weight: 400; color: #ffffff !important; } | |
| .egm-badges { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; } | |
| .egm-badge { | |
| background: rgba(255,255,255,0.12); | |
| border: 1px solid rgba(255,255,255,0.2); | |
| border-radius: 20px; | |
| padding: 3px 12px; | |
| font-size: 11px; | |
| font-weight: 500; | |
| color: rgba(255,255,255,0.9); | |
| } | |
| /* Tabs */ | |
| .tab-nav button { | |
| font-weight: 600; | |
| font-size: 13px; | |
| color: #4a5568; | |
| border-radius: 8px 8px 0 0; | |
| } | |
| .tab-nav button.selected { | |
| color: #0a2342; | |
| border-bottom: 3px solid #0a2342; | |
| background: white; | |
| } | |
| /* Chat bubbles β Gradio 5 selectors */ | |
| .message.user > div, .user > .bubble-wrap, [data-testid="user"] .prose, | |
| .message.user .prose, div.user { background: #0a2342 !important; color: #ffffff !important; border-radius: 14px 14px 4px 14px !important; } | |
| .message.user > div *, .message.user p, .message.user span { color: #ffffff !important; } | |
| .message.bot > div, .bot > .bubble-wrap, [data-testid="bot"] .prose, | |
| .message.bot .prose { 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; } | |
| /* Input box */ | |
| textarea, input[type=text] { | |
| border: 1.5px solid #cbd5e0; | |
| border-radius: 10px; | |
| background: white; | |
| font-size: 14px; | |
| transition: border-color 0.2s; | |
| } | |
| textarea:focus, input[type=text]:focus { border-color: #0a2342; box-shadow: 0 0 0 3px rgba(10,35,66,0.08); } | |
| /* Buttons */ | |
| .gr-button-primary { | |
| background: #0a2342; | |
| border: none; | |
| border-radius: 10px; | |
| font-weight: 600; | |
| letter-spacing: 0.2px; | |
| transition: background 0.2s; | |
| } | |
| .gr-button-primary:hover { background: #1a3a5c; } | |
| .gr-button-secondary { border-color: #cbd5e0; border-radius: 10px; font-weight: 500; } | |
| /* Panels / blocks */ | |
| .gr-panel, .gr-box { border-radius: 12px; border: 1px solid #e2e8f0; box-shadow: 0 1px 6px rgba(0,0,0,0.05); } | |
| /* Slider */ | |
| .gr-slider input[type=range]::-webkit-slider-thumb { background: #0a2342; } | |
| /* Dropdown */ | |
| .gr-dropdown { border-radius: 10px; } | |
| /* Footer */ | |
| .egm-footer { font-size: 11px; color: #94a3b8; text-align: center; padding: 12px 0 4px; } | |
| /* Ensure bold text inside navy cards stays white */ | |
| .egm-card b, .egm-card strong { color: #ffffff; } | |
| """ | |
| with gr.Blocks(title="EGM Research Assistant", theme=gr.themes.Base(), css=CUSTOM_CSS) as demo: | |
| gr.HTML(f""" | |
| <div class="egm-header"> | |
| <h1>π EGM Research Assistant</h1> | |
| <p>Evidence Gap Map Β· AI-powered research synthesis Β· Dataset-only responses</p> | |
| <div class="egm-badges"> | |
| <span class="egm-badge">π {TOTAL} studies</span> | |
| <span class="egm-badge">π Semantic RAG</span> | |
| <span class="egm-badge">β Dataset-only responses</span> | |
| <span class="egm-badge">π Evidence visualised</span> | |
| </div> | |
| </div> | |
| """) | |
| with gr.Tabs(): | |
| # ββ Tab 1: Chat ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with gr.TabItem("π¬ Research Chat"): | |
| with gr.Row(): | |
| with gr.Column(scale=4): | |
| chatbot = gr.Chatbot(label="Research conversation", | |
| height=480, type="messages") | |
| with gr.Row(): | |
| msg = gr.Textbox( | |
| placeholder="e.g. What does the evidence say about cash transfers on education outcomes?", | |
| label="Your question", lines=2, scale=5) | |
| send_btn = gr.Button("Send β", variant="primary", scale=1) | |
| with gr.Column(scale=1, min_width=220): | |
| gr.Markdown("### βοΈ Settings") | |
| top_k = gr.Slider(minimum=10, maximum=358, value=25, step=5, | |
| label="Studies retrieved per query", | |
| info="Charts show ONLY retrieved studies β lower = more focused on your question") | |
| clear_btn = gr.Button("π Clear", variant="secondary") | |
| gr.Markdown("### π‘ Try asking") | |
| gr.Examples(examples=[ | |
| ["What interventions improve employment outcomes?"], | |
| ["Evidence on cash transfers for education in Sub-Saharan Africa"], | |
| ["How effective is microfinance for women's empowerment?"], | |
| ["Which studies focus on fragile or conflict-affected states?"], | |
| ["What qualitative studies exist in the dataset?"], | |
| ], inputs=msg) | |
| chat_chart = gr.HTML(value=EMPTY_HTML, label="Evidence snapshot") | |
| send_btn.click(fn=chat, inputs=[msg, chatbot, top_k], | |
| outputs=[chatbot, chat_chart]).then(lambda: "", outputs=msg) | |
| msg.submit(fn=chat, inputs=[msg, chatbot, top_k], | |
| outputs=[chatbot, chat_chart]).then(lambda: "", outputs=msg) | |
| clear_btn.click(fn=clear_chat, outputs=[chatbot, chat_chart]) | |
| # ββ Tab 2: Dataset Summary ββββββββββββββββββββββββββββββββββββββββ | |
| with gr.TabItem("π Dataset Summary"): | |
| # Load Plotly JS once β shared by all charts in this tab | |
| gr.Markdown(""" | |
| ### Explore the spread of any column in the dataset | |
| Select a column below to instantly see how studies are distributed across categories. | |
| No AI involved β this reads directly from the dataset. | |
| """) | |
| col_dropdown = gr.Dropdown( | |
| choices=list(SUMMARY_COLS.keys()), | |
| value="PSE Intervention Group", | |
| label="Select column to summarise", | |
| interactive=True, | |
| ) | |
| summary_chart = gr.HTML( | |
| value=make_summary_chart("Intervention Category"), | |
| label="Column distribution" | |
| ) | |
| col_dropdown.change( | |
| fn=make_summary_chart, | |
| inputs=col_dropdown, | |
| outputs=summary_chart, | |
| ) | |
| # ββ Tab 3: Evidence Gaps βββββββββββββββββββββββββββββββββββββββββ | |
| with gr.TabItem("π΄ Evidence Gaps"): | |
| gr.Markdown(""" | |
| ### Evidence Gap Analysis | |
| Heatmaps showing where evidence exists and where it is missing. Cells show study count or **GAP** where no evidence exists. | |
| π΄ Red = no studies | π‘ Amber = limited (<5) | π΅ Navy = strong (10+) | |
| """) | |
| with gr.Row(): | |
| gap_type = gr.Radio( | |
| choices=[ | |
| "Intervention Category Γ Outcome Category", | |
| "Intervention Category Γ SDG", | |
| "Outcome Category Γ SDG", | |
| "PSE Intervention Γ PSE Outcome", | |
| "PSE Intervention Γ SDG", | |
| "PSE Outcome Γ SDG", | |
| ], | |
| value="Intervention Category Γ Outcome Category", | |
| label="Analysis type", | |
| interactive=True, | |
| ) | |
| gap_method = gr.Radio( | |
| choices=["All", "IE Quantitative", "IE Qualitative", "SR (Systematic Review)"], | |
| value="All", | |
| label="Filter by method type", | |
| interactive=True, | |
| ) | |
| gap_output = gr.HTML( | |
| value=make_gap_tab_content("Intervention Category Γ Outcome Category", "All") | |
| ) | |
| gap_type.change(fn=make_gap_tab_content, inputs=[gap_type, gap_method], outputs=gap_output) | |
| gap_method.change(fn=make_gap_tab_content, inputs=[gap_type, gap_method], outputs=gap_output) | |
| gr.HTML('<div class="egm-footer">Responses grounded exclusively in the EGM dataset Β· sentence-transformers + ChromaDB + Claude </div>') | |
| if __name__ == "__main__": | |
| demo.launch() | |