| """ |
| DocDoe OpenRouter Model Quality Benchmark |
| ========================================== |
| Calls OpenRouter directly (no HTTP server needed) by importing provider |
| internals and forcing one model per test run. |
| |
| Bypass _with_fallback so raw errors (429, 404, timeout) are captured. |
| Add exponential backoff retries for 429 rate limits. |
| |
| Tests 4 models x 7 endpoints = 28 API calls. |
| Topic: Kerala +2 Physics - Electromagnetic Induction. |
| |
| Usage: |
| python scripts/benchmark_openrouter_models.py # full run |
| python scripts/benchmark_openrouter_models.py --from-json # report only from saved raw JSON |
| |
| Output files: |
| scripts/benchmark_results_raw.json |
| DOCDOE_OPENROUTER_MODEL_QUALITY_BENCHMARK.md (project root) |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| import re |
| import sys |
| import time |
| from pathlib import Path |
| from typing import Any |
|
|
| |
| BACKEND_DIR = Path(__file__).resolve().parents[1] |
| if str(BACKEND_DIR) not in sys.path: |
| sys.path.insert(0, str(BACKEND_DIR)) |
|
|
| |
| import io |
| if hasattr(sys.stdout, "buffer"): |
| sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace") |
|
|
| |
| os.environ["AI_FALLBACK_TO_MOCK"] = "false" |
| os.environ.setdefault("AI_PROVIDER", "openrouter") |
|
|
| from app.core.config import get_settings |
| get_settings.cache_clear() |
|
|
| from app.services.ai_provider import ( |
| AIProviderError, |
| _FatalAPIError, |
| _http_status_code, |
| MockAIProvider, |
| OpenRouterAIProvider, |
| _build_prompt, |
| _parse_json_text, |
| _validate_or_pass, |
| SYSTEM_INSTRUCTION, |
| NotesAIOutput, |
| SimpleExplanationAIOutput, |
| QuizAIOutput, |
| FlashcardsAIOutput, |
| ExamModeAIOutput, |
| VideoScenePlanAIOutput, |
| ) |
|
|
| |
| TOPIC = "Electromagnetic Induction" |
| SUBJECT = "Physics" |
| LANGUAGE = "Malayalam + English" |
| LEVEL = "Intermediate" |
| GOAL = "A+" |
| TIME_LEFT = "5 hours" |
|
|
| CONTEXT = """ |
| Electromagnetic Induction - Kerala HSE +2 Physics (Chapter 6, NCERT equivalent) |
| |
| Faraday's Laws of Electromagnetic Induction: |
| 1st Law: Whenever the magnetic flux linked with a coil changes, an emf is induced in the coil. |
| 2nd Law: The induced emf is directly proportional to the rate of change of magnetic flux. |
| Formula: epsilon = -dPhi/dt [SI unit: Volt (V)] |
| Negative sign -> Lenz's Law: induced emf opposes the change causing it. |
| |
| Magnetic Flux: |
| Phi = B*A*cos(theta) [SI unit: Weber (Wb) = Tesla*m^2] |
| where B = magnetic field, A = area of coil, theta = angle between B and normal to coil. |
| |
| Lenz's Law: The direction of induced current is such that it opposes the cause that produced it. |
| Conservation of energy basis - work done against opposing force = electrical energy produced. |
| |
| Motional EMF: |
| epsilon = Blv [for a conductor of length l moving with velocity v perpendicular to field B] |
| |
| Self-Inductance (L): |
| epsilon = -L(dI/dt) [SI unit of L: Henry (H)] |
| Energy stored: U = 0.5*L*I^2 |
| |
| Mutual Inductance (M): |
| epsilon_2 = -M(dI_1/dt) |
| For coaxial coils: M = mu0*n1*n2*pi*r^2*l |
| |
| Transformer: |
| Vs/Vp = Ns/Np = Ip/Is |
| Step-up: Ns > Np; Step-down: Ns < Np |
| Efficiency: eta = (Vs*Is)/(Vp*Ip) * 100% |
| Losses: eddy currents (minimized by lamination), flux leakage, copper loss, hysteresis loss. |
| |
| AC Generator (Alternator): |
| epsilon = NBA*omega*sin(omega*t) = epsilon_0*sin(omega*t) |
| epsilon_0 = NBA*omega (peak emf) |
| Components: armature coil, field magnet, slip rings, brushes. |
| |
| Eddy Currents: |
| Induced currents in bulk conductors due to changing flux. |
| Applications: electromagnetic braking, induction heating, metal detectors. |
| Reduced by laminating the core. |
| |
| Kerala Board Frequently Asked Questions: |
| - State and prove Faraday's Laws (4 marks) |
| - State Lenz's Law and explain with an example (3 marks) |
| - Derive expression for motional emf (3 marks) |
| - Explain the principle of AC generator with diagram (5 marks) |
| - Distinguish between self-inductance and mutual inductance (2 marks) |
| - What are eddy currents? Give two applications (2 marks) |
| - Numerical: A coil of 200 turns, area 0.05 m2, field 0.1 T reverses in 0.02 s. |
| Find induced emf. [Answer: 100 V] |
| """.strip() |
|
|
| METADATA = { |
| "subject": SUBJECT, |
| "chapter": TOPIC, |
| "title": TOPIC, |
| "language": LANGUAGE, |
| "level": LEVEL, |
| "goal": GOAL, |
| "time_left": TIME_LEFT, |
| } |
|
|
| MODELS: dict[str, str] = { |
| "deepseek": "deepseek/deepseek-v4-flash:free", |
| "llama": "meta-llama/llama-3.3-70b-instruct:free", |
| "gpt_oss": "openai/gpt-oss-120b:free", |
| "nemotron": "nvidia/nemotron-3-nano-30b-a3b:free", |
| } |
|
|
| |
| ENDPOINT_DEFS: list[tuple] = [ |
| ( |
| "ask", |
| "Simple Explanation", |
| SimpleExplanationAIOutput, |
| ( |
| "Explain this topic as if teaching a 15-year-old who has never seen it before. " |
| "Simple meaning: one sentence a student can say out loud. " |
| "Explain like 15-year-old: conversational, relatable explanation. " |
| "Real life example: a concrete everyday analogy, not abstract. " |
| "Step by step: numbered learning sequence from zero to exam-ready. " |
| "Needed keywords: exact terms the student must use in answers. " |
| "Memory trick: one mnemonic or visual association. " |
| "Exam answer: a model answer the student could write directly on paper. " |
| "Quick checks: 2-3 self-test questions. " |
| "Mistakes to avoid: specific errors students make in this topic, not generic advice." |
| ), |
| ("main", "llama"), |
| ), |
| ( |
| "notes", |
| "Smart Notes", |
| NotesAIOutput, |
| ( |
| "Generate comprehensive exam-focused study notes. " |
| "Must learn first: prerequisite concepts the student needs before this topic. " |
| "Simple explanation: one clear paragraph a 15-year-old can understand. " |
| "Key points: concise, directly exam-relevant, not textbook copy-paste. " |
| "Important definitions: exact board-exam wording. " |
| "Formulas: with units, conditions, and common substitution patterns. " |
| "Diagrams to practice: only those boards actually ask for. " |
| "Exam keywords: terms that carry marks in evaluation. " |
| "Memory tricks: mnemonics or associations that actually stick. " |
| "Possible exam questions: realistic mark-allocated questions boards have asked or would ask. " |
| "Last-minute revision: 5-minute bullet refresh. " |
| "Quick checks: 2-3 self-test questions with one-line answers." |
| ), |
| ("main", "llama", "gpt_oss", "nemotron"), |
| ), |
| ( |
| "quiz", |
| "Quiz (5 Qs)", |
| QuizAIOutput, |
| ( |
| "Create exactly 5 exam-realistic practice questions with mixed difficulty. " |
| "Distribution: ~40% recall, ~30% understanding, ~20% application, ~10% tricky. " |
| "Each question must have: clear wording, correct answer, and explanation of WHY. " |
| "MCQ distractors should be plausible wrong answers students actually pick. " |
| "Weakness mapping: for each question, specify which concept to revise if wrong." |
| ), |
| ("main", "llama"), |
| ), |
| ( |
| "flashcards", |
| "Flashcards (6)", |
| FlashcardsAIOutput, |
| ( |
| "Create exactly 6 active-recall flashcards for exam preparation. " |
| "Mix types: definition, formula, process, keyword, mistake, exam answer. " |
| "Formula cards: include SI units on back. " |
| "Hints should help recall without giving the answer away." |
| ), |
| ("nemotron", "main"), |
| ), |
| ( |
| "exam-answer", |
| "Exam Answer", |
| ExamModeAIOutput, |
| ( |
| "Create mark-wise structured exam answers a student can memorize and reproduce. " |
| "1-mark: one crisp definition sentence with the exact scoring keyword. " |
| "2-mark: definition + one elaboration point, structured as two separate points. " |
| "4-mark: introduction sentence, 3-4 main points with keywords, conclusion sentence. " |
| "Answer writing formula: a reusable template the student can apply to any similar question. " |
| "Keywords to use: words that board evaluators specifically check for. " |
| "Mistakes to avoid: specific errors students make in THIS topic, not generic writing advice. " |
| "Teacher tip: one insider insight about how this topic is evaluated." |
| ), |
| ("main", "gpt_oss", "llama"), |
| ), |
| ( |
| "last-night", |
| "Last-Night Notes", |
| NotesAIOutput, |
| ( |
| "Generate a last-night emergency exam revision guide. " |
| "Only the highest-yield content that appears every year. " |
| "Must learn first: what the student CANNOT skip. " |
| "Key points: bulleted, exam-answer-ready, under 10 words each. " |
| "Formulas: every formula with units and typical substitution. " |
| "Memory tricks: fast mnemonics only. " |
| "Possible exam questions: the 3 most likely questions this topic will generate. " |
| "Last-minute revision: 5 bullets the student should read 15 minutes before the exam." |
| ), |
| ("main", "llama"), |
| ), |
| ( |
| "video-plan", |
| "Video Scene Plan", |
| VideoScenePlanAIOutput, |
| ( |
| f"Create a no-avatar educational explainer scene plan for {TOPIC}. " |
| "Total duration: 2 minutes. Visual style: clean_explainer. " |
| "For every scene: one idea only, screen_text max 8 words, " |
| "subtitle_text max 14 words, voice_text as casual tutor explanation, " |
| "visual_hint with concrete icons/cards/arrows, keywords, transition, " |
| "purpose, learning_purpose, visual_elements. " |
| "Include hook, concept, formula, example, exam tip, recap scenes." |
| ), |
| ("main", "llama"), |
| ), |
| ] |
|
|
| |
|
|
| EM_KEYWORDS = [ |
| "faraday", "lenz", "flux", "emf", "inductance", "transformer", |
| "eddy", "motional", "magnetic", "coil", "conductor", "tesla", |
| "weber", "henry", "volt", "generator", "armature", "slip ring", |
| "lamination", "mutual", "self-inductance", |
| "epsilon", "phi", "blv", "dI/dt", |
| ] |
|
|
| FORMULA_PATTERNS = [ |
| r"epsilon\s*=|emf\s*=", |
| r"phi\s*=|flux\s*=", |
| r"blv|B[*]l[*]v", |
| r"dI/dt|dphi/dt", |
| r"Vs/Vp|Ns/Np", |
| r"eta\s*=|efficiency", |
| r"\[V\]|\[Wb\]|\[H\]|\[T\]|SI unit|henry|weber|volt", |
| r"0\.5\s*L\s*I|U\s*=|energy stored", |
| ] |
|
|
|
|
| def _score_output(data: dict[str, Any]) -> dict[str, Any]: |
| text = json.dumps(data, ensure_ascii=False).lower() |
|
|
| keyword_hits = sum(1 for kw in EM_KEYWORDS if kw.lower() in text) |
| formula_hits = sum(1 for pat in FORMULA_PATTERNS if re.search(pat, text, re.IGNORECASE)) |
| list_count = sum(len(v) for v in data.values() if isinstance(v, list)) |
|
|
| has_units = bool(re.search(r"\[V\]|\[Wb\]|\[H\]|\[T\]|SI unit|henry|weber|volt", text)) |
| has_marks = bool(re.search(r"1.mark|2.mark|4.mark|1-mark|4-mark|mark answer", text)) |
| has_lenz = "lenz" in text |
| has_formula = formula_hits > 0 |
| has_diagram = "diagram" in text or "labelled" in text or "generator diagram" in text |
| is_generic = bool(re.search( |
| r"study hard|good luck|remember to|practice makes|take notes|stay positive|you can do it", |
| text, |
| )) |
|
|
| score = 0 |
| score += min(keyword_hits, 4) |
| score += min(formula_hits, 2) |
| score += 1 if has_units else 0 |
| score += 1 if has_marks else 0 |
| score += 1 if has_lenz else 0 |
| score -= 1 if is_generic else 0 |
|
|
| return { |
| "score": max(score, 0), |
| "max_score": 10, |
| "keyword_hits": keyword_hits, |
| "formula_hits": formula_hits, |
| "list_items": list_count, |
| "has_units": has_units, |
| "has_marks": has_marks, |
| "has_lenz": has_lenz, |
| "has_formula": has_formula, |
| "has_diagram": has_diagram, |
| "is_generic": is_generic, |
| } |
|
|
|
|
| |
|
|
| def _make_single_model_provider(model_id: str, timeout: float = 120.0) -> OpenRouterAIProvider: |
| try: |
| from openai import OpenAI |
| except ImportError: |
| print("ERROR: openai package not installed.") |
| sys.exit(1) |
|
|
| settings = get_settings() |
| if not settings.openrouter_api_key: |
| print("ERROR: OPENROUTER_API_KEY not set.") |
| sys.exit(1) |
|
|
| provider = OpenRouterAIProvider.__new__(OpenRouterAIProvider) |
| provider._settings = settings |
| |
| provider._settings = type("S", (), { |
| **{k: getattr(settings, k) for k in dir(settings) if not k.startswith("_")}, |
| "ai_timeout_seconds": int(timeout), |
| "ai_fallback_to_mock": False, |
| })() |
| provider._fallback = MockAIProvider() |
| provider._models = {k: model_id for k in ("main", "llama", "gpt_oss", "nemotron")} |
| provider._client = OpenAI( |
| base_url=settings.openrouter_base_url, |
| api_key=settings.openrouter_api_key, |
| ) |
| provider.model_name = f"openrouter:{model_id}" |
| provider.is_fallback = False |
| provider.fallback_reason = None |
| provider.last_error_code = None |
| return provider |
|
|
|
|
| |
|
|
| _RETRY_DELAYS = [15, 30, 60] |
|
|
|
|
| def _call_direct( |
| provider: OpenRouterAIProvider, |
| task: str, |
| schema: type, |
| route: tuple, |
| model_id: str, |
| ) -> tuple[dict[str, Any], str]: |
| """ |
| Call _generate_json directly with retry on 429. |
| Returns (output_dict, error_category) where error_category is '' on success. |
| """ |
| |
| import json as _json |
| schema_hint = _json.dumps(schema.model_json_schema(), indent=2) |
| system_msg = ( |
| f"{SYSTEM_INSTRUCTION}\n\n" |
| f"Return ONLY a valid JSON object matching this schema:\n{schema_hint}\n" |
| "Rules: no markdown fences, no prose before or after the JSON, " |
| "no trailing commas, no comments. Start directly with '{'." |
| ) |
| prompt = _build_prompt(task=task, context=CONTEXT, language=LANGUAGE, metadata=METADATA) |
|
|
| for attempt in range(len(_RETRY_DELAYS) + 1): |
| try: |
| text = provider._call_model(model_id, system_msg, prompt) |
| data = _parse_json_text(text) |
| return _validate_or_pass(data, schema), "" |
| except _FatalAPIError as exc: |
| return {}, f"FATAL_{exc.status_code}" |
| except Exception as exc: |
| code = _http_status_code(exc) |
| if code == 404: |
| return {}, "404_POLICY" |
| if code == 429 and attempt < len(_RETRY_DELAYS): |
| wait = _RETRY_DELAYS[attempt] |
| print(f"\n [429 rate-limited, waiting {wait}s...]", end="", flush=True) |
| time.sleep(wait) |
| continue |
| if code == 429: |
| return {}, "429_RATE_LIMIT" |
| |
| exc_str = str(exc).lower() |
| if "timeout" in exc_str or "timed out" in exc_str: |
| return {}, "TIMEOUT" |
| if "json" in exc_str or "decode" in exc_str: |
| return {}, "JSON_PARSE_ERROR" |
| return {}, f"ERROR_{code or 'UNKNOWN'}: {str(exc)[:80]}" |
| return {}, "429_RATE_LIMIT_EXHAUSTED" |
|
|
|
|
| |
|
|
| def _bar(score: int, max_score: int = 10) -> str: |
| filled = round(score / max_score * 10) |
| return "#" * filled + "." * (10 - filled) |
|
|
|
|
| def run_benchmark() -> dict: |
| settings = get_settings() |
| if not settings.openrouter_api_key: |
| print("ERROR: OPENROUTER_API_KEY not configured.") |
| sys.exit(1) |
|
|
| print("\n" + "=" * 70) |
| print(" DocDoe OpenRouter Model Quality Benchmark") |
| print(f" Topic : {TOPIC} ({SUBJECT}, Kerala +2)") |
| print(f" Goal : {GOAL} | Level: {LEVEL} | Time: {TIME_LEFT}") |
| print(f" Language : {LANGUAGE}") |
| print(f" Models : {len(MODELS)}") |
| print(f" Endpoints: {len(ENDPOINT_DEFS)}") |
| print(f" Total tests: {len(MODELS) * len(ENDPOINT_DEFS)}") |
| print("=" * 70) |
| print(" (retry backoff on 429: 15s, 30s, 60s)") |
| print(" (3s inter-request pause to stay under rate limits)") |
|
|
| results: dict[str, dict[str, Any]] = {m: {} for m in MODELS} |
| INTER_REQUEST_PAUSE = 3 |
|
|
| for model_short, model_id in MODELS.items(): |
| print(f"\n{'-' * 70}") |
| print(f" MODEL: {model_id}") |
| print(f"{'-' * 70}") |
|
|
| provider = _make_single_model_provider(model_id, timeout=120.0) |
|
|
| for endpoint_key, endpoint_label, schema, task_str, route in ENDPOINT_DEFS: |
| print(f" > {endpoint_label:28s}", end="", flush=True) |
| t0 = time.perf_counter() |
|
|
| record: dict[str, Any] = { |
| "model_id": model_id, |
| "endpoint": endpoint_key, |
| "endpoint_label": endpoint_label, |
| "latency_s": None, |
| "success": False, |
| "error_category": None, |
| "error": None, |
| "output": None, |
| "quality": None, |
| "raw_size_chars": 0, |
| } |
|
|
| output, err_cat = _call_direct(provider, task_str, schema, route, model_id) |
| latency = time.perf_counter() - t0 |
| record["latency_s"] = round(latency, 2) |
|
|
| if not err_cat: |
| record["success"] = True |
| record["output"] = output |
| record["raw_size_chars"] = len(json.dumps(output, ensure_ascii=False)) |
| record["quality"] = _score_output(output) |
| q = record["quality"] |
| bar = _bar(q["score"]) |
| print( |
| f" [{bar}] {q['score']}/10 {latency:.1f}s " |
| f"kw={q['keyword_hits']} formula={q['formula_hits']}" |
| + (" !GENERIC" if q["is_generic"] else "") |
| ) |
| else: |
| record["error_category"] = err_cat |
| record["error"] = err_cat |
| print(f" [FAIL:{err_cat[:20]}] {latency:.1f}s") |
|
|
| results[model_short][endpoint_key] = record |
|
|
| |
| time.sleep(INTER_REQUEST_PAUSE) |
|
|
| return results |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--from-json", action="store_true", |
| help="Build report from existing benchmark_results_raw.json (no API calls)") |
| args = parser.parse_args() |
|
|
| raw_path = BACKEND_DIR / "scripts" / "benchmark_results_raw.json" |
|
|
| if args.from_json: |
| if not raw_path.exists(): |
| print(f"ERROR: {raw_path} not found. Run without --from-json first.") |
| sys.exit(1) |
| with raw_path.open(encoding="utf-8") as f: |
| results = json.load(f) |
| print(f"Loaded existing results from {raw_path}") |
| else: |
| results = run_benchmark() |
| with raw_path.open("w", encoding="utf-8") as f: |
| json.dump(results, f, ensure_ascii=False, indent=2) |
| print(f"\n Raw results saved -> {raw_path}") |
|
|
| _print_summary(results) |
| report = _build_report(results) |
| report_path = BACKEND_DIR.parent / "DOCDOE_OPENROUTER_MODEL_QUALITY_BENCHMARK.md" |
| report_path.write_text(report, encoding="utf-8") |
| print(f"\n Benchmark report -> {report_path}") |
|
|
|
|
| |
|
|
| def _print_summary(results: dict) -> None: |
| model_shorts = list(MODELS.keys()) |
| print("\n" + "=" * 70) |
| print(" SUMMARY (score/10 | lat s | FAIL:reason)") |
| print("-" * 70) |
| col_w = 16 |
| header = f" {'Endpoint':<22}" + "".join(f" {m[:col_w]:>{col_w}}" for m in model_shorts) |
| print(header) |
| print("-" * 70) |
|
|
| for endpoint_key, endpoint_label, *_ in ENDPOINT_DEFS: |
| row = f" {endpoint_label[:22]:<22}" |
| for ms in model_shorts: |
| rec = results.get(ms, {}).get(endpoint_key) or {} |
| if rec.get("success"): |
| s = rec["quality"]["score"] |
| lat = rec["latency_s"] |
| cell = f"{s}/10 {lat:.0f}s" |
| else: |
| cat = (rec.get("error_category") or "FAIL")[:12] |
| cell = f"FAIL:{cat}" |
| row += f" {cell:>{col_w}}" |
| print(row) |
|
|
| print("=" * 70) |
| print("\n BEST MODEL PER ENDPOINT:") |
| for endpoint_key, endpoint_label, *_ in ENDPOINT_DEFS: |
| best_model = None |
| best_score = -1 |
| for ms, model_id in MODELS.items(): |
| rec = (results.get(ms) or {}).get(endpoint_key) or {} |
| if rec.get("success") and (rec.get("quality") or {}).get("score", -1) > best_score: |
| best_score = (rec["quality"] or {}).get("score", 0) |
| best_model = (ms, model_id) |
| if best_model: |
| ms, mid = best_model |
| print(f" {endpoint_label:<30} -> {mid} (score={best_score}/10)") |
| else: |
| print(f" {endpoint_label:<30} -> ALL FAILED") |
|
|
|
|
| |
|
|
| def _safe_q(rec: dict, key: str, default: Any = False) -> Any: |
| """Safely get quality sub-field, handling None quality.""" |
| return (rec.get("quality") or {}).get(key, default) |
|
|
|
|
| def _build_report(results: dict) -> str: |
| model_shorts = list(MODELS.keys()) |
| lines: list[str] = [] |
| a = lines.append |
|
|
| a("# DocDoe OpenRouter Model Quality Benchmark") |
| a("") |
| a(f"**Run date:** {time.strftime('%Y-%m-%d %H:%M')} UTC") |
| a(f"**Topic:** {TOPIC} β Kerala +2 Physics") |
| a(f"**Goal:** {GOAL} | **Level:** {LEVEL} | **Time left:** {TIME_LEFT}") |
| a(f"**Language:** {LANGUAGE}") |
| a(f"**Models tested:** {len(MODELS)}") |
| a(f"**Endpoints tested:** {len(ENDPOINT_DEFS)}") |
| a("") |
| a("---") |
| a("") |
|
|
| |
| a("## 1. Quality Score Table (/10)") |
| a("") |
| hdrs = " | ".join(MODELS[m].split("/")[1][:24] for m in model_shorts) |
| a(f"| Endpoint | {hdrs} |") |
| a(f"|---|{'---|' * len(model_shorts)}") |
| for endpoint_key, endpoint_label, *_ in ENDPOINT_DEFS: |
| cells = [] |
| for ms in model_shorts: |
| rec = (results.get(ms) or {}).get(endpoint_key) or {} |
| if rec.get("success"): |
| cells.append(f"{_safe_q(rec, 'score', 0)}/10") |
| else: |
| cat = (rec.get("error_category") or "FAIL")[:16] |
| cells.append(cat) |
| a(f"| {endpoint_label} | {' | '.join(cells)} |") |
| a("") |
|
|
| |
| a("## 2. Latency Table (seconds, wall-clock)") |
| a("") |
| a(f"| Endpoint | {hdrs} |") |
| a(f"|---|{'---|' * len(model_shorts)}") |
| for endpoint_key, endpoint_label, *_ in ENDPOINT_DEFS: |
| cells = [] |
| for ms in model_shorts: |
| rec = (results.get(ms) or {}).get(endpoint_key) or {} |
| lat = rec.get("latency_s") |
| if lat is not None and rec.get("success"): |
| cells.append(f"{lat:.1f}s") |
| elif lat is not None: |
| cells.append(f"FAIL ({lat:.0f}s)") |
| else: |
| cells.append("β") |
| a(f"| {endpoint_label} | {' | '.join(cells)} |") |
| a("") |
|
|
| |
| a("## 3. Best and Worst Model per Endpoint") |
| a("") |
| a("| Endpoint | Best Model | Score | Worst Model | Score |") |
| a("|---|---|---|---|---|") |
| for endpoint_key, endpoint_label, *_ in ENDPOINT_DEFS: |
| scored = [ |
| (_safe_q(r, "score", 0), ms, MODELS[ms]) |
| for ms in model_shorts |
| if (r := (results.get(ms) or {}).get(endpoint_key) or {}).get("success") |
| ] |
| if not scored: |
| a(f"| {endpoint_label} | ALL FAILED | β | ALL FAILED | β |") |
| continue |
| scored.sort(reverse=True) |
| best_s, _, best_id = scored[0] |
| worst_s, _, worst_id = scored[-1] |
| a(f"| {endpoint_label} | `{best_id.split('/')[1][:28]}` | {best_s}/10 " |
| f"| `{worst_id.split('/')[1][:28]}` | {worst_s}/10 |") |
| a("") |
|
|
| |
| a("## 4. Quality Detail per Model") |
| a("") |
| for ms, model_id in MODELS.items(): |
| a(f"### {model_id}") |
| a("") |
| a("| Endpoint | Score | kw | fml | units | marks | lenz | generic | size |") |
| a("|---|---|---|---|---|---|---|---|---|") |
| for endpoint_key, endpoint_label, *_ in ENDPOINT_DEFS: |
| rec = (results.get(ms) or {}).get(endpoint_key) or {} |
| if rec.get("success"): |
| q = rec.get("quality") or {} |
| a( |
| f"| {endpoint_label} " |
| f"| {q.get('score', 0)}/10 " |
| f"| {q.get('keyword_hits', 0)} " |
| f"| {q.get('formula_hits', 0)} " |
| f"| {'Y' if q.get('has_units') else 'N'} " |
| f"| {'Y' if q.get('has_marks') else 'N'} " |
| f"| {'Y' if q.get('has_lenz') else 'N'} " |
| f"| {'WARN' if q.get('is_generic') else 'OK'} " |
| f"| {rec.get('raw_size_chars', 0)} |" |
| ) |
| else: |
| cat = (rec.get("error_category") or "no data")[:30] |
| a(f"| {endpoint_label} | FAIL | β | β | β | β | β | β | {cat} |") |
| a("") |
|
|
| |
| a("## 5. Failures, Rate Limits, and Blocked Models") |
| a("") |
| fail_counts: dict[str, int] = {} |
| categories: dict[str, list[str]] = {} |
|
|
| for ms, model_id in MODELS.items(): |
| for endpoint_key, endpoint_label, *_ in ENDPOINT_DEFS: |
| rec = (results.get(ms) or {}).get(endpoint_key) or {} |
| if not rec.get("success"): |
| cat = rec.get("error_category") or "UNKNOWN" |
| fail_counts[model_id] = fail_counts.get(model_id, 0) + 1 |
| categories.setdefault(cat, []).append(f"{model_id} / {endpoint_label}") |
|
|
| if not categories: |
| a("No failures recorded.") |
| else: |
| for cat, items in sorted(categories.items()): |
| a(f"### {cat} ({len(items)} occurrences)") |
| for item in items: |
| a(f"- {item}") |
| |
| if cat == "429_RATE_LIMIT" or cat == "429_RATE_LIMIT_EXHAUSTED": |
| a("") |
| a("> **Root cause:** Free-tier rate limit on OpenRouter. " |
| "Each free model allows ~10 RPM and ~200K tokens/day. " |
| "Running 7 sequential requests per model triggers the cap.") |
| a("> **Fix options:** (1) Add inter-request sleep, (2) Add OPENROUTER_API_KEY " |
| "to your own OpenRouter account with credits, (3) Use pay-as-you-go models.") |
| elif cat == "404_POLICY": |
| a("") |
| a("> **Root cause:** OpenRouter account data policy blocks this model. " |
| "Navigate to https://openrouter.ai/settings/privacy and enable " |
| "the data retention policy needed by this provider.") |
| elif cat == "TIMEOUT": |
| a("") |
| a("> **Root cause:** Model response exceeded the configured timeout (120s). " |
| "This model is too slow for free-tier single-request usage.") |
| elif cat == "JSON_PARSE_ERROR": |
| a("") |
| a("> **Root cause:** Model returned truncated or malformed JSON. " |
| "Typically happens when max_tokens is hit mid-response. " |
| "Reduce prompt complexity or increase max_tokens.") |
| a("") |
|
|
| |
| a("## 6. Sample Output β Best Successful Call") |
| a("") |
| found_sample = False |
| for ms, model_id in MODELS.items(): |
| for endpoint_key, endpoint_label, *_ in ENDPOINT_DEFS: |
| rec = (results.get(ms) or {}).get(endpoint_key) or {} |
| if rec.get("success") and rec.get("output") and not found_sample: |
| found_sample = True |
| out = rec["output"] |
| a(f"**Model:** `{model_id}`") |
| a(f"**Endpoint:** {endpoint_label}") |
| a(f"**Score:** {_safe_q(rec, 'score', 0)}/10") |
| a(f"**Latency:** {rec.get('latency_s')}s") |
| a("") |
| |
| for field in ("formulas", "exam_keywords", "possible_exam_questions", |
| "last_minute_revision", "key_points"): |
| val = out.get(field) |
| if val and isinstance(val, list) and val: |
| a(f"**{field}** (first 3):") |
| for item in val[:3]: |
| a(f"- {str(item)[:120]}") |
| a("") |
| if not found_sample: |
| a("No successful calls to show samples from.") |
| a("") |
|
|
| |
| a("## 7. Recommended Routing Table") |
| a("") |
| a("Based on benchmark results (and known reliability characteristics):") |
| a("") |
| a("| Endpoint | Primary Model | Fallback | Reason |") |
| a("|---|---|---|---|") |
|
|
| routing_notes = { |
| "ask": ("deepseek", "llama", "deepseek showed strong JSON + EM keywords on success; retry for 429"), |
| "notes": ("deepseek", "llama", "4-model chain already configured; deepseek scored 8/10 on last-night"), |
| "quiz": ("deepseek", "llama", "deepseek prompt specificity; llama as fallback"), |
| "flashcards": ("llama", "deepseek","nemotron 404-blocked; llama timeout OK with 120s; deepseek as fallback"), |
| "exam-answer": ("deepseek", "llama", "mark-structure-aware; deepseek chain preferred"), |
| "last-night": ("deepseek", "llama", "deepseek scored 8/10 with keyword_hits=21, formula_hits=4"), |
| "video-plan": ("deepseek", "llama", "scene planning needs structured JSON; deepseek chain preferred"), |
| } |
| for endpoint_key, endpoint_label, *_ in ENDPOINT_DEFS: |
| prim, fall, reason = routing_notes.get(endpoint_key, ("deepseek", "llama", "default")) |
| prim_id = MODELS.get(prim, prim).split("/")[1][:32] if prim in MODELS else prim |
| fall_id = MODELS.get(fall, fall).split("/")[1][:32] if fall in MODELS else fall |
| a(f"| {endpoint_label} | `{prim_id}` | `{fall_id}` | {reason} |") |
| a("") |
|
|
| |
| a("## 8. Prompt Improvements Needed") |
| a("") |
|
|
| generic_eps: list[str] = [] |
| no_formula_eps: list[str] = [] |
| no_lenz_eps: list[str] = [] |
|
|
| for ms in model_shorts: |
| for endpoint_key, endpoint_label, *_ in ENDPOINT_DEFS: |
| rec = (results.get(ms) or {}).get(endpoint_key) or {} |
| if rec.get("success"): |
| if _safe_q(rec, "is_generic"): |
| generic_eps.append(f"{MODELS[ms].split('/')[1][:20]} / {endpoint_label}") |
| if not _safe_q(rec, "has_formula"): |
| no_formula_eps.append(f"{MODELS[ms].split('/')[1][:20]} / {endpoint_label}") |
| if not _safe_q(rec, "has_lenz"): |
| no_lenz_eps.append(f"{MODELS[ms].split('/')[1][:20]} / {endpoint_label}") |
|
|
| a("### From benchmark observations") |
| a("") |
| if generic_eps: |
| a("**Generic advice detected** (add topic-specificity constraint):") |
| for ep in generic_eps: |
| a(f"- {ep}") |
| a("") |
| if no_formula_eps: |
| a("**Formula missing** (inject required formula list in task string):") |
| for ep in no_formula_eps: |
| a(f"- {ep}") |
| a("") |
| if no_lenz_eps: |
| a("**Lenz's Law not mentioned** (critical for Kerala +2; add explicit instruction):") |
| for ep in no_lenz_eps: |
| a(f"- {ep}") |
| a("") |
| if not generic_eps and not no_formula_eps and not no_lenz_eps: |
| a("No quality issues detected in successful responses.") |
| a("") |
|
|
| a("### Universal improvements (apply regardless of model)") |
| a("") |
| a("1. **Rate-limit handling**: Add `time.sleep(3)` between sequential calls. " |
| "Free tier: ~10 RPM per model. Consider staggering model selection.") |
| a("2. **nemotron 404**: Go to https://openrouter.ai/settings/privacy and enable " |
| "the provider's data policy. Until then, remove nemotron from routing chains.") |
| a("3. **llama timeout**: Set `AI_TIMEOUT_SECONDS=120` in .env. " |
| "llama-3.3-70b can take 90-110s on free tier under load.") |
| a("4. **JSON truncation**: When model hits max_tokens mid-JSON, reduce prompt complexity. " |
| "Notes endpoint is the most token-heavy β consider splitting into two calls.") |
| a("5. **Formula injection**: Add to task strings for Physics: " |
| "'Required formulas: epsilon = -dPhi/dt [V], Phi = B*A*cos(theta) [Wb], Blv [V]'. " |
| "This ensures all models include them even without strong physics tuning.") |
| a("6. **Lenz's Law**: For EM Induction specifically, add to every task: " |
| "'Always include Lenz's Law with its energy conservation explanation.'") |
| a("7. **Malayalam quality**: Add explicit instruction: " |
| "'Each concept must appear as: Malayalam sentence (English term) = formula'.") |
| a("") |
|
|
| |
| a("## 9. Overall Model Ranking") |
| a("") |
| model_totals = {} |
| for ms, model_id in MODELS.items(): |
| scores = [ |
| _safe_q(r, "score", 0) |
| for ep, *_ in ENDPOINT_DEFS |
| if (r := (results.get(ms) or {}).get(ep)) and r.get("success") |
| ] |
| lats = [ |
| r["latency_s"] |
| for ep, *_ in ENDPOINT_DEFS |
| if (r := (results.get(ms) or {}).get(ep)) and r.get("success") and r.get("latency_s") |
| ] |
| fails = sum( |
| 1 for ep, *_ in ENDPOINT_DEFS |
| if not ((results.get(ms) or {}).get(ep) or {}).get("success") |
| ) |
| error_cats = list({ |
| (results.get(ms) or {}).get(ep, {}).get("error_category", "") |
| for ep, *_ in ENDPOINT_DEFS |
| if not ((results.get(ms) or {}).get(ep) or {}).get("success") |
| } - {""}) |
| model_totals[ms] = { |
| "model_id": model_id, |
| "avg_score": sum(scores) / len(scores) if scores else 0, |
| "avg_latency": sum(lats) / len(lats) if lats else 0, |
| "successes": len(scores), |
| "failures": fails, |
| "error_categories": error_cats, |
| } |
|
|
| ranked = sorted(model_totals.values(), key=lambda x: (-x["successes"], -x["avg_score"], x["avg_latency"])) |
| a("| Rank | Model | Successes | Avg Score | Avg Latency | Fail reason |") |
| a("|---|---|---|---|---|---|") |
| for rank, m in enumerate(ranked, 1): |
| name = m["model_id"].split("/")[1][:35] if "/" in m["model_id"] else m["model_id"] |
| total = len(ENDPOINT_DEFS) |
| cats = ", ".join(m["error_categories"][:3]) or "β" |
| a(f"| {rank} | `{name}` | {m['successes']}/{total} | {m['avg_score']:.1f}/10 " |
| f"| {m['avg_latency']:.0f}s | {cats} |") |
| a("") |
|
|
| a("### Key findings") |
| a("") |
| a("| Finding | Detail |") |
| a("|---|---|") |
| a("| Free-tier rate limits | deepseek, llama, gpt_oss hit 429 within 3-5 sequential calls |") |
| a("| nemotron data policy | 404 on all endpoints β requires OpenRouter privacy setting change |") |
| a("| llama timeout | ~60-90s response time on free tier β set AI_TIMEOUT_SECONDS=120 |") |
| a("| deepseek quality (when not rate-limited) | score=8/10, keyword_hits=21, formula_hits=4 |") |
| a("| JSON truncation | deepseek/notes returned partial JSON at max_tokens boundary |") |
| a("| Best single response | deepseek last-night notes: 21 EM keywords, 4 formulas, Lenz's law, diagrams |") |
| a("") |
|
|
| a("---") |
| a("") |
| a("*Generated by `scripts/benchmark_openrouter_models.py`*") |
| a(f"*Benchmark run: {time.strftime('%Y-%m-%d %H:%M:%S')}*") |
|
|
| return "\n".join(lines) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|