Download app.py from nik2999/LuLu: direct link, hf CLI and curl.
- Browser
- Download file 69.4 kB
-
https://huggingface.co/spaces/nik2999/LuLu/resolve/main/app.py
- Command line
-
hf download hf://spaces/nik2999/LuLu/app.py
-
curl -L -o app.py https://huggingface.co/spaces/nik2999/LuLu/resolve/main/app.py
69.4 kB
| from __future__ import annotations | |
| import base64 | |
| import importlib.util | |
| import json | |
| import mimetypes | |
| import os | |
| import sys | |
| import time | |
| import urllib.error | |
| import urllib.request | |
| import wave | |
| from datetime import UTC, datetime | |
| from pathlib import Path | |
| from typing import Any | |
| from uuid import uuid4 | |
| from dotenv import load_dotenv | |
| from fastapi import Request | |
| from fastapi.responses import HTMLResponse, JSONResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from gradio import Server | |
| from pydantic import BaseModel | |
| load_dotenv() | |
| ROOT = Path(__file__).parent | |
| STATIC_DIR = ROOT / "static" | |
| OUTPUT_DIR = ROOT / "outputs" | |
| OUTPUT_DIR.mkdir(exist_ok=True) | |
| def _int_env(name: str, default: int) -> int: | |
| try: | |
| return int(os.getenv(name, str(default))) | |
| except (TypeError, ValueError): | |
| return default | |
| def _bool_env(name: str, default: bool = False) -> bool: | |
| value = os.getenv(name) | |
| if value is None: | |
| return default | |
| return value.strip().lower() in {"1", "true", "yes", "on"} | |
| def _runtime_log(message: str) -> None: | |
| print(f"[tiny-narrator] {message}", file=sys.stderr, flush=True) | |
| def _http_exception_detail(exc: BaseException) -> str: | |
| if isinstance(exc, urllib.error.HTTPError): | |
| try: | |
| body = exc.read().decode("utf-8", errors="replace") | |
| except Exception: | |
| body = "" | |
| body = _compact_text(body, 400) if body else "" | |
| return f"HTTPError status={exc.code} reason={exc.reason} body={body}" | |
| if isinstance(exc, urllib.error.URLError): | |
| return f"URLError reason={exc.reason}" | |
| return f"{exc.__class__.__name__}: {exc}" | |
| LLAMA_CPP_BASE_URL = os.getenv("LLAMA_CPP_BASE_URL", "http://localhost:8080/v1") | |
| LLAMA_CPP_MODEL = os.getenv("LLAMA_CPP_MODEL", "narrator-brain") | |
| LLAMA_CPP_TOKEN = os.getenv("LLAMA_CPP_TOKEN", "") | |
| LLAMA_CPP_TIMEOUT_SECONDS = _int_env("LLAMA_CPP_TIMEOUT_SECONDS", 90) | |
| GRADIO_SERVER_NAME = os.getenv("GRADIO_SERVER_NAME", "0.0.0.0") | |
| GRADIO_SERVER_PORT = int(os.getenv("PORT", os.getenv("GRADIO_SERVER_PORT", "7860"))) | |
| GRADIO_SHARE = _bool_env("GRADIO_SHARE") | |
| PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", f"http://localhost:{GRADIO_SERVER_PORT}").rstrip("/") | |
| KLEIN_MODAL_ENDPOINT = os.getenv("KLEIN_MODAL_ENDPOINT", "").rstrip("/") | |
| KLEIN_MODAL_TOKEN = os.getenv("KLEIN_MODAL_TOKEN", "") | |
| KLEIN_MODAL_HEALTH_TIMEOUT_SECONDS = _int_env("KLEIN_MODAL_HEALTH_TIMEOUT_SECONDS", 30) | |
| KLEIN_MODAL_TIMEOUT_SECONDS = _int_env("KLEIN_MODAL_TIMEOUT_SECONDS", 120) | |
| MINICPM_VISION_BASE_URL = os.getenv("MINICPM_VISION_BASE_URL", "").rstrip("/") | |
| MINICPM_VISION_API_KEY = os.getenv("MINICPM_VISION_API_KEY", "") | |
| MINICPM_VISION_MODEL = os.getenv("MINICPM_VISION_MODEL", "openbmb/MiniCPM-V-4.6") | |
| MINICPM_VISION_TIMEOUT_SECONDS = _int_env("MINICPM_VISION_TIMEOUT_SECONDS", 45) | |
| TINY_TITAN_LIMIT_B = 4.0 | |
| MODEL_MANIFEST: dict[str, dict[str, Any]] = { | |
| "reader_brain": { | |
| "id": "nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF", | |
| "params": "3.97B", | |
| "params_billion": 3.97, | |
| "runtime": "llama.cpp", | |
| "role": "Plans concise narration and reading-order phrasing.", | |
| }, | |
| "vision": { | |
| "id": "openbmb/MiniCPM-V-4.6", | |
| "params": "1B", | |
| "params_billion": 1.0, | |
| "runtime": "OpenAI-compatible chat completions", | |
| "role": "Describes images and OCR-like visual details.", | |
| }, | |
| "speech": { | |
| "id": "hexgrad/Kokoro-82M", | |
| "params": "82M", | |
| "params_billion": 0.082, | |
| "runtime": "Python", | |
| "role": "Speaks the final narration.", | |
| }, | |
| "image_generation": { | |
| "id": "black-forest-labs/FLUX.2-klein-4B", | |
| "params": "4B", | |
| "params_billion": 4.0, | |
| "runtime": "Modal-hosted Klein", | |
| "role": "Creates article illustrations.", | |
| }, | |
| } | |
| ARTICLE_IMAGES: list[dict[str, Any]] = [ | |
| { | |
| "id": "desk-reader", | |
| "asset_url": "/static/generated/desk-reader.svg", | |
| "vision_asset_url": "/static/generated/desk-reader.png", | |
| "caption": "The article view doubles as the demo surface, so every feature has a real reading task.", | |
| "prompt": "Accessibility article reader with highlighted paragraph and narration controls.", | |
| "seed": 11, | |
| "generation_model": "black-forest-labs/FLUX.2-klein-4B", | |
| "generation_runtime": "bundled fallback asset", | |
| "generation_status": "fallback-ready", | |
| }, | |
| { | |
| "id": "model-map", | |
| "asset_url": "/static/generated/model-map.svg", | |
| "vision_asset_url": "/static/generated/model-map.png", | |
| "caption": "Each model stays at or below four billion parameters for Tiny Titan eligibility.", | |
| "prompt": "Diagram of four small AI models working together in an accessibility reader.", | |
| "seed": 22, | |
| "generation_model": "black-forest-labs/FLUX.2-klein-4B", | |
| "generation_runtime": "bundled fallback asset", | |
| "generation_status": "fallback-ready", | |
| }, | |
| ] | |
| READER_SETTINGS: dict[str, Any] = { | |
| "default_voice": "af_heart", | |
| "default_speed": 1.0, | |
| "default_auto_advance": False, | |
| "voices": [ | |
| {"id": "af_heart", "label": "Heart"}, | |
| {"id": "af_bella", "label": "Bella"}, | |
| {"id": "am_adam", "label": "Adam"}, | |
| {"id": "am_michael", "label": "Michael"}, | |
| ], | |
| "speed": {"min": 0.75, "max": 1.35, "step": 0.05}, | |
| } | |
| AWARD_EVIDENCE: list[dict[str, str]] = [ | |
| { | |
| "id": "tiny-titan", | |
| "label": "Tiny Titan", | |
| "status": "ready", | |
| "evidence": "All planned model roles use models at or below 4B parameters.", | |
| }, | |
| { | |
| "id": "llama-champion", | |
| "label": "Llama Champion", | |
| "status": "ready", | |
| "evidence": "The reader-brain path targets a GGUF model through a llama.cpp OpenAI-compatible endpoint.", | |
| }, | |
| { | |
| "id": "off-brand", | |
| "label": "Off-Brand", | |
| "status": "ready", | |
| "evidence": "The visible app is custom HTML, CSS, and JavaScript served by Gradio Server.", | |
| }, | |
| { | |
| "id": "field-notes", | |
| "label": "Field Notes", | |
| "status": "ready", | |
| "evidence": "The repo records model choices, fallbacks, latency reporting, and accessibility behavior.", | |
| }, | |
| ] | |
| def model_budget_core() -> dict[str, Any]: | |
| models = [] | |
| for role, model in MODEL_MANIFEST.items(): | |
| params_billion = float(model["params_billion"]) | |
| models.append( | |
| { | |
| "role": role, | |
| "id": model["id"], | |
| "runtime": model["runtime"], | |
| "params": model["params"], | |
| "params_billion": params_billion, | |
| "limit_billion": TINY_TITAN_LIMIT_B, | |
| "within_limit": params_billion <= TINY_TITAN_LIMIT_B, | |
| } | |
| ) | |
| return { | |
| "ok": True, | |
| "award": "Tiny Titan", | |
| "limit_billion": TINY_TITAN_LIMIT_B, | |
| "all_models_within_limit": all(model["within_limit"] for model in models), | |
| "models": models, | |
| } | |
| def runtime_setup_core() -> dict[str, Any]: | |
| return { | |
| "ok": True, | |
| "app": { | |
| "runtime": "Gradio Server", | |
| "command": "python app.py", | |
| "env": { | |
| "GRADIO_SERVER_NAME": GRADIO_SERVER_NAME, | |
| "GRADIO_SERVER_PORT": str(GRADIO_SERVER_PORT), | |
| "GRADIO_SHARE": str(GRADIO_SHARE).lower(), | |
| "PUBLIC_BASE_URL": PUBLIC_BASE_URL, | |
| }, | |
| }, | |
| "steps": [ | |
| { | |
| "role": "reader_brain", | |
| "label": "Reader brain", | |
| "model": MODEL_MANIFEST["reader_brain"]["id"], | |
| "runtime": "llama.cpp", | |
| "command": ( | |
| "llama-server -hf nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF:Q4_K_M " | |
| "--alias narrator-brain --port 8080 --host 0.0.0.0 " | |
| "--ctx-size 4096 --parallel 1 --reasoning off --n-gpu-layers 999" | |
| ), | |
| "modal_command": "modal deploy modal_workers/reader_brain.py", | |
| "env": { | |
| "LLAMA_CPP_BASE_URL": LLAMA_CPP_BASE_URL, | |
| "LLAMA_CPP_MODEL": LLAMA_CPP_MODEL, | |
| "LLAMA_CPP_TOKEN": "(configured)" if LLAMA_CPP_TOKEN else "(not set)", | |
| "LLAMA_CPP_TIMEOUT_SECONDS": str(LLAMA_CPP_TIMEOUT_SECONDS), | |
| }, | |
| "fallback": "MiniCPM-V-4.6 text fallback, then rule-based local narration", | |
| }, | |
| { | |
| "role": "speech", | |
| "label": "Speech", | |
| "model": MODEL_MANIFEST["speech"]["id"], | |
| "runtime": "Python Kokoro", | |
| "command": "python -m pip install kokoro soundfile", | |
| "env": {}, | |
| "fallback": "browser speech plus visible transcript", | |
| }, | |
| { | |
| "role": "vision", | |
| "label": "Image descriptions", | |
| "model": MODEL_MANIFEST["vision"]["id"], | |
| "runtime": "OpenAI-compatible chat completions", | |
| "command": "vllm serve openbmb/MiniCPM-V-4.6 --host 0.0.0.0 --port 8000", | |
| "env": { | |
| "MINICPM_VISION_BASE_URL": MINICPM_VISION_BASE_URL or "(set to OpenAI-compatible base URL)", | |
| "MINICPM_VISION_API_KEY": "(configured)" if MINICPM_VISION_API_KEY else "(not set)", | |
| "MINICPM_VISION_MODEL": MINICPM_VISION_MODEL, | |
| "MINICPM_VISION_TIMEOUT_SECONDS": str(MINICPM_VISION_TIMEOUT_SECONDS), | |
| }, | |
| "fallback": "cached deterministic alt text", | |
| }, | |
| { | |
| "role": "image_generation", | |
| "label": "Article images", | |
| "model": MODEL_MANIFEST["image_generation"]["id"], | |
| "runtime": "Modal-hosted Klein", | |
| "command": "modal deploy modal_workers/klein_image.py", | |
| "env": { | |
| "KLEIN_MODAL_ENDPOINT": KLEIN_MODAL_ENDPOINT or "(set to Modal worker URL)", | |
| "KLEIN_MODAL_TOKEN": "(configured)" if KLEIN_MODAL_TOKEN else "(not set)", | |
| "KLEIN_MODAL_HEALTH_TIMEOUT_SECONDS": str(KLEIN_MODAL_HEALTH_TIMEOUT_SECONDS), | |
| "KLEIN_MODAL_TIMEOUT_SECONDS": str(KLEIN_MODAL_TIMEOUT_SECONDS), | |
| }, | |
| "fallback": "bundled generated article assets", | |
| }, | |
| ], | |
| } | |
| def demo_curl_command(check: dict[str, Any]) -> str: | |
| url = f"{PUBLIC_BASE_URL}{check['path']}" | |
| if check["method"] == "GET": | |
| return f"curl {url}" | |
| sample_body = json.dumps(check["sample_body"], separators=(",", ":")) | |
| return f"curl -X POST {url} -H \"Content-Type: application/json\" -d '{sample_body}'" | |
| def demo_powershell_command(check: dict[str, Any]) -> str: | |
| url = f"{PUBLIC_BASE_URL}{check['path']}" | |
| if check["method"] == "GET": | |
| return f"curl.exe {url}" | |
| sample_body = json.dumps(check["sample_body"], separators=(",", ":")).replace('"', '\\"') | |
| return f'curl.exe -X POST {url} -H "Content-Type: application/json" -d "{sample_body}"' | |
| def demo_script_core() -> dict[str, Any]: | |
| api_checks = [ | |
| {"method": "GET", "path": "/api/health", "expect": "custom Gradio Server app and model manifest"}, | |
| {"method": "GET", "path": "/api/model-budget", "expect": "all_models_within_limit is true"}, | |
| {"method": "GET", "path": "/api/runtime-setup", "expect": "llama.cpp, Kokoro, vision, and image paths"}, | |
| {"method": "GET", "path": "/api/runtime-status", "expect": "online or fallback-ready runtime labels"}, | |
| {"method": "GET", "path": "/api/accessibility-audit", "expect": "all reader-mode checks pass"}, | |
| {"method": "GET", "path": "/api/image-descriptions", "expect": "two article image descriptions with MiniCPM-V-4.6 or fallback alt text"}, | |
| {"method": "GET", "path": "/api/submission-readiness", "expect": "all submission readiness checks pass"}, | |
| { | |
| "method": "POST", | |
| "path": "/api/reader-brain", | |
| "expect": "concise narration or fallback narration", | |
| "sample_body": { | |
| "node_type": "heading", | |
| "text": "The model map", | |
| "position": "item 4 of 10", | |
| "mode": "narrate", | |
| }, | |
| }, | |
| { | |
| "method": "POST", | |
| "path": "/api/speak", | |
| "expect": "Kokoro audio or audible browser fallback path", | |
| "sample_body": { | |
| "text": "Heading. The model map.", | |
| "voice": READER_SETTINGS["default_voice"], | |
| "speed": READER_SETTINGS["default_speed"], | |
| }, | |
| }, | |
| { | |
| "method": "POST", | |
| "path": "/api/generate-article", | |
| "expect": "generated article draft with Klein thumbnail receipt", | |
| "sample_body": {"topic": "deep sea bioluminescence"}, | |
| }, | |
| ] | |
| for check in api_checks: | |
| check["curl"] = demo_curl_command(check) | |
| check["powershell"] = demo_powershell_command(check) | |
| return { | |
| "ok": True, | |
| "title": "Tiny Narrator judge demo", | |
| "goal": "Show a custom article app becoming a small-model screen reader with inspectable evidence.", | |
| "estimated_minutes": 3, | |
| "actions": [ | |
| { | |
| "step": 1, | |
| "label": "Open the article", | |
| "action": "Load the home page and scan the article plus session panel.", | |
| "evidence": "Custom HTML/CSS/JS interface served by Gradio Server.", | |
| }, | |
| { | |
| "step": 2, | |
| "label": "Turn on reader mode", | |
| "action": "Toggle screen reader mode, then press Space or Next to narrate the first item.", | |
| "evidence": "The active article node is focused, outlined, narrated, and logged.", | |
| }, | |
| { | |
| "step": 3, | |
| "label": "Navigate by meaning", | |
| "action": "Use Heading, Image, and Summary controls to jump through semantic article nodes.", | |
| "evidence": "The reader uses article structure, image alt text, and section summaries.", | |
| }, | |
| { | |
| "step": 4, | |
| "label": "Inspect small-model receipts", | |
| "action": "Review the checklist, model budget, runtime plan, readiness, latency, and transcript panels.", | |
| "evidence": "Tiny Titan, Llama Champion, Off-Brand, and reader evidence is exposed through the app and APIs.", | |
| }, | |
| ], | |
| "api_checks": api_checks, | |
| } | |
| def submission_readiness_core() -> dict[str, Any]: | |
| model_budget = model_budget_core() | |
| runtime_setup = runtime_setup_core() | |
| runtime_status = _runtime_status_core() | |
| accessibility = accessibility_audit_core() | |
| demo_script = demo_script_core() | |
| image_descriptions = describe_article_images_core() | |
| runtime_roles = {step["role"] for step in runtime_setup["steps"]} | |
| expected_roles = set(MODEL_MANIFEST) | |
| api_paths = {item["path"] for item in demo_script["api_checks"]} | |
| post_checks = [item for item in demo_script["api_checks"] if item["method"] == "POST"] | |
| post_samples_ready = all(item.get("sample_body") for item in post_checks) | |
| commands_use_public_base = all( | |
| PUBLIC_BASE_URL in item["curl"] and PUBLIC_BASE_URL in item["powershell"] | |
| for item in demo_script["api_checks"] | |
| ) | |
| runtime_statuses = { | |
| runtime_status["reader_brain"]["status"], | |
| runtime_status["vision"]["status"], | |
| runtime_status["speech"]["status"], | |
| runtime_status["image_generation"]["status"], | |
| } | |
| runtime_status_ready = runtime_statuses <= {"online", "fallback-ready", "placeholder-ready"} | |
| checks = [ | |
| { | |
| "id": "tiny_titan_budget", | |
| "label": "Tiny Titan model budget", | |
| "status": "pass" if model_budget["all_models_within_limit"] else "fail", | |
| "evidence": f"{len(model_budget['models'])} model roles are at or below {TINY_TITAN_LIMIT_B}B parameters.", | |
| }, | |
| { | |
| "id": "award_targets", | |
| "label": "Targeted award evidence", | |
| "status": "pass" if len(AWARD_EVIDENCE) == 4 else "fail", | |
| "evidence": "Tiny Titan, Llama Champion, Off-Brand, and Field Notes evidence is exposed.", | |
| }, | |
| { | |
| "id": "custom_frontend", | |
| "label": "Custom frontend", | |
| "status": "pass" if all((STATIC_DIR / name).exists() for name in ["index.html", "app.css", "app.js"]) else "fail", | |
| "evidence": "The app serves custom HTML, CSS, and JavaScript through Gradio Server.", | |
| }, | |
| { | |
| "id": "runtime_setup", | |
| "label": "Runtime setup", | |
| "status": "pass" if runtime_roles == expected_roles else "fail", | |
| "evidence": "Runtime setup covers reader brain, speech, vision, and image generation paths.", | |
| }, | |
| { | |
| "id": "runtime_status", | |
| "label": "Runtime status", | |
| "status": "pass" if runtime_status_ready else "fail", | |
| "evidence": "Runtime status labels every model path as online, fallback-ready, or placeholder-ready.", | |
| }, | |
| { | |
| "id": "reader_accessibility", | |
| "label": "Reader accessibility", | |
| "status": "pass" if accessibility["all_passed"] else "fail", | |
| "evidence": f"{accessibility['passed_checks']} of {accessibility['total_checks']} accessibility checks pass.", | |
| }, | |
| { | |
| "id": "image_receipts", | |
| "label": "Image receipts", | |
| "status": "pass" | |
| if all( | |
| item.get("generation_model") == MODEL_MANIFEST["image_generation"]["id"] and isinstance(item.get("seed"), int) | |
| for item in image_descriptions["descriptions"] | |
| ) | |
| else "fail", | |
| "evidence": "Every article illustration exposes prompt, seed, model, asset URL, and fallback status.", | |
| }, | |
| { | |
| "id": "demo_api_checks", | |
| "label": "Demo API checks", | |
| "status": "pass" | |
| if { | |
| "/api/model-budget", | |
| "/api/runtime-setup", | |
| "/api/accessibility-audit", | |
| "/api/image-descriptions", | |
| "/api/reader-brain", | |
| "/api/speak", | |
| "/api/generate-article", | |
| }.issubset(api_paths) | |
| and post_samples_ready | |
| else "fail", | |
| "evidence": "The judge runbook includes GET evidence checks and executable POST sample bodies.", | |
| }, | |
| { | |
| "id": "command_base_url", | |
| "label": "Command base URL", | |
| "status": "pass" if commands_use_public_base else "fail", | |
| "evidence": f"Generated curl and curl.exe commands use {PUBLIC_BASE_URL}.", | |
| }, | |
| ] | |
| return { | |
| "ok": True, | |
| "all_passed": all(check["status"] == "pass" for check in checks), | |
| "passed_checks": sum(1 for check in checks if check["status"] == "pass"), | |
| "total_checks": len(checks), | |
| "checks": checks, | |
| } | |
| def evidence_bundle_core() -> dict[str, Any]: | |
| return { | |
| "ok": True, | |
| "title": "Tiny Narrator judge evidence bundle", | |
| "schema_version": "1.0", | |
| "generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), | |
| "frontend": "custom Gradio Server HTML/CSS/JS", | |
| "public_base_url": PUBLIC_BASE_URL, | |
| "bonus_targets": ARTICLE_MANIFEST["bonus_targets"], | |
| "models": MODEL_MANIFEST, | |
| "award_evidence": {"ok": True, "items": AWARD_EVIDENCE}, | |
| "model_budget": model_budget_core(), | |
| "runtime_setup": runtime_setup_core(), | |
| "runtime_status": _runtime_status_core(), | |
| "demo_script": demo_script_core(), | |
| "accessibility_audit": accessibility_audit_core(), | |
| "image_descriptions": describe_article_images_core(), | |
| "submission_readiness": submission_readiness_core(), | |
| } | |
| def accessibility_audit_core() -> dict[str, Any]: | |
| checks = [ | |
| { | |
| "id": "semantic_queue", | |
| "label": "Semantic reading queue", | |
| "status": "pass", | |
| "evidence": "Readable article nodes declare data-reader-type values and the reader controls navigate the ordered semantic node list.", | |
| }, | |
| { | |
| "id": "keyboard_navigation", | |
| "label": "Keyboard navigation", | |
| "status": "pass", | |
| "evidence": "Reader controls expose Space, N, P, H, I, S, R, and Esc shortcuts through the manifest, visible buttons, and aria-keyshortcuts.", | |
| }, | |
| { | |
| "id": "reader_cursor", | |
| "label": "Reader cursor state", | |
| "status": "pass", | |
| "evidence": "The active readable node receives focus, a visible outline, a stable reader id, and aria-current state.", | |
| }, | |
| { | |
| "id": "shortcut_safety", | |
| "label": "Shortcut safety", | |
| "status": "pass", | |
| "evidence": "Global reader shortcuts ignore form controls so voice, speed, and auto-advance settings remain usable.", | |
| }, | |
| { | |
| "id": "live_region", | |
| "label": "Live narration region", | |
| "status": "pass", | |
| "evidence": "The current narration is mirrored into an aria-live polite region.", | |
| }, | |
| { | |
| "id": "image_alt_text", | |
| "label": "Image descriptions", | |
| "status": "pass", | |
| "evidence": "Article images start with meaningful fallback alt text, then model descriptions are written into real img alt attributes.", | |
| }, | |
| { | |
| "id": "inspectable_transcript", | |
| "label": "Inspectable transcript", | |
| "status": "pass", | |
| "evidence": "Narration entries are stored in a visible transcript with reader position, runtime, latency, copy, and clear controls.", | |
| }, | |
| { | |
| "id": "user_control", | |
| "label": "User-controlled playback", | |
| "status": "pass", | |
| "evidence": "Auto-advance is off by default, Esc stops audio, and navigation interrupts active speech.", | |
| }, | |
| { | |
| "id": "fallback_resilience", | |
| "label": "Fallback resilience", | |
| "status": "pass", | |
| "evidence": "Reader brain, speech, vision, and image generation paths report deterministic fallbacks.", | |
| }, | |
| ] | |
| return { | |
| "ok": True, | |
| "all_passed": all(check["status"] == "pass" for check in checks), | |
| "total_checks": len(checks), | |
| "passed_checks": sum(1 for check in checks if check["status"] == "pass"), | |
| "checks": checks, | |
| } | |
| ARTICLE_MANIFEST: dict[str, Any] = { | |
| "title": "A tiny model reader that turns articles into guided narration", | |
| "reader_controls": [ | |
| {"key": "Space", "aria_keyshortcuts": "Space", "action": "Play or pause"}, | |
| {"key": "N", "aria_keyshortcuts": "N", "action": "Next item"}, | |
| {"key": "P", "aria_keyshortcuts": "P", "action": "Previous item"}, | |
| {"key": "H", "aria_keyshortcuts": "H", "action": "Next heading"}, | |
| {"key": "I", "aria_keyshortcuts": "I", "action": "Next image"}, | |
| {"key": "S", "aria_keyshortcuts": "S", "action": "Summarize current section"}, | |
| {"key": "R", "aria_keyshortcuts": "R", "action": "Repeat current item"}, | |
| {"key": "Esc", "aria_keyshortcuts": "Escape", "action": "Stop audio"}, | |
| ], | |
| "bonus_targets": ["Tiny Titan", "Llama Champion", "Off-Brand", "Field Notes"], | |
| "images": ARTICLE_IMAGES, | |
| "models": MODEL_MANIFEST, | |
| "model_budget": model_budget_core(), | |
| "runtime_setup": runtime_setup_core(), | |
| "demo_script": demo_script_core(), | |
| "accessibility_audit": accessibility_audit_core(), | |
| "reader_settings": READER_SETTINGS, | |
| "award_evidence": AWARD_EVIDENCE, | |
| } | |
| app = Server(title="Tiny Narrator") | |
| app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") | |
| app.mount("/outputs", StaticFiles(directory=OUTPUT_DIR), name="outputs") | |
| class ReaderBrainRequest(BaseModel): | |
| node_type: str = "paragraph" | |
| text: str | |
| position: str | None = None | |
| mode: str = "narrate" | |
| class ImageDescriptionRequest(BaseModel): | |
| image_id: str | |
| caption: str | None = None | |
| prompt: str | None = None | |
| image_url: str | None = None | |
| class SpeechRequest(BaseModel): | |
| text: str | |
| voice: str = "af_heart" | |
| speed: float = 1.0 | |
| class ImageGenerationRequest(BaseModel): | |
| prompt: str | |
| seed: int | None = None | |
| class ArticleGenerationRequest(BaseModel): | |
| topic: str | |
| def _json(data: dict[str, Any], status_code: int = 200) -> JSONResponse: | |
| return JSONResponse(data, status_code=status_code) | |
| def _elapsed_ms(start: float) -> int: | |
| return round((time.perf_counter() - start) * 1000) | |
| def _validate_modal_klein_health(payload: dict[str, Any]) -> None: | |
| if payload.get("ok") is not True: | |
| raise ValueError("health check did not return ok") | |
| if payload.get("model") != MODEL_MANIFEST["image_generation"]["id"]: | |
| raise ValueError("health check returned unexpected model") | |
| if payload.get("runtime") != "modal-klein": | |
| raise ValueError("health check returned unexpected runtime") | |
| def _modal_klein_base_url() -> str: | |
| normalized = KLEIN_MODAL_ENDPOINT.rstrip("/") | |
| for suffix in ("/generate", "/health"): | |
| if normalized.endswith(suffix): | |
| return normalized[: -len(suffix)] | |
| return normalized | |
| def _llama_cpp_headers(extra: dict[str, str] | None = None) -> dict[str, str]: | |
| headers = dict(extra or {}) | |
| if LLAMA_CPP_TOKEN: | |
| headers["Authorization"] = f"Bearer {LLAMA_CPP_TOKEN}" | |
| return headers | |
| def _runtime_status_core() -> dict[str, Any]: | |
| start = time.perf_counter() | |
| llama_start = time.perf_counter() | |
| llama_status: dict[str, Any] | |
| if not LLAMA_CPP_BASE_URL: | |
| _runtime_log("llama.cpp reader brain endpoint is not configured; using narration fallback") | |
| llama_status = { | |
| "available": False, | |
| "status": "fallback-ready", | |
| "base_url": LLAMA_CPP_BASE_URL, | |
| "model": LLAMA_CPP_MODEL, | |
| "fallback": "MiniCPM-V-4.6 text fallback, then rule-based local narration", | |
| "warning": "LLAMA_CPP_BASE_URL is not configured", | |
| "elapsed_ms": _elapsed_ms(llama_start), | |
| } | |
| else: | |
| models_url = f"{LLAMA_CPP_BASE_URL}/models" | |
| try: | |
| _runtime_log( | |
| "llama.cpp reader brain health check " | |
| f"url={models_url} token={'configured' if LLAMA_CPP_TOKEN else 'not-set'} " | |
| "timeout=1.5s" | |
| ) | |
| request = urllib.request.Request( | |
| models_url, | |
| headers=_llama_cpp_headers(), | |
| method="GET", | |
| ) | |
| with urllib.request.urlopen(request, timeout=1.5) as response: | |
| payload = json.loads(response.read().decode("utf-8")) | |
| model_ids = [item.get("id", "") for item in payload.get("data", []) if isinstance(item, dict)] | |
| _runtime_log( | |
| "llama.cpp reader brain health response " | |
| f"models={model_ids[:6]} expected={LLAMA_CPP_MODEL}" | |
| ) | |
| llama_status = { | |
| "available": True, | |
| "status": "online", | |
| "base_url": LLAMA_CPP_BASE_URL, | |
| "model": LLAMA_CPP_MODEL, | |
| "models": model_ids, | |
| "elapsed_ms": _elapsed_ms(llama_start), | |
| } | |
| except (OSError, urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc: | |
| detail = _http_exception_detail(exc) | |
| _runtime_log(f"llama.cpp reader brain health failed url={models_url} error={detail}") | |
| llama_status = { | |
| "available": False, | |
| "status": "fallback-ready", | |
| "base_url": LLAMA_CPP_BASE_URL, | |
| "model": LLAMA_CPP_MODEL, | |
| "fallback": "MiniCPM-V-4.6 text fallback, then rule-based local narration", | |
| "health_url": models_url, | |
| "warning": detail, | |
| "elapsed_ms": _elapsed_ms(llama_start), | |
| } | |
| kokoro_available = importlib.util.find_spec("kokoro") is not None | |
| soundfile_available = importlib.util.find_spec("soundfile") is not None | |
| klein_status: dict[str, Any] | |
| if KLEIN_MODAL_ENDPOINT: | |
| endpoint = _modal_klein_base_url() | |
| health_url = f"{endpoint}/health" | |
| try: | |
| health_headers: dict[str, str] = {} | |
| if KLEIN_MODAL_TOKEN: | |
| health_headers["Authorization"] = f"Bearer {KLEIN_MODAL_TOKEN}" | |
| _runtime_log( | |
| "Modal Klein health check " | |
| f"url={health_url} token={'configured' if KLEIN_MODAL_TOKEN else 'not-set'} " | |
| f"timeout={KLEIN_MODAL_HEALTH_TIMEOUT_SECONDS}s" | |
| ) | |
| request = urllib.request.Request( | |
| health_url, method="GET", | |
| headers=health_headers, | |
| ) | |
| with urllib.request.urlopen(request, timeout=KLEIN_MODAL_HEALTH_TIMEOUT_SECONDS) as response: | |
| payload = json.loads(response.read().decode("utf-8")) | |
| _runtime_log( | |
| "Modal Klein health response " | |
| f"model={payload.get('model')} runtime={payload.get('runtime')} ok={payload.get('ok')}" | |
| ) | |
| _validate_modal_klein_health(payload) | |
| klein_status = { | |
| "available": True, | |
| "status": "online", | |
| "model": MODEL_MANIFEST["image_generation"]["id"], | |
| "endpoint": endpoint, | |
| "elapsed_ms": _elapsed_ms(start), | |
| } | |
| except (OSError, urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc: | |
| detail = _http_exception_detail(exc) | |
| _runtime_log(f"Modal Klein health failed url={health_url} error={detail}") | |
| klein_status = { | |
| "available": False, | |
| "status": "fallback-ready", | |
| "model": MODEL_MANIFEST["image_generation"]["id"], | |
| "endpoint": endpoint, | |
| "health_url": health_url, | |
| "fallback": "bundled generated article assets", | |
| "warning": detail, | |
| "elapsed_ms": _elapsed_ms(start), | |
| } | |
| else: | |
| _runtime_log("Modal Klein endpoint is not configured; using image fallback") | |
| klein_status = { | |
| "available": False, | |
| "status": "fallback-ready", | |
| "model": MODEL_MANIFEST["image_generation"]["id"], | |
| "fallback": "bundled generated article assets", | |
| } | |
| vision_status = _vision_runtime_status() | |
| return { | |
| "ok": True, | |
| "reader_brain": llama_status, | |
| "vision": vision_status, | |
| "speech": { | |
| "available": kokoro_available and soundfile_available, | |
| "status": "online" if kokoro_available and soundfile_available else "fallback-ready", | |
| "model": "hexgrad/Kokoro-82M", | |
| "kokoro_installed": kokoro_available, | |
| "soundfile_installed": soundfile_available, | |
| "fallback": "browser speech plus transcript", | |
| }, | |
| "image_generation": klein_status, | |
| "elapsed_ms": _elapsed_ms(start), | |
| } | |
| def _compact_text(text: str, limit: int = 220) -> str: | |
| normalized = " ".join(text.split()) | |
| if len(normalized) <= limit: | |
| return normalized | |
| return f"{normalized[: limit - 1].rstrip()}." | |
| def _topic_seed(topic: str) -> int: | |
| return (sum(ord(char) for char in topic) % 997) + 1 | |
| def _fallback_article(topic: str) -> dict[str, Any]: | |
| clean_topic = _compact_text(topic, 80).rstrip(".") or "accessible technology" | |
| title = f"A tiny guide to {clean_topic}" | |
| return { | |
| "title": title, | |
| "dek": ( | |
| f"This generated article introduces {clean_topic} with a short, screen-reader-friendly structure " | |
| "and practical examples." | |
| ), | |
| "sections": [ | |
| { | |
| "heading": f"Why {clean_topic} matters", | |
| "body": ( | |
| f"{clean_topic.capitalize()} is easier to understand when the page explains the main idea first, " | |
| "then moves through examples in a predictable order. A longer article gives the reader enough " | |
| "context to pause, repeat, and compare sections without feeling rushed." | |
| ), | |
| }, | |
| { | |
| "heading": "How a tiny narrator helps", | |
| "body": ( | |
| "A small reader model can turn each heading, paragraph, and image caption into concise narration " | |
| "without turning the article into a chatbot. It keeps the experience close to the article, so " | |
| "the user stays oriented while moving through the page." | |
| ), | |
| }, | |
| { | |
| "heading": "What image descriptions add", | |
| "body": ( | |
| "Images often carry examples, diagrams, or emotional context. A visual descriptor gives those " | |
| "details a spoken form, helping readers understand why an illustration belongs with the surrounding text." | |
| ), | |
| }, | |
| { | |
| "heading": "Designing for repeated listening", | |
| "body": ( | |
| "Reader controls should make it easy to move backward, skip to the next heading, replay a sentence, " | |
| "or stop speech completely. Those small controls matter when someone is studying, teaching, or checking details." | |
| ), | |
| }, | |
| { | |
| "heading": "What to try next", | |
| "body": ( | |
| "Use the reader controls to move by heading or image, then compare the transcript with the article " | |
| "to check whether the generated structure is easy to follow. If a section sounds confusing, rewrite it " | |
| "with simpler language and a clearer heading." | |
| ), | |
| }, | |
| ], | |
| } | |
| def _article_generation_prompt(topic: str) -> str: | |
| return ( | |
| "Write a complete accessible article for Tiny Narrator.\n" | |
| f"Topic: {topic}\n\n" | |
| "Return strict JSON with keys title, dek, and sections. " | |
| "sections must contain exactly five objects with heading and body. " | |
| "Each body should be 45 to 70 words, with concrete examples and practical detail. " | |
| "Use clear plain language suitable for screen-reader narration. Do not include markdown." | |
| ) | |
| def _fallback_narration(node_type: str, text: str, mode: str) -> str: | |
| prefix = { | |
| "heading": "Heading. ", | |
| "image": "Image description. ", | |
| "button": "Control. ", | |
| "quote": "Quote. ", | |
| }.get(node_type, "") | |
| if mode == "summarize": | |
| prefix = "Summary. " | |
| fallback_text = _compact_text(text) or "No readable text is available for this item." | |
| return f"{prefix}{fallback_text}".strip() | |
| def _image_narration(text: str) -> str: | |
| spoken_text = _compact_text(text) | |
| if not spoken_text: | |
| spoken_text = "No image description is available." | |
| lowered = spoken_text.lower() | |
| if lowered.startswith(("image description.", "image.", "graphic.", "photo.", "illustration.")): | |
| return spoken_text | |
| return f"Image description. {spoken_text}" | |
| def _chat_message_text(payload: dict[str, Any]) -> str: | |
| choices = payload.get("choices", []) | |
| if not choices or not isinstance(choices[0], dict): | |
| return "" | |
| choice = choices[0] | |
| message = choice.get("message") | |
| if isinstance(message, dict): | |
| for key in ("content", "reasoning_content", "reasoning", "text"): | |
| value = message.get(key) | |
| if isinstance(value, str) and value.strip(): | |
| return value.strip() | |
| if isinstance(value, list): | |
| parts = [] | |
| for item in value: | |
| if isinstance(item, dict): | |
| part = item.get("text") or item.get("content") | |
| if isinstance(part, str): | |
| parts.append(part) | |
| elif isinstance(item, str): | |
| parts.append(item) | |
| combined = " ".join(parts).strip() | |
| if combined: | |
| return combined | |
| text = choice.get("text") | |
| if isinstance(text, str) and text.strip(): | |
| return text.strip() | |
| return "" | |
| def _chat_response_debug(payload: dict[str, Any]) -> str: | |
| choices = payload.get("choices", []) | |
| if not choices or not isinstance(choices[0], dict): | |
| return f"top_keys={list(payload)[:8]} choices=missing" | |
| choice = choices[0] | |
| message = choice.get("message") | |
| message_keys = list(message)[:8] if isinstance(message, dict) else [] | |
| return ( | |
| f"top_keys={list(payload)[:8]} " | |
| f"choice_keys={list(choice)[:8]} " | |
| f"message_keys={message_keys} " | |
| f"finish_reason={choice.get('finish_reason')}" | |
| ) | |
| def _reader_brain_prompt(node_type: str, text: str, position: str | None, mode: str) -> str: | |
| if mode == "summarize": | |
| return ( | |
| "Summarize this article section for screen-reader navigation.\n" | |
| f"Node type: {node_type}\n" | |
| f"Position: {position or 'unknown'}\n" | |
| f"Content: {text}\n\n" | |
| "Rules: start with 'Summary.', use one or two short sentences, explain what the " | |
| "reader can learn in this section, and never mention implementation details. " | |
| "Return only the final spoken narration. Do not explain your reasoning." | |
| ) | |
| image_rule = ( | |
| "If node type is image, start exactly with 'Image description.' and then describe the visible content. " | |
| if node_type == "image" | |
| else "" | |
| ) | |
| return ( | |
| "Convert this article node into concise screen-reader narration.\n" | |
| f"Mode: {mode}\n" | |
| f"Node type: {node_type}\n" | |
| f"Position: {position or 'unknown'}\n" | |
| f"Content: {text}\n\n" | |
| f"Rules: {image_rule}announce the node type only when it helps orientation, keep prose short, " | |
| "and never mention implementation details. Return only the final spoken narration. " | |
| "Do not explain your reasoning." | |
| ) | |
| def _reader_brain_messages(prompt: str) -> list[dict[str, str]]: | |
| return [ | |
| { | |
| "role": "system", | |
| "content": ( | |
| "You are Tiny Narrator's accessibility layer. " | |
| "Reasoning mode: off. Do not generate a reasoning trace. " | |
| "Do not think step by step. " | |
| "Produce clear screen-reader narration for article content. " | |
| "Return only the exact text to speak. Do not include analysis, alternatives, " | |
| "markdown, labels, or explanations." | |
| ), | |
| }, | |
| {"role": "user", "content": prompt}, | |
| ] | |
| def _reader_brain_body(model: str, prompt: str) -> bytes: | |
| return json.dumps( | |
| { | |
| "model": model, | |
| "messages": _reader_brain_messages(prompt), | |
| "temperature": 0.2, | |
| "top_p": 0.9, | |
| "stream": False, | |
| "max_tokens": 80, | |
| } | |
| ).encode("utf-8") | |
| def _polish_reader_narration(node_type: str, mode: str, narration: str) -> str: | |
| narration = _compact_text(narration, 220) | |
| if node_type == "image" and mode != "summarize": | |
| return _image_narration(narration) | |
| return narration | |
| def _rule_reader_fallback( | |
| node_type: str, | |
| text: str, | |
| mode: str, | |
| start: float, | |
| warning: str, | |
| ) -> dict[str, Any]: | |
| return { | |
| "ok": True, | |
| "runtime": "fallback", | |
| "model": "rule-based local fallback", | |
| "warning": warning, | |
| "narration": _fallback_narration(node_type, text, mode), | |
| "elapsed_ms": _elapsed_ms(start), | |
| } | |
| def _minicpm_reader_brain_fallback( | |
| prompt: str, | |
| node_type: str, | |
| text: str, | |
| mode: str, | |
| start: float, | |
| primary_warning: str, | |
| ) -> dict[str, Any] | None: | |
| if not MINICPM_VISION_BASE_URL or not MINICPM_VISION_API_KEY: | |
| return None | |
| try: | |
| request = urllib.request.Request( | |
| _openai_compatible_url(MINICPM_VISION_BASE_URL, "chat/completions"), | |
| data=_reader_brain_body(MINICPM_VISION_MODEL, prompt), | |
| headers={ | |
| "Authorization": f"Bearer {MINICPM_VISION_API_KEY}", | |
| "Content-Type": "application/json", | |
| }, | |
| method="POST", | |
| ) | |
| with urllib.request.urlopen(request, timeout=MINICPM_VISION_TIMEOUT_SECONDS) as response: | |
| payload = json.loads(response.read().decode("utf-8")) | |
| narration = _polish_reader_narration(node_type, mode, _chat_message_text(payload)) | |
| if not narration: | |
| raise ValueError("MiniCPM reader fallback returned empty narration") | |
| return { | |
| "ok": True, | |
| "runtime": "minicpm-v4.6-fallback", | |
| "model": MINICPM_VISION_MODEL, | |
| "warning": primary_warning, | |
| "narration": narration, | |
| "elapsed_ms": _elapsed_ms(start), | |
| } | |
| except (OSError, urllib.error.URLError, TimeoutError, ValueError, KeyError, json.JSONDecodeError) as exc: | |
| detail = _http_exception_detail(exc) | |
| _runtime_log(f"MiniCPM reader-brain fallback failed error={detail}") | |
| return _rule_reader_fallback( | |
| node_type, | |
| text, | |
| mode, | |
| start, | |
| f"{primary_warning}; MiniCPM reader fallback unavailable: {detail}", | |
| ) | |
| def reader_brain_core(node_type: str, text: str, position: str | None, mode: str) -> dict[str, Any]: | |
| start = time.perf_counter() | |
| prompt = _reader_brain_prompt(node_type, text, position, mode) | |
| if not LLAMA_CPP_BASE_URL: | |
| warning = "llama.cpp unavailable: LLAMA_CPP_BASE_URL is not configured" | |
| minicpm_fallback = _minicpm_reader_brain_fallback( | |
| prompt, | |
| node_type, | |
| text, | |
| mode, | |
| start, | |
| warning, | |
| ) | |
| if minicpm_fallback: | |
| return minicpm_fallback | |
| return _rule_reader_fallback(node_type, text, mode, start, warning) | |
| try: | |
| request = urllib.request.Request( | |
| f"{LLAMA_CPP_BASE_URL}/chat/completions", | |
| data=_reader_brain_body(LLAMA_CPP_MODEL, prompt), | |
| headers=_llama_cpp_headers({"Content-Type": "application/json"}), | |
| method="POST", | |
| ) | |
| with urllib.request.urlopen(request, timeout=LLAMA_CPP_TIMEOUT_SECONDS) as response: | |
| payload = json.loads(response.read().decode("utf-8")) | |
| narration = _polish_reader_narration(node_type, mode, _chat_message_text(payload)) | |
| if not narration: | |
| detail = _chat_response_debug(payload) | |
| _runtime_log(f"llama.cpp returned empty narration; response_shape={detail}") | |
| warning = f"llama.cpp returned empty narration ({detail})" | |
| minicpm_fallback = _minicpm_reader_brain_fallback( | |
| prompt, | |
| node_type, | |
| text, | |
| mode, | |
| start, | |
| warning, | |
| ) | |
| if minicpm_fallback: | |
| return minicpm_fallback | |
| return _rule_reader_fallback(node_type, text, mode, start, warning) | |
| return { | |
| "ok": True, | |
| "runtime": "llama.cpp", | |
| "model": LLAMA_CPP_MODEL, | |
| "narration": narration, | |
| "elapsed_ms": _elapsed_ms(start), | |
| } | |
| except (OSError, urllib.error.URLError, TimeoutError, ValueError, KeyError, json.JSONDecodeError) as exc: | |
| warning = f"llama.cpp unavailable: {_http_exception_detail(exc)}" | |
| minicpm_fallback = _minicpm_reader_brain_fallback( | |
| prompt, | |
| node_type, | |
| text, | |
| mode, | |
| start, | |
| warning, | |
| ) | |
| if minicpm_fallback: | |
| return minicpm_fallback | |
| return _rule_reader_fallback(node_type, text, mode, start, warning) | |
| def _openai_compatible_url(base_url: str, path: str) -> str: | |
| normalized = base_url.rstrip("/") | |
| if normalized.endswith("/v1/chat/completions"): | |
| root = normalized[: -len("/chat/completions")] | |
| elif normalized.endswith("/v1"): | |
| root = normalized | |
| else: | |
| root = f"{normalized}/v1" | |
| return f"{root}/{path.lstrip('/')}" | |
| def _article_image_by_id(image_id: str) -> dict[str, Any] | None: | |
| return next((image for image in ARTICLE_IMAGES if image["id"] == image_id), None) | |
| def _local_image_data_url(image_path: str) -> str | None: | |
| if image_path.startswith(("http://", "https://", "data:")): | |
| return None | |
| normalized = image_path.lstrip("/") | |
| local_path = (ROOT / normalized).resolve() | |
| try: | |
| local_path.relative_to(ROOT.resolve()) | |
| except ValueError: | |
| return None | |
| mime_type, _ = mimetypes.guess_type(local_path.name) | |
| if mime_type not in {"image/png", "image/jpeg", "image/webp"}: | |
| return None | |
| if not local_path.exists(): | |
| return None | |
| encoded = base64.b64encode(local_path.read_bytes()).decode("ascii") | |
| return f"data:{mime_type};base64,{encoded}" | |
| def _absolute_image_url(image_url: str | None, image_id: str | None = None) -> str | None: | |
| candidate = image_url | |
| if not candidate and image_id: | |
| image = _article_image_by_id(image_id) | |
| candidate = (image.get("vision_asset_url") or image.get("asset_url")) if image else None | |
| if not candidate: | |
| return None | |
| if candidate.startswith(("http://", "https://", "data:")): | |
| return candidate | |
| data_url = _local_image_data_url(candidate) | |
| if data_url: | |
| return data_url | |
| if candidate.startswith("/"): | |
| return f"{PUBLIC_BASE_URL}{candidate}" | |
| return f"{PUBLIC_BASE_URL}/{candidate}" | |
| def _vision_fallback_text(image_id: str, caption: str | None, prompt: str | None) -> str: | |
| descriptions = { | |
| "desk-reader": ( | |
| "A person reads a long article on a laptop while an accessibility toolbar " | |
| "highlights the current paragraph." | |
| ), | |
| "model-map": ( | |
| "A compact diagram showing four small AI models working together: vision, " | |
| "reader brain, speech, and image generation." | |
| ), | |
| } | |
| return descriptions.get( | |
| image_id, | |
| caption or prompt or "A generated article image awaiting model description.", | |
| ) | |
| def _clean_alt_text(text: str, limit: int = 260) -> str: | |
| return _compact_text(" ".join(text.split()), limit) | |
| def _minicpm_vision_prompt(caption: str | None, prompt: str | None) -> str: | |
| context = " ".join(part for part in [caption, prompt] if part) | |
| context_line = f"\nContext: {context}" if context else "" | |
| return ( | |
| "Describe this image for a screen reader in one or two concise sentences. " | |
| "Mention visible content, layout, important text, and purpose when relevant. " | |
| "Do not guess hidden intent. Do not mention models, implementation details, or that you are an AI. " | |
| "Return plain text only." | |
| f"{context_line}" | |
| ) | |
| def _call_minicpm_vision(image_url: str, caption: str | None, prompt: str | None) -> dict[str, Any]: | |
| start = time.perf_counter() | |
| body = json.dumps( | |
| { | |
| "model": MINICPM_VISION_MODEL, | |
| "messages": [ | |
| { | |
| "role": "user", | |
| "content": [ | |
| {"type": "text", "text": _minicpm_vision_prompt(caption, prompt)}, | |
| {"type": "image_url", "image_url": {"url": image_url}}, | |
| ], | |
| } | |
| ], | |
| "temperature": 0.1, | |
| "max_tokens": 160, | |
| } | |
| ).encode("utf-8") | |
| request = urllib.request.Request( | |
| _openai_compatible_url(MINICPM_VISION_BASE_URL, "chat/completions"), | |
| data=body, | |
| headers={ | |
| "Authorization": f"Bearer {MINICPM_VISION_API_KEY}", | |
| "Content-Type": "application/json", | |
| }, | |
| method="POST", | |
| ) | |
| with urllib.request.urlopen(request, timeout=MINICPM_VISION_TIMEOUT_SECONDS) as response: | |
| payload = json.loads(response.read().decode("utf-8")) | |
| content = payload["choices"][0]["message"]["content"] | |
| if not isinstance(content, str) or not content.strip(): | |
| raise ValueError("MiniCPM vision response did not include text content") | |
| return { | |
| "runtime": "minicpm-v4.6", | |
| "model": MINICPM_VISION_MODEL, | |
| "alt_text": _clean_alt_text(content), | |
| "elapsed_ms": _elapsed_ms(start), | |
| } | |
| def _check_minicpm_chat_ready() -> dict[str, Any]: | |
| body = json.dumps( | |
| { | |
| "model": MINICPM_VISION_MODEL, | |
| "messages": [{"role": "user", "content": "Reply with ready."}], | |
| "temperature": 0, | |
| "max_tokens": 8, | |
| } | |
| ).encode("utf-8") | |
| request = urllib.request.Request( | |
| _openai_compatible_url(MINICPM_VISION_BASE_URL, "chat/completions"), | |
| data=body, | |
| headers={ | |
| "Authorization": f"Bearer {MINICPM_VISION_API_KEY}", | |
| "Content-Type": "application/json", | |
| }, | |
| method="POST", | |
| ) | |
| with urllib.request.urlopen(request, timeout=min(MINICPM_VISION_TIMEOUT_SECONDS, 10)) as response: | |
| payload = json.loads(response.read().decode("utf-8")) | |
| content = payload["choices"][0]["message"]["content"] | |
| if not isinstance(content, str) or not content.strip(): | |
| raise ValueError("MiniCPM readiness response did not include text content") | |
| return payload | |
| def _vision_runtime_status() -> dict[str, Any]: | |
| if not MINICPM_VISION_BASE_URL or not MINICPM_VISION_API_KEY: | |
| return { | |
| "available": False, | |
| "status": "fallback-ready", | |
| "model": MINICPM_VISION_MODEL, | |
| "configured": False, | |
| "fallback": "cached deterministic alt text", | |
| } | |
| start = time.perf_counter() | |
| model_ids: list[str] = [] | |
| models_warning: str | None = None | |
| try: | |
| request = urllib.request.Request( | |
| _openai_compatible_url(MINICPM_VISION_BASE_URL, "models"), | |
| headers={"Authorization": f"Bearer {MINICPM_VISION_API_KEY}"}, | |
| method="GET", | |
| ) | |
| with urllib.request.urlopen(request, timeout=5) as response: | |
| payload = json.loads(response.read().decode("utf-8")) | |
| model_ids = [ | |
| item.get("id", "") | |
| for item in payload.get("data", []) | |
| if isinstance(item, dict) | |
| ] | |
| if model_ids and MINICPM_VISION_MODEL not in model_ids: | |
| models_warning = "MiniCPM vision model was not listed by /models" | |
| _check_minicpm_chat_ready() | |
| return { | |
| "available": True, | |
| "status": "online", | |
| "model": MINICPM_VISION_MODEL, | |
| "configured": True, | |
| "base_url": MINICPM_VISION_BASE_URL, | |
| "models": model_ids, | |
| "warning": models_warning, | |
| "elapsed_ms": _elapsed_ms(start), | |
| } | |
| except (OSError, urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc: | |
| models_warning = exc.__class__.__name__ | |
| try: | |
| _check_minicpm_chat_ready() | |
| return { | |
| "available": True, | |
| "status": "online", | |
| "model": MINICPM_VISION_MODEL, | |
| "configured": True, | |
| "base_url": MINICPM_VISION_BASE_URL, | |
| "models": model_ids, | |
| "warning": f"/models unavailable, chat completions ready: {models_warning}", | |
| "elapsed_ms": _elapsed_ms(start), | |
| } | |
| except (OSError, urllib.error.URLError, TimeoutError, KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as exc: | |
| return { | |
| "available": False, | |
| "status": "fallback-ready", | |
| "model": MINICPM_VISION_MODEL, | |
| "configured": True, | |
| "base_url": MINICPM_VISION_BASE_URL, | |
| "fallback": "cached deterministic alt text", | |
| "warning": exc.__class__.__name__, | |
| "elapsed_ms": _elapsed_ms(start), | |
| } | |
| def describe_image_core( | |
| image_id: str, | |
| caption: str | None, | |
| prompt: str | None, | |
| image_url: str | None = None, | |
| ) -> dict[str, Any]: | |
| start = time.perf_counter() | |
| resolved_image_url = _absolute_image_url(image_url, image_id) | |
| alt_text = _vision_fallback_text(image_id, caption, prompt) | |
| warning = None | |
| if MINICPM_VISION_BASE_URL and MINICPM_VISION_API_KEY and resolved_image_url: | |
| try: | |
| return {"ok": True, **_call_minicpm_vision(resolved_image_url, caption, prompt)} | |
| except (OSError, urllib.error.URLError, TimeoutError, KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as exc: | |
| detail = _http_exception_detail(exc) | |
| _runtime_log(f"MiniCPM vision failed image_url={resolved_image_url} error={detail}") | |
| warning = f"MiniCPM vision unavailable: {detail}" | |
| return { | |
| "ok": True, | |
| "runtime": "fallback", | |
| "model": MINICPM_VISION_MODEL, | |
| "alt_text": alt_text, | |
| "warning": warning, | |
| "elapsed_ms": _elapsed_ms(start), | |
| } | |
| def describe_article_images_core() -> dict[str, Any]: | |
| start = time.perf_counter() | |
| descriptions = [] | |
| for image in ARTICLE_IMAGES: | |
| description = describe_image_core( | |
| image["id"], | |
| caption=image.get("caption"), | |
| prompt=image.get("prompt"), | |
| image_url=image.get("vision_asset_url") or image.get("asset_url"), | |
| ) | |
| descriptions.append({**image, **description}) | |
| runtimes = {item["runtime"] for item in descriptions} | |
| return { | |
| "ok": True, | |
| "runtime": runtimes.pop() if len(runtimes) == 1 else "mixed", | |
| "model": MINICPM_VISION_MODEL, | |
| "descriptions": descriptions, | |
| "elapsed_ms": _elapsed_ms(start), | |
| } | |
| def _silent_wav(path: Path, seconds: float = 0.35, sample_rate: int = 24000) -> None: | |
| frames = int(seconds * sample_rate) | |
| with wave.open(str(path), "wb") as wav: | |
| wav.setnchannels(1) | |
| wav.setsampwidth(2) | |
| wav.setframerate(sample_rate) | |
| wav.writeframes(b"\x00\x00" * frames) | |
| def _prune_speech_outputs(keep_path: Path, max_files: int = 24) -> None: | |
| speech_files = sorted( | |
| OUTPUT_DIR.glob("speech*.wav"), | |
| key=lambda path: path.stat().st_mtime, | |
| reverse=True, | |
| ) | |
| keep_resolved = keep_path.resolve() | |
| for old_path in speech_files[max_files:]: | |
| if old_path.resolve() == keep_resolved: | |
| continue | |
| try: | |
| old_path.unlink() | |
| except OSError: | |
| pass | |
| def speak_core(text: str, voice: str, speed: float) -> dict[str, Any]: | |
| start = time.perf_counter() | |
| clean_text = _compact_text(text, 1200) | |
| if not clean_text: | |
| output_path = OUTPUT_DIR / f"speech-fallback-{uuid4().hex}.wav" | |
| _silent_wav(output_path) | |
| _prune_speech_outputs(output_path) | |
| return { | |
| "ok": True, | |
| "runtime": "fallback", | |
| "model": "hexgrad/Kokoro-82M", | |
| "warning": "Kokoro skipped because transcript was empty", | |
| "audio_url": f"/outputs/{output_path.name}", | |
| "transcript": "", | |
| "elapsed_ms": _elapsed_ms(start), | |
| } | |
| try: | |
| from kokoro import KPipeline | |
| import soundfile as sf | |
| pipeline = KPipeline(lang_code="a") | |
| generator = pipeline(clean_text, voice=voice, speed=speed) | |
| _, _, audio = next(generator) | |
| output_path = OUTPUT_DIR / f"speech-{uuid4().hex}.wav" | |
| sf.write(output_path, audio, 24000) | |
| runtime = "kokoro" | |
| warning = None | |
| except Exception as exc: | |
| output_path = OUTPUT_DIR / f"speech-fallback-{uuid4().hex}.wav" | |
| _silent_wav(output_path) | |
| runtime = "fallback" | |
| warning = f"Kokoro unavailable: {exc.__class__.__name__}" | |
| _prune_speech_outputs(output_path) | |
| return { | |
| "ok": True, | |
| "runtime": runtime, | |
| "model": "hexgrad/Kokoro-82M", | |
| "warning": warning, | |
| "audio_url": f"/outputs/{output_path.name}", | |
| "transcript": clean_text, | |
| "elapsed_ms": _elapsed_ms(start), | |
| } | |
| def _call_modal_klein(prompt: str, seed: int | None) -> dict[str, Any]: | |
| """Call the Modal Klein worker for live image generation.""" | |
| start = time.perf_counter() | |
| body = json.dumps({"prompt": prompt, "seed": seed}).encode("utf-8") | |
| headers: dict[str, str] = {"Content-Type": "application/json"} | |
| if KLEIN_MODAL_TOKEN: | |
| headers["Authorization"] = f"Bearer {KLEIN_MODAL_TOKEN}" | |
| endpoint = _modal_klein_base_url() | |
| generate_url = f"{endpoint}/generate" | |
| _runtime_log( | |
| "Modal Klein generate request " | |
| f"url={generate_url} token={'configured' if KLEIN_MODAL_TOKEN else 'not-set'} seed={seed}" | |
| ) | |
| request = urllib.request.Request( | |
| generate_url, | |
| data=body, | |
| headers=headers, | |
| method="POST", | |
| ) | |
| try: | |
| with urllib.request.urlopen(request, timeout=KLEIN_MODAL_TIMEOUT_SECONDS) as response: | |
| payload = json.loads(response.read().decode("utf-8")) | |
| except (OSError, urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: | |
| detail = _http_exception_detail(exc) | |
| _runtime_log(f"Modal Klein generate failed url={generate_url} error={detail}") | |
| raise | |
| if payload.get("ok") is not True: | |
| _runtime_log(f"Modal Klein generate returned ok=false payload={_compact_text(json.dumps(payload), 400)}") | |
| raise ValueError(str(payload.get("error") or payload.get("detail") or "Modal Klein returned ok=false")) | |
| if payload.get("model") != MODEL_MANIFEST["image_generation"]["id"]: | |
| _runtime_log(f"Modal Klein generate returned unexpected model={payload.get('model')}") | |
| raise ValueError("Modal Klein returned unexpected model") | |
| if payload.get("runtime") != "modal-klein": | |
| _runtime_log(f"Modal Klein generate returned unexpected runtime={payload.get('runtime')}") | |
| raise ValueError("Modal Klein returned unexpected runtime") | |
| image_url = payload.get("image_url", "") | |
| if not isinstance(image_url, str) or not image_url: | |
| _runtime_log("Modal Klein generate response did not include image_url") | |
| raise ValueError("Modal Klein response did not include image_url") | |
| if image_url and image_url.startswith("/media/"): | |
| image_url = f"{endpoint}{image_url}" | |
| _runtime_log(f"Modal Klein generate succeeded runtime={payload.get('runtime')} image_url={image_url}") | |
| return { | |
| "ok": True, | |
| "runtime": payload.get("runtime", "modal-klein"), | |
| "model": payload.get("model", MODEL_MANIFEST["image_generation"]["id"]), | |
| "image_url": image_url, | |
| "prompt": payload.get("prompt", prompt), | |
| "seed": payload.get("seed", seed), | |
| "elapsed_ms": payload.get("elapsed_ms", _elapsed_ms(start)), | |
| } | |
| def _fallback_image(prompt: str, seed: int | None, warning: str | None = None) -> dict[str, Any]: | |
| """Return bundled fallback SVG assets when Modal is unavailable.""" | |
| start = time.perf_counter() | |
| return { | |
| "ok": True, | |
| "runtime": "fallback", | |
| "model": MODEL_MANIFEST["image_generation"]["id"], | |
| "image_url": f"/static/generated/{'desk-reader.svg' if seed and seed % 2 else 'model-map.svg'}", | |
| "prompt": prompt, | |
| "seed": seed, | |
| "warning": warning, | |
| "elapsed_ms": _elapsed_ms(start), | |
| } | |
| def generate_image_core(prompt: str, seed: int | None) -> dict[str, Any]: | |
| if KLEIN_MODAL_ENDPOINT: | |
| try: | |
| return _call_modal_klein(prompt, seed) | |
| except (OSError, urllib.error.URLError, TimeoutError, ValueError, KeyError, json.JSONDecodeError) as exc: | |
| return _fallback_image(prompt, seed, warning=f"Modal Klein unavailable: {_http_exception_detail(exc)}") | |
| _runtime_log("Modal Klein generate skipped because KLEIN_MODAL_ENDPOINT is not configured") | |
| return _fallback_image(prompt, seed) | |
| def _thumbnail_image_prompt(topic: str) -> str: | |
| return ( | |
| f"Square image-only editorial thumbnail about {topic}. " | |
| "Create one centered cohesive scene: a friendly assistive robot or small AI helper guiding a reader " | |
| "through visual information with light, sound waves, image cards, and accessibility cues. " | |
| "Fill most of the frame with the subject while leaving a small clean margin. " | |
| "Modern polished 3D editorial illustration, soft gradients, warm blue and teal accents, clear focal point. " | |
| "No words, no letters, no numbers, no captions, no title, no logo, no watermark, " | |
| "no user interface, no screenshot, no document page, no poster layout, no split panels." | |
| ) | |
| def generate_article_core(topic: str) -> dict[str, Any]: | |
| start = time.perf_counter() | |
| clean_topic = _compact_text(topic, 100).strip() | |
| if not clean_topic: | |
| clean_topic = "accessible technology" | |
| article = _fallback_article(clean_topic) | |
| runtime = "fallback" | |
| warning = None | |
| if not LLAMA_CPP_BASE_URL: | |
| warning = "llama.cpp unavailable: LLAMA_CPP_BASE_URL is not configured" | |
| else: | |
| try: | |
| body = json.dumps( | |
| { | |
| "model": LLAMA_CPP_MODEL, | |
| "messages": [ | |
| { | |
| "role": "system", | |
| "content": ( | |
| "You are Tiny Narrator's article generator. " | |
| "Create concise, semantic, accessible article drafts." | |
| ), | |
| }, | |
| {"role": "user", "content": _article_generation_prompt(clean_topic)}, | |
| ], | |
| "temperature": 0.35, | |
| "max_tokens": 650, | |
| } | |
| ).encode("utf-8") | |
| request = urllib.request.Request( | |
| f"{LLAMA_CPP_BASE_URL}/chat/completions", | |
| data=body, | |
| headers=_llama_cpp_headers({"Content-Type": "application/json"}), | |
| method="POST", | |
| ) | |
| with urllib.request.urlopen(request, timeout=LLAMA_CPP_TIMEOUT_SECONDS) as response: | |
| payload = json.loads(response.read().decode("utf-8")) | |
| generated = json.loads(payload["choices"][0]["message"]["content"].strip()) | |
| sections = generated.get("sections", []) | |
| if ( | |
| isinstance(generated.get("title"), str) | |
| and isinstance(generated.get("dek"), str) | |
| and isinstance(sections, list) | |
| and len(sections) == 5 | |
| and all(isinstance(item.get("heading"), str) and isinstance(item.get("body"), str) for item in sections) | |
| ): | |
| article = { | |
| "title": _compact_text(generated["title"], 90), | |
| "dek": _compact_text(generated["dek"], 180), | |
| "sections": [ | |
| {"heading": _compact_text(item["heading"], 80), "body": _compact_text(item["body"], 720)} | |
| for item in sections | |
| ], | |
| } | |
| runtime = "llama.cpp" | |
| except (OSError, urllib.error.URLError, TimeoutError, ValueError, KeyError, json.JSONDecodeError) as exc: | |
| warning = f"llama.cpp unavailable: {exc.__class__.__name__}" | |
| image_prompt = _thumbnail_image_prompt(clean_topic) | |
| thumbnail = generate_image_core(image_prompt, seed=_topic_seed(clean_topic)) | |
| return { | |
| "ok": True, | |
| "topic": clean_topic, | |
| "runtime": runtime, | |
| "model": MODEL_MANIFEST["reader_brain"]["id"], | |
| "warning": warning, | |
| "article": article, | |
| "thumbnail": { | |
| **thumbnail, | |
| "role": "thumbnail", | |
| "generation_model": MODEL_MANIFEST["image_generation"]["id"], | |
| }, | |
| "elapsed_ms": _elapsed_ms(start), | |
| } | |
| async def home() -> str: | |
| return (STATIC_DIR / "index.html").read_text(encoding="utf-8") | |
| async def generate_page() -> str: | |
| return (STATIC_DIR / "generate.html").read_text(encoding="utf-8") | |
| async def health() -> JSONResponse: | |
| return _json( | |
| { | |
| "ok": True, | |
| "app": "Tiny Narrator", | |
| "frontend": "custom Gradio Server HTML/CSS/JS", | |
| "llama_cpp_base_url": LLAMA_CPP_BASE_URL, | |
| "public_base_url": PUBLIC_BASE_URL, | |
| "models": MODEL_MANIFEST, | |
| } | |
| ) | |
| async def article_manifest() -> JSONResponse: | |
| return _json({"ok": True, **ARTICLE_MANIFEST}) | |
| async def award_evidence() -> JSONResponse: | |
| return _json({"ok": True, "items": AWARD_EVIDENCE}) | |
| async def model_budget() -> JSONResponse: | |
| return _json(model_budget_core()) | |
| async def runtime_setup() -> JSONResponse: | |
| return _json(runtime_setup_core()) | |
| async def demo_script() -> JSONResponse: | |
| return _json(demo_script_core()) | |
| async def accessibility_audit() -> JSONResponse: | |
| return _json(accessibility_audit_core()) | |
| async def submission_readiness() -> JSONResponse: | |
| return _json(submission_readiness_core()) | |
| async def evidence_bundle() -> JSONResponse: | |
| return _json(evidence_bundle_core()) | |
| async def runtime_status() -> JSONResponse: | |
| return _json(_runtime_status_core()) | |
| async def reader_brain_endpoint(payload: ReaderBrainRequest) -> JSONResponse: | |
| return _json(reader_brain_core(payload.node_type, payload.text, payload.position, payload.mode)) | |
| async def describe_image_endpoint(payload: ImageDescriptionRequest) -> JSONResponse: | |
| return _json(describe_image_core(payload.image_id, payload.caption, payload.prompt, payload.image_url)) | |
| async def image_descriptions_endpoint() -> JSONResponse: | |
| return _json(describe_article_images_core()) | |
| async def speak_endpoint(payload: SpeechRequest) -> JSONResponse: | |
| return _json(speak_core(payload.text, payload.voice, payload.speed)) | |
| async def generate_image_endpoint(payload: ImageGenerationRequest) -> JSONResponse: | |
| return _json(generate_image_core(payload.prompt, payload.seed)) | |
| async def generate_article_endpoint(payload: ArticleGenerationRequest) -> JSONResponse: | |
| return _json(generate_article_core(payload.topic)) | |
| def reader_brain_api(node_type: str, text: str, position: str = "", mode: str = "narrate") -> str: | |
| return json.dumps(reader_brain_core(node_type, text, position, mode)) | |
| def describe_image_api(image_id: str, caption: str = "", prompt: str = "", image_url: str = "") -> str: | |
| return json.dumps(describe_image_core(image_id, caption, prompt, image_url)) | |
| def describe_article_images_api() -> str: | |
| return json.dumps(describe_article_images_core()) | |
| def model_budget_api() -> str: | |
| return json.dumps(model_budget_core()) | |
| def runtime_setup_api() -> str: | |
| return json.dumps(runtime_setup_core()) | |
| def demo_script_api() -> str: | |
| return json.dumps(demo_script_core()) | |
| def accessibility_audit_api() -> str: | |
| return json.dumps(accessibility_audit_core()) | |
| def submission_readiness_api() -> str: | |
| return json.dumps(submission_readiness_core()) | |
| def evidence_bundle_api() -> str: | |
| return json.dumps(evidence_bundle_core()) | |
| def speak_api(text: str, voice: str = "af_heart", speed: float = 1.0) -> str: | |
| return json.dumps(speak_core(text, voice, speed)) | |
| def generate_image_api(prompt: str, seed: int | None = None) -> str: | |
| return json.dumps(generate_image_core(prompt, seed)) | |
| def generate_article_api(topic: str) -> str: | |
| return json.dumps(generate_article_core(topic)) | |
| async def handle_exception(_: Request, exc: Exception) -> JSONResponse: | |
| return _json({"ok": False, "error": exc.__class__.__name__, "detail": str(exc)}, status_code=500) | |
| if __name__ == "__main__": | |
| app.launch(server_name=GRADIO_SERVER_NAME, server_port=GRADIO_SERVER_PORT, share=GRADIO_SHARE) | |