Spaces:
Sleeping
Sleeping
Delete app.py
Browse files
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} · {n_cats} categories · {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 · {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 (<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 | 🟡 amber = limited evidence | 🔵 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|