Spaces:
Sleeping
Sleeping
| """Gradio app for the Amul AI / CeRAI evaluation submission. | |
| Tabs: | |
| 1. Report β the written findings, rendered. | |
| 2. Live demo β try Amul AI's chat endpoint right here. | |
| 3. CeRAI run results β table + per-dataset breakdown from the latest CeRAI run. | |
| 4. Run mini-eval β kick off a small eval against Amul AI in-browser; uses | |
| a built-in 6-prompt slice from the BBK dairy split so | |
| the Space stays fast. | |
| Designed to work both locally (`python app.py`) and on Hugging Face Spaces. | |
| On Spaces, set the model `Hardware` to CPU basic β no GPU is needed. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import re | |
| import time | |
| import uuid | |
| from pathlib import Path | |
| from threading import Lock | |
| import gradio as gr | |
| import httpx | |
| import plotly.graph_objects as go | |
| ROOT = Path(__file__).resolve().parent | |
| REPORT_DIR = ROOT / "report" | |
| RESULTS_DIR = ROOT / "results" | |
| AMUL_API_BASE = "https://api.prod.amulai.in" | |
| AMUL_ORIGIN = "https://amulai.in" | |
| AMUL_USER_AGENT = ( | |
| "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " | |
| "(KHTML, like Gecko) Chrome/120 Safari/537.36" | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # Anonymous JWT cache. Same idea as amul_proxy/server.py but inline so the | |
| # Space doesn't need a sidecar process. | |
| # --------------------------------------------------------------------------- # | |
| class _TokenCache: | |
| def __init__(self) -> None: | |
| self._token: str | None = None | |
| self._expiry: float = 0.0 | |
| self._lock = Lock() | |
| def get(self) -> str: | |
| with self._lock: | |
| if self._token and time.time() < self._expiry - 60: | |
| return self._token | |
| r = httpx.post( | |
| f"{AMUL_API_BASE}/api/auth/anonymous", | |
| headers={ | |
| "origin": AMUL_ORIGIN, | |
| "referer": f"{AMUL_ORIGIN}/", | |
| "content-type": "application/json", | |
| "user-agent": AMUL_USER_AGENT, | |
| }, | |
| json={}, | |
| timeout=30, | |
| ) | |
| r.raise_for_status() | |
| data = r.json() | |
| self._token = data["access_token"] | |
| self._expiry = time.time() + int(data.get("expires_in", 3600)) | |
| return self._token | |
| TOKEN = _TokenCache() | |
| def call_amul(query: str, session_id: str | None = None, | |
| source_lang: str = "en", target_lang: str = "en") -> tuple[str, float]: | |
| """One round-trip to Amul AI. Returns (text, elapsed_seconds).""" | |
| sid = session_id or f"hfspace-{uuid.uuid4()}" | |
| started = time.time() | |
| token = TOKEN.get() | |
| params = { | |
| "session_id": sid, | |
| "query": query, | |
| "source_lang": source_lang, | |
| "target_lang": target_lang, | |
| "use_translation_pipeline": "false", | |
| } | |
| headers = { | |
| "authorization": f"Bearer {token}", | |
| "accept": "*/*", | |
| "origin": AMUL_ORIGIN, | |
| "referer": f"{AMUL_ORIGIN}/", | |
| "user-agent": AMUL_USER_AGENT, | |
| } | |
| chunks: list[str] = [] | |
| with httpx.stream("GET", f"{AMUL_API_BASE}/api/chat/", params=params, | |
| headers=headers, timeout=120) as r: | |
| r.raise_for_status() | |
| for piece in r.iter_text(): | |
| if piece: | |
| chunks.append(piece) | |
| return "".join(chunks).strip(), time.time() - started | |
| # --------------------------------------------------------------------------- # | |
| # Tab content # | |
| # --------------------------------------------------------------------------- # | |
| def _read_or_placeholder(path: Path, fallback: str) -> str: | |
| return path.read_text() if path.exists() else fallback | |
| def _latest_run_dir() -> Path | None: | |
| if not RESULTS_DIR.exists(): | |
| return None | |
| runs = sorted( | |
| (p for p in RESULTS_DIR.iterdir() if (p / "summary.json").exists()), | |
| key=lambda p: p.stat().st_mtime, | |
| reverse=True, | |
| ) | |
| return runs[0] if runs else None | |
| # Each of the four written deliverables under report/ becomes its own tab. | |
| # Re-read at build_ui() time, so editing the markdown and re-launching the | |
| # Space (or running `python app.py`) picks up changes without a code edit. | |
| REPORT_DOCS: list[tuple[str, str, str]] = [ | |
| # (tab label, file basename, fallback) | |
| ("Findings", "findings.md", "_missing report/findings.md_"), | |
| ("How CeRAI works", "cerai_overview.md", "_missing report/cerai_overview.md_"), | |
| ("Edits we made", "cerai_ai_evaluation_tool_edits.md", "_missing report/cerai_ai_evaluation_tool_edits.md_"), | |
| ("Proposed extensions", "extension_proposal.md", "_missing report/extension_proposal.md_"), | |
| ] | |
| # --------------------------------------------------------------------------- # | |
| # Charts # | |
| # --------------------------------------------------------------------------- # | |
| # Three plots that carry the headline story from results/<latest>/*.json: | |
| # 1. Surface metrics β BBK vs KCC bars, colour-coded by metric. | |
| # 2. Surface metrics vs LLM judge β the "judge sees what BLEU misses" story. | |
| # 3. LLM judge score histogram β the bimodal 0/1 distribution. | |
| # Each one degrades to a placeholder if the underlying JSON is missing, so the | |
| # Space still renders cleanly on a fresh clone without a CeRAI run. | |
| _BBK_COLOR = "#4c72b0" # blue | |
| _KCC_COLOR = "#dd8452" # orange | |
| _JUDGE_COLOR = "#55a868" # green | |
| _BG = "rgba(0,0,0,0)" | |
| def _load_summary_and_judge() -> tuple[dict | None, dict | None]: | |
| rd = _latest_run_dir() | |
| if not rd: | |
| return None, None | |
| summary = json.loads((rd / "summary.json").read_text()) | |
| judge_path = rd / "llm_judge.json" | |
| judge = json.loads(judge_path.read_text()) if judge_path.exists() else None | |
| return summary, judge | |
| def _placeholder_figure(text: str) -> go.Figure: | |
| fig = go.Figure() | |
| fig.add_annotation(text=text, xref="paper", yref="paper", x=0.5, y=0.5, | |
| showarrow=False, font={"size": 14, "color": "#888"}) | |
| fig.update_layout(paper_bgcolor=_BG, plot_bgcolor=_BG, | |
| xaxis={"visible": False}, yaxis={"visible": False}, | |
| margin={"l": 20, "r": 20, "t": 30, "b": 20}) | |
| return fig | |
| def chart_surface_metrics_per_dataset() -> go.Figure: | |
| summary, _ = _load_summary_and_judge() | |
| if not summary or not summary.get("by_dataset_metric"): | |
| return _placeholder_figure("No run results yet β run bash scripts/run_full_eval.sh") | |
| # Keep only the four content-quality metrics (drop TAT/Error_Rate which | |
| # aren't on a 0-1 scale and don't tell the surface-overlap story). | |
| metric_order = ["Lexical_Diversity", "BLEU", "ROUGE", "METEOR"] | |
| bbk = summary["by_dataset_metric"].get("bbk", {}) | |
| kcc = summary["by_dataset_metric"].get("kcc", {}) | |
| fig = go.Figure(data=[ | |
| go.Bar(name="BBK (structured benchmark)", | |
| x=metric_order, | |
| y=[bbk.get(m, {}).get("score_mean", 0) or 0 for m in metric_order], | |
| marker_color=_BBK_COLOR, | |
| text=[f"{bbk.get(m, {}).get('score_mean', 0):.2f}" for m in metric_order], | |
| textposition="outside"), | |
| go.Bar(name="KCC (real farmer Q&A)", | |
| x=metric_order, | |
| y=[kcc.get(m, {}).get("score_mean", 0) or 0 for m in metric_order], | |
| marker_color=_KCC_COLOR, | |
| text=[f"{kcc.get(m, {}).get('score_mean', 0):.2f}" for m in metric_order], | |
| textposition="outside"), | |
| ]) | |
| fig.update_layout( | |
| title="CeRAI surface-metric scores: BBK vs KCC", | |
| barmode="group", yaxis={"range": [0, 1.05], "title": "Mean score (0β1, higher = better)"}, | |
| paper_bgcolor=_BG, plot_bgcolor=_BG, | |
| legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1}, | |
| margin={"l": 50, "r": 20, "t": 80, "b": 50}, height=380, | |
| ) | |
| return fig | |
| def chart_surface_vs_judge() -> go.Figure: | |
| summary, judge = _load_summary_and_judge() | |
| if not summary or not judge: | |
| return _placeholder_figure("Run the eval + Gemini judge first (steps 5 & 6 in the README)") | |
| bbk = summary["by_dataset_metric"].get("bbk", {}) | |
| kcc = summary["by_dataset_metric"].get("kcc", {}) | |
| # Mean of BLEU/ROUGE/METEOR per dataset β collapsed into one "surface metric" | |
| # bar so the comparison against the judge is one-vs-one per dataset. | |
| def _surface_mean(dat: dict) -> float: | |
| xs = [dat.get(m, {}).get("score_mean", 0) or 0 for m in ("BLEU", "ROUGE", "METEOR")] | |
| return sum(xs) / max(1, len(xs)) | |
| surface = {"BBK": _surface_mean(bbk), "KCC": _surface_mean(kcc)} | |
| judge_means = judge.get("by_dataset", {}) | |
| judge_vals = {"BBK": judge_means.get("bbk", {}).get("mean", 0) or 0, | |
| "KCC": judge_means.get("kcc", {}).get("mean", 0) or 0} | |
| datasets = ["BBK", "KCC"] | |
| fig = go.Figure(data=[ | |
| go.Bar(name="CeRAI surface metrics (mean of BLEU/ROUGE/METEOR)", | |
| x=datasets, y=[surface[d] for d in datasets], | |
| marker_color=_BBK_COLOR, | |
| text=[f"{surface[d]:.3f}" for d in datasets], textposition="outside"), | |
| go.Bar(name=f"LLM-as-judge ({judge.get('judge_model', 'gemini')})", | |
| x=datasets, y=[judge_vals[d] for d in datasets], | |
| marker_color=_JUDGE_COLOR, | |
| text=[f"{judge_vals[d]:.3f}" for d in datasets], textposition="outside"), | |
| ]) | |
| fig.update_layout( | |
| title="Same chatbot, two verdicts β surface metrics vs the LLM judge", | |
| barmode="group", yaxis={"range": [0, 1.1], "title": "Score (0β1, higher = better)"}, | |
| paper_bgcolor=_BG, plot_bgcolor=_BG, | |
| legend={"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1}, | |
| margin={"l": 50, "r": 20, "t": 80, "b": 50}, height=380, | |
| ) | |
| return fig | |
| def chart_judge_distribution() -> go.Figure: | |
| _, judge = _load_summary_and_judge() | |
| if not judge or not judge.get("rows"): | |
| return _placeholder_figure("Run the Gemini judge first (step 6 in the README)") | |
| scores = [r["score"] for r in judge["rows"]] | |
| fig = go.Figure(data=[ | |
| go.Histogram(x=scores, nbinsx=11, marker_color=_JUDGE_COLOR, | |
| hovertemplate="score bucket: %{x}<br>count: %{y}<extra></extra>"), | |
| ]) | |
| n_zero = sum(1 for s in scores if s == 0.0) | |
| n_one = sum(1 for s in scores if s == 1.0) | |
| fig.update_layout( | |
| title=f"Judge score distribution (n={len(scores)}, mean={judge.get('mean_score', 0):.3f}) Β· " | |
| f"bimodal: {n_zero} hard-0s, {n_one} clean-1s", | |
| xaxis={"title": "judge score (0 = wrong, 1 = correct)", "range": [-0.05, 1.05]}, | |
| yaxis={"title": "count of prompts"}, | |
| paper_bgcolor=_BG, plot_bgcolor=_BG, | |
| margin={"l": 50, "r": 20, "t": 80, "b": 50}, height=380, | |
| showlegend=False, | |
| ) | |
| return fig | |
| def latest_results_summary() -> tuple[str, str]: | |
| rd = _latest_run_dir() | |
| if not rd: | |
| return ("_No CeRAI run results checked into the repo yet. " | |
| "Run `bash scripts/run_full_eval.sh` locally to populate `results/`._", | |
| "{}") | |
| summary = json.loads((rd / "summary.json").read_text()) | |
| md_lines = [f"### Run `{summary['run_name']}`", | |
| f"- Total testcases: **{summary['total_testcases']}**", | |
| f"- Datasets: " + ", ".join( | |
| f"{k} ({v})" for k, v in summary.get("datasets", {}).items()), | |
| "", | |
| "| Dataset | Metric | n | Mean score | Mean latency (s) | MCQ accuracy |", | |
| "|---|---|---|---|---|---|"] | |
| for ds, by_metric in summary.get("by_dataset_metric", {}).items(): | |
| for metric, info in by_metric.items(): | |
| mcq = info.get("mcq") | |
| mcq_text = ( | |
| "β" if not mcq else | |
| f"{mcq['correct']}/{mcq['n']} ({mcq['accuracy']*100:.0f}%)") | |
| md_lines.append( | |
| f"| {ds.upper()} | {metric} | {info['n']} | " | |
| f"{info['score_mean'] if info['score_mean'] is not None else 'β'} | " | |
| f"{info['latency_mean'] if info['latency_mean'] is not None else 'β'} | " | |
| f"{mcq_text} |" | |
| ) | |
| md_lines.append("") | |
| md_lines.append(f"_Per-prompt detail: see `results/{summary['run_name']}/results.md` " | |
| f"in the repo._") | |
| return "\n".join(md_lines), json.dumps(summary, indent=2) | |
| # --------------------------------------------------------------------------- # | |
| # Live demo handlers # | |
| # --------------------------------------------------------------------------- # | |
| def amul_chat(message, history): # untyped on purpose β list[dict] tripped | |
| # gradio_client's JSON-schema β Python-type resolver on the /api/info route | |
| # (it can't handle additionalProperties: true under dict-without-parameters). | |
| if not message or not str(message).strip(): | |
| return "Type a question first." | |
| text, elapsed = call_amul(str(message)) | |
| footer = f"\n\n_(latency: {elapsed:.2f}s Β· session: fresh)_" | |
| return text + footer | |
| # --------------------------------------------------------------------------- # | |
| # Mini-eval (in-browser, deterministic, small) # | |
| # --------------------------------------------------------------------------- # | |
| MCQ_LETTER_RE = re.compile(r"\s*\*?\*?([A-D])[\.\)\*\s]?") | |
| MINI_EVAL = [ | |
| { | |
| "q": "The normal titratable acidity in fresh cow milk is approximately 0.13 to 0.17%. True or false?", | |
| "expected_substrings": ["true", "yes", "0.1", "correct"], | |
| "kind": "free_form", | |
| }, | |
| { | |
| "q": ("Question: In HTST pasteurization milk is heated to at least?\n" | |
| "A) 63 Β°C\nB) 71.5 Β°C\nC) 75.5 Β°C\nD) 80 Β°C\n\n" | |
| "Reply with ONLY the single correct letter."), | |
| "expected_letter": "B", | |
| "kind": "mcq", | |
| }, | |
| { | |
| "q": ("Question: As per PFA rules fat content in skim milk should be?\n" | |
| "A) Not less than 0.25%\nB) Not more than 0.25%\n" | |
| "C) Not more than 0.5 %\nD) Not less than 0.5 %\n\n" | |
| "Reply with ONLY the single correct letter."), | |
| "expected_letter": "C", | |
| "kind": "mcq", | |
| }, | |
| { | |
| "q": "What is the typical lactation yield of a Sahiwal cow in litres per lactation?", | |
| "expected_substrings": ["sahiwal", "kg", "litre", "1500", "2000", "1700", "1800"], | |
| "kind": "free_form", | |
| }, | |
| ] | |
| def run_mini_eval(progress=gr.Progress()) -> tuple[str, str]: | |
| rows = [] | |
| correct = 0 | |
| total = 0 | |
| for i, item in enumerate(MINI_EVAL): | |
| progress(i / len(MINI_EVAL), desc=f"Asking Amul AI (q {i+1}/{len(MINI_EVAL)})") | |
| try: | |
| text, elapsed = call_amul(item["q"]) | |
| except httpx.HTTPStatusError as exc: | |
| rows.append((item["q"][:60], "ERROR", | |
| f"upstream {exc.response.status_code}: {exc.response.text[:100]}", | |
| "β", "β")) | |
| continue | |
| except httpx.RequestError as exc: | |
| rows.append((item["q"][:60], "ERROR", | |
| f"network error: {type(exc).__name__}: {str(exc)[:120]}", | |
| "β", "β")) | |
| continue | |
| except Exception as exc: | |
| rows.append((item["q"][:60], "ERROR", | |
| f"{type(exc).__name__}: {str(exc)[:120]}", "β", "β")) | |
| continue | |
| if item["kind"] == "mcq": | |
| m = MCQ_LETTER_RE.match(text.strip()) | |
| got = m.group(1).upper() if m else "?" | |
| ok = got == item["expected_letter"] | |
| correct += int(ok) | |
| total += 1 | |
| rows.append((item["q"][:60], item["expected_letter"], got, | |
| f"{elapsed:.2f}s", "β " if ok else "β")) | |
| else: | |
| blob = text.lower() | |
| ok = any(s in blob for s in item["expected_substrings"]) | |
| rows.append((item["q"][:60], "/".join(item["expected_substrings"]), | |
| text[:80], f"{elapsed:.2f}s", "β " if ok else "β")) | |
| progress(1.0, desc="Done") | |
| score_text = (f"**Score**: {correct}/{total} MCQ correct" if total | |
| else "**Score**: free-form rows are flagged with β if any expected substring appears.") | |
| md = ["| Prompt (truncated) | Expected | Got | Latency | OK |", | |
| "|---|---|---|---|---|"] | |
| for r in rows: | |
| md.append(f"| {r[0]} | {r[1]} | {r[2]} | {r[3]} | {r[4]} |") | |
| return score_text, "\n".join(md) | |
| # --------------------------------------------------------------------------- # | |
| # UI # | |
| # --------------------------------------------------------------------------- # | |
| HEADER = """ | |
| # Evaluating Amul AI with CeRAI's *AIEvaluationTool* | |
| > Started Option A (use the tool to evaluate a real conversational endpoint). | |
| > Found enough fundamental issues mid-run that the writeup pivots into Option B | |
| > territory, empirical run, concrete extension proposal grounded in | |
| > 2025/26 evaluation literature. Both halves of evidence are in this Space and | |
| > the linked GitHub repo. | |
| """ | |
| def build_ui() -> gr.Blocks: | |
| with gr.Blocks(title="Amul AI Γ CeRAI β Eval submission", | |
| theme=gr.themes.Soft()) as demo: | |
| gr.Markdown(HEADER) | |
| with gr.Tabs(): | |
| with gr.Tab("Results at a glance"): | |
| gr.Markdown( | |
| "## Headline numbers from the latest CeRAI run\n\n" | |
| "Three plots that summarise what this evaluation found. The " | |
| "narrative version is in the **Findings** tab; the per-prompt " | |
| "table is in **CeRAI run results**.\n\n" | |
| "**Why the chatbot looks bad here and good in the judge plot below " | |
| "is the entire argument of this submission** β surface-overlap " | |
| "metrics (BLEU/ROUGE/METEOR) penalise long English responses " | |
| "against short or Romanised-Hindi references; an LLM judge with " | |
| "an escape-hatch rubric tells a very different (and arguably more " | |
| "honest) story about the same data." | |
| ) | |
| gr.Plot(chart_surface_metrics_per_dataset()) | |
| gr.Plot(chart_surface_vs_judge()) | |
| gr.Plot(chart_judge_distribution()) | |
| with gr.Accordion("How to read these", open=False): | |
| gr.Markdown( | |
| "- **Chart 1**: CeRAI's surface metrics on each dataset. " | |
| "BBK is short option-text references (ROUGE picks up some " | |
| "overlap, BLEU/METEOR don't); KCC is Romanised-Hindi short " | |
| "references vs the chatbot's English replies β overlap " | |
| "collapses to ~0.\n" | |
| "- **Chart 2**: Same chatbot, two verdicts. The surface " | |
| "metrics average β 0.30 on BBK and β 0.01 on KCC. The LLM " | |
| "judge (with the rubric in `extension_proposal.md` Β§1/Β§3) " | |
| "scores them β 0.79 and β 1.00. That gap is the headline.\n" | |
| "- **Chart 3**: The judge is bimodal β most prompts are clean " | |
| "1.0s, a small set are clean 0.0s (factual misses), only a " | |
| "couple sit in the middle. That shape is more honest than " | |
| "the surface metrics' diffuse 0.0β0.4 cloud." | |
| ) | |
| for label, fname, fallback in REPORT_DOCS: | |
| with gr.Tab(label): | |
| gr.Markdown(_read_or_placeholder(REPORT_DIR / fname, fallback)) | |
| with gr.Tab("Try Amul AI"): | |
| gr.Markdown( | |
| "Calls `https://api.prod.amulai.in/api/chat/` with an anonymous " | |
| "JWT β same code path as our `amul_proxy/`. Each turn is a fresh " | |
| "session (no chat memory)." | |
| ) | |
| gr.ChatInterface( | |
| fn=amul_chat, | |
| type="messages", | |
| cache_examples=False, # examples cache builds JSON schema -> crash on bool | |
| examples=[ | |
| "How many liters of milk does a Holstein cow produce daily?", | |
| "What is the normal titratable acidity of fresh cow milk?", | |
| "Pasu ko khurpaka munhpaka ki bimari ho gayi hai, kya karu?", | |
| "How do I prevent mastitis in my buffalo?", | |
| ], | |
| ) | |
| with gr.Tab("CeRAI run results"): | |
| md, raw = latest_results_summary() | |
| gr.Markdown(md) | |
| with gr.Accordion("Raw summary.json", open=False): | |
| gr.Code(value=raw, language="json") | |
| with gr.Tab("Run mini-eval"): | |
| gr.Markdown( | |
| "Hits Amul AI with a tiny built-in 4-question slice (2 MCQ, " | |
| "2 free-form) drawn from BhashaBench-Krishi dairy/poultry. " | |
| "Useful for a quick smoke test from the browser; the *real* " | |
| "evaluation is in the **CeRAI run results** tab." | |
| ) | |
| run_btn = gr.Button("Run mini-eval", variant="primary") | |
| score_md = gr.Markdown() | |
| table_md = gr.Markdown() | |
| run_btn.click(run_mini_eval, outputs=[score_md, table_md]) | |
| with gr.Tab("How to reproduce"): | |
| gr.Markdown(_read_or_placeholder( | |
| ROOT / "README.md", | |
| "_README missing._")) | |
| return demo | |
| if __name__ == "__main__": | |
| demo = build_ui() | |
| demo.queue(default_concurrency_limit=2) | |
| demo.launch(server_name="0.0.0.0", | |
| server_port=int(os.environ.get("PORT", 7860)), | |
| show_api=False) # avoid /api/info schema crash in gradio_client 1.14 |