| from __future__ import annotations |
|
|
| import os |
| import re |
| import tempfile |
| import threading |
| import time |
| from html import escape |
|
|
| import gradio as gr |
|
|
| try: |
| import spaces |
| except ImportError: |
| class _SpacesFallback: |
| @staticmethod |
| def GPU(*args, **kwargs): |
| def decorator(fn): |
| return fn |
|
|
| return decorator |
|
|
| spaces = _SpacesFallback() |
|
|
| from study_engine import ( |
| DEFAULT_MODEL_ID, |
| DEMO_CASES, |
| EXAMPLE_INPUT, |
| VISION_MODEL_ID, |
| answer_drills, |
| build_rescue_plan, |
| classify_gpu_failure, |
| coach_state, |
| ensure_weights, |
| extract_topics_from_image, |
| free_resident_vlm, |
| load_resident_vlm, |
| packet_to_markdown, |
| time_blocks, |
| ) |
|
|
|
|
| CSS = """ |
| :root { |
| --ink: #071613; |
| --muted: #1c342f; |
| --muted-soft: #27423c; |
| --paper: #f4e2c5; |
| --card: #fffaf0; |
| --card-solid: #fff8ea; |
| --field: #fffef9; |
| --line: #5e5545; |
| --green: #005844; |
| --green-dark: #032f28; |
| --coral: #84231b; |
| --gold: #755004; |
| --blue: #073e58; |
| --graph: rgba(7, 62, 88, 0.11); |
| --shadow: rgba(37, 29, 16, 0.20); |
| } |
| |
| .gradio-container { |
| background: |
| radial-gradient(circle at 8% 8%, rgba(183, 67, 54, 0.18), transparent 26%), |
| radial-gradient(circle at 92% 4%, rgba(0, 108, 91, 0.18), transparent 24%), |
| linear-gradient(var(--graph) 1px, transparent 1px), |
| linear-gradient(90deg, var(--graph) 1px, transparent 1px), |
| var(--paper); |
| background-size: auto, 24px 24px, 24px 24px, auto; |
| color: var(--ink); |
| font-family: "Trebuchet MS", "Segoe UI", ui-sans-serif, system-ui, sans-serif; |
| -webkit-font-smoothing: antialiased; |
| text-rendering: optimizeLegibility; |
| min-height: 100vh; |
| } |
| |
| .gradio-container, |
| .gradio-container * { |
| text-shadow: none !important; |
| } |
| |
| .gradio-container button:focus-visible, |
| .gradio-container textarea:focus-visible, |
| .gradio-container input:focus-visible, |
| .gradio-container select:focus-visible { |
| outline: 3px solid rgba(0, 108, 91, 0.34) !important; |
| outline-offset: 2px !important; |
| } |
| |
| .app-shell { |
| max-width: 1240px; |
| margin: 0 auto; |
| padding: 24px clamp(14px, 3vw, 34px) 38px; |
| } |
| |
| .hero { |
| position: relative; |
| overflow: hidden; |
| display: grid; |
| grid-template-columns: minmax(0, 1fr); |
| gap: 14px; |
| border: 1px solid rgba(7, 22, 19, 0.34); |
| border-radius: 24px; |
| background: |
| linear-gradient(135deg, #fffaf0, #f4d9aa); |
| box-shadow: 0 18px 48px rgba(37, 29, 16, 0.18); |
| padding: clamp(18px, 3vw, 30px); |
| } |
| |
| .hero:after { |
| content: ""; |
| position: absolute; |
| right: -92px; |
| top: -102px; |
| width: 260px; |
| height: 260px; |
| border-radius: 999px; |
| border: 38px solid rgba(183, 67, 54, 0.12); |
| } |
| |
| .eyebrow { |
| display: inline-flex; |
| align-items: center; |
| width: fit-content; |
| border: 1px solid rgba(0, 108, 91, 0.28); |
| border-radius: 999px; |
| background: rgba(0, 88, 68, 0.16); |
| color: var(--green-dark); |
| font-size: 14px; |
| font-weight: 900; |
| letter-spacing: 0.10em; |
| padding: 8px 12px; |
| text-transform: uppercase; |
| } |
| |
| .hero h1 { |
| position: relative; |
| margin: 14px 0 8px; |
| font-family: Georgia, "Times New Roman", ui-serif, serif; |
| color: var(--ink); |
| font-size: clamp(34px, 5vw, 58px); |
| line-height: 0.98; |
| letter-spacing: -0.045em; |
| max-width: 860px; |
| } |
| |
| .hero p { |
| margin: 0; |
| max-width: 720px; |
| color: var(--muted); |
| font-size: clamp(17px, 2vw, 20px); |
| font-weight: 750; |
| line-height: 1.55; |
| } |
| |
| .hero-steps { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 8px; |
| margin-top: 14px; |
| } |
| |
| .hero-steps span { |
| border: 1px solid rgba(7, 22, 19, 0.32); |
| border-radius: 999px; |
| background: #fffdf7; |
| color: var(--ink); |
| font-size: 15px; |
| font-weight: 900; |
| padding: 8px 11px; |
| } |
| |
| .hero-proof { |
| display: grid; |
| grid-template-columns: repeat(3, minmax(0, 1fr)); |
| gap: 10px; |
| margin-top: 18px; |
| max-width: 880px; |
| } |
| |
| .hero-proof div { |
| border: 1px solid rgba(7, 22, 19, 0.30); |
| border-radius: 18px; |
| background: #fffdf7; |
| padding: 12px; |
| } |
| |
| .hero-proof b { |
| display: block; |
| color: var(--coral); |
| font-family: Georgia, "Times New Roman", ui-serif, serif; |
| font-size: clamp(22px, 3vw, 30px); |
| letter-spacing: -0.04em; |
| line-height: 0.95; |
| } |
| |
| .hero-proof span { |
| display: block; |
| margin-top: 5px; |
| color: var(--ink); |
| font-size: 15px; |
| font-weight: 850; |
| line-height: 1.42; |
| } |
| |
| .demo-status { |
| display: grid; |
| grid-template-columns: 1.25fr 1fr 1fr; |
| gap: 10px; |
| margin-top: 16px; |
| } |
| |
| .status-card { |
| border: 1px solid rgba(7, 22, 19, 0.32); |
| border-radius: 20px; |
| background: #fff8ea; |
| box-shadow: 0 16px 40px rgba(37, 29, 16, 0.13); |
| padding: 13px 14px; |
| } |
| |
| .status-card b { |
| display: block; |
| color: var(--green-dark); |
| font-size: 14px; |
| letter-spacing: 0.10em; |
| text-transform: uppercase; |
| } |
| |
| .status-card span { |
| display: block; |
| margin-top: 5px; |
| color: var(--muted); |
| font-size: 15px; |
| font-weight: 750; |
| line-height: 1.45; |
| } |
| |
| .model-budget { |
| display: grid; |
| grid-template-columns: 1.2fr repeat(2, minmax(0, 1fr)); |
| gap: 10px; |
| margin-top: 10px; |
| } |
| |
| .budget-card { |
| border: 1px solid rgba(7, 22, 19, 0.34); |
| border-radius: 20px; |
| background: var(--card-solid); |
| padding: 13px 14px; |
| } |
| |
| .budget-card:first-child { |
| background: |
| radial-gradient(circle at top right, rgba(0, 108, 91, 0.16), transparent 46%), |
| var(--card-solid); |
| } |
| |
| .budget-card b { |
| display: block; |
| color: var(--ink); |
| font-size: 14px; |
| font-weight: 900; |
| letter-spacing: 0.08em; |
| text-transform: uppercase; |
| } |
| |
| .budget-card span { |
| display: block; |
| margin-top: 6px; |
| color: var(--muted); |
| font-size: 15px; |
| font-weight: 750; |
| line-height: 1.45; |
| } |
| |
| #main-workspace { |
| gap: 18px; |
| margin-top: 20px; |
| align-items: flex-start; |
| } |
| |
| .input-card, |
| .output-stack { |
| border: 1px solid rgba(7, 22, 19, 0.34); |
| border-radius: 26px; |
| background: var(--card); |
| box-shadow: 0 18px 52px rgba(37, 29, 16, 0.16); |
| padding: clamp(14px, 2vw, 20px); |
| } |
| |
| @media (min-width: 941px) { |
| .input-card { |
| position: sticky; |
| top: 16px; |
| } |
| } |
| |
| .section-title { |
| margin-bottom: 14px; |
| } |
| |
| .section-title h2 { |
| margin: 0; |
| font-family: Georgia, "Times New Roman", ui-serif, serif; |
| color: var(--ink); |
| font-size: 26px; |
| letter-spacing: -0.02em; |
| } |
| |
| .section-title p { |
| margin: 6px 0 0; |
| color: var(--muted); |
| font-size: 16px; |
| font-weight: 750; |
| line-height: 1.5; |
| } |
| |
| .panel { |
| border: 1px solid rgba(7, 22, 19, 0.30); |
| border-radius: 20px; |
| background: #fffef9; |
| box-shadow: none; |
| margin-bottom: 10px; |
| padding: 13px 15px; |
| } |
| |
| .panel h3 { |
| color: var(--green-dark); |
| font-family: Georgia, "Times New Roman", ui-serif, serif; |
| letter-spacing: -0.01em; |
| } |
| |
| .panel h3:first-child { |
| margin-top: 0; |
| } |
| |
| .panel ul, |
| .final-sheet ul { |
| padding-left: 1.15rem; |
| } |
| |
| .panel li, |
| .final-sheet li { |
| margin-bottom: 5px; |
| } |
| |
| .output-stack pre, |
| .output-stack code { |
| max-width: 100% !important; |
| white-space: pre-wrap !important; |
| word-break: break-word !important; |
| } |
| |
| .output-stack pre { |
| overflow-x: auto !important; |
| } |
| |
| .input-card textarea, |
| .input-card input, |
| .input-card select { |
| border-radius: 14px !important; |
| border-color: rgba(7, 22, 19, 0.48) !important; |
| background: var(--field) !important; |
| color: var(--ink) !important; |
| font-size: 16px !important; |
| font-weight: 750 !important; |
| line-height: 1.45 !important; |
| } |
| |
| .input-card label, |
| .input-card .wrap label { |
| color: var(--ink) !important; |
| font-size: 15px !important; |
| font-weight: 900 !important; |
| } |
| |
| .gradio-container input::placeholder, |
| .gradio-container textarea::placeholder { |
| color: #4f625d !important; |
| opacity: 1 !important; |
| } |
| |
| .gradio-container .prose, |
| .gradio-container .markdown, |
| .gradio-container .prose p, |
| .gradio-container .prose li, |
| .gradio-container .prose span, |
| .gradio-container .markdown p, |
| .gradio-container .markdown li, |
| .gradio-container .markdown span { |
| color: var(--ink) !important; |
| font-size: 16px !important; |
| font-weight: 700; |
| line-height: 1.55; |
| } |
| |
| .gradio-container .prose h1, |
| .gradio-container .prose h2, |
| .gradio-container .prose h3, |
| .gradio-container .markdown h1, |
| .gradio-container .markdown h2, |
| .gradio-container .markdown h3 { |
| color: var(--ink) !important; |
| font-weight: 900 !important; |
| } |
| |
| .gradio-container .block-info, |
| .gradio-container .form .secondary-wrap, |
| .gradio-container label span, |
| .gradio-container .wrap span { |
| color: var(--muted) !important; |
| font-size: 14px !important; |
| font-weight: 700 !important; |
| opacity: 1 !important; |
| } |
| |
| .primary-action button, button.primary-action { |
| background: var(--green) !important; |
| border-color: var(--green) !important; |
| border-radius: 16px !important; |
| color: white !important; |
| font-weight: 850 !important; |
| min-height: 46px; |
| box-shadow: 0 12px 28px rgba(0, 108, 91, 0.24); |
| } |
| |
| .primary-action button:hover, button.primary-action:hover { |
| background: var(--green-dark) !important; |
| } |
| |
| .secondary-action button, button.secondary-action { |
| border-color: var(--coral) !important; |
| color: var(--coral) !important; |
| background: #fff7ed !important; |
| border-radius: 16px !important; |
| font-weight: 800 !important; |
| min-height: 46px; |
| } |
| |
| /* Force the cream/light design even when the visitor's device is in dark mode. */ |
| .gradio-container, .gradio-container.dark, .dark { |
| color-scheme: light; |
| --body-background-fill: var(--paper); |
| --background-fill-primary: var(--field); |
| --background-fill-secondary: var(--card-solid); |
| --block-background-fill: var(--card); |
| --block-label-background-fill: var(--card); |
| --block-title-background-fill: var(--card); |
| --input-background-fill: var(--field); |
| --body-text-color: var(--ink); |
| --body-text-color-subdued: var(--muted); |
| --block-label-text-color: var(--ink); |
| --block-title-text-color: var(--ink); |
| --block-info-text-color: var(--muted); |
| --border-color-primary: rgba(7, 22, 19, 0.34); |
| --border-color-accent: rgba(0, 88, 68, 0.34); |
| } |
| .gradio-container.dark .input-card, |
| .gradio-container.dark .output-stack, |
| .dark .input-card, |
| .dark .output-stack { |
| background: var(--card) !important; |
| } |
| |
| #model-note { |
| margin-top: 10px; |
| border-left: 4px solid var(--gold); |
| border-radius: 12px; |
| background: rgba(189, 143, 34, 0.10); |
| padding: 10px 12px; |
| font-size: 15px; |
| font-weight: 800; |
| color: #241800; |
| } |
| |
| .privacy-note { |
| display: flex; |
| gap: 10px; |
| align-items: flex-start; |
| margin-bottom: 14px; |
| border: 1px solid rgba(0, 88, 68, 0.30); |
| border-left: 4px solid var(--green); |
| border-radius: 16px; |
| background: rgba(0, 108, 91, 0.07); |
| padding: 12px 14px; |
| } |
| |
| .privacy-note .lock { |
| font-size: 18px; |
| line-height: 1.35; |
| } |
| |
| .privacy-note b { |
| display: block; |
| color: var(--green-dark); |
| font-size: 13px; |
| font-weight: 900; |
| letter-spacing: 0.08em; |
| text-transform: uppercase; |
| margin-bottom: 3px; |
| } |
| |
| .privacy-note span { |
| display: block; |
| color: var(--muted); |
| font-size: 14px; |
| font-weight: 750; |
| line-height: 1.5; |
| } |
| |
| .rescue-flow { |
| border: 1px solid rgba(7, 22, 19, 0.30); |
| border-radius: 18px; |
| background: |
| radial-gradient(circle at top right, rgba(0, 108, 91, 0.10), transparent 50%), |
| #fffdf7; |
| padding: 13px 15px; |
| margin-bottom: 14px; |
| } |
| |
| .rescue-flow b { |
| display: block; |
| color: var(--green-dark); |
| font-size: 13px; |
| font-weight: 900; |
| letter-spacing: 0.08em; |
| text-transform: uppercase; |
| margin-bottom: 9px; |
| } |
| |
| .rescue-flow ol { |
| margin: 0; |
| padding: 0; |
| list-style: none; |
| counter-reset: rf; |
| display: grid; |
| gap: 7px; |
| } |
| |
| .rescue-flow li { |
| position: relative; |
| padding-left: 30px; |
| color: var(--ink); |
| font-size: 14.5px; |
| font-weight: 800; |
| line-height: 1.4; |
| } |
| |
| .rescue-flow li:before { |
| counter-increment: rf; |
| content: counter(rf); |
| position: absolute; |
| left: 0; |
| top: -1px; |
| width: 21px; |
| height: 21px; |
| border-radius: 999px; |
| background: var(--green); |
| color: #fff; |
| font-size: 12px; |
| font-weight: 900; |
| text-align: center; |
| line-height: 21px; |
| } |
| |
| .coach-hint { |
| margin: 8px 0 2px; |
| color: var(--muted); |
| font-size: 14px; |
| font-weight: 750; |
| line-height: 1.45; |
| } |
| |
| #gen-status .status-busy { |
| border-left: 4px solid var(--gold); |
| border-radius: 12px; |
| background: rgba(189, 143, 34, 0.10); |
| padding: 10px 12px; |
| margin: 8px 0 2px; |
| } |
| |
| @media (prefers-reduced-motion: no-preference) { |
| .status-busy { |
| animation: epr-pulse 1.6s ease-in-out infinite; |
| } |
| |
| @keyframes epr-pulse { |
| 0%, 100% { opacity: 1; } |
| 50% { opacity: 0.55; } |
| } |
| } |
| |
| .wiz-progress { |
| list-style: none; |
| margin: 0 0 14px; |
| padding: 0; |
| display: grid; |
| grid-template-columns: repeat(5, 1fr); |
| gap: 6px; |
| } |
| |
| .wiz-dot { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| gap: 4px; |
| text-align: center; |
| } |
| |
| .wiz-bub { |
| width: 26px; |
| height: 26px; |
| border-radius: 999px; |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-size: 13px; |
| font-weight: 900; |
| border: 1px solid rgba(7, 22, 19, 0.30); |
| background: #fffdf7; |
| color: var(--muted); |
| } |
| |
| .wiz-lab { |
| font-size: 10.5px; |
| font-weight: 850; |
| line-height: 1.2; |
| letter-spacing: 0.02em; |
| color: var(--muted); |
| } |
| |
| .wiz-active .wiz-bub { |
| background: var(--green); |
| border-color: var(--green); |
| color: #fff; |
| box-shadow: 0 6px 16px rgba(0, 108, 91, 0.28); |
| } |
| |
| .wiz-active .wiz-lab { |
| color: var(--green-dark); |
| } |
| |
| .wiz-done .wiz-bub { |
| background: rgba(0, 108, 91, 0.16); |
| border-color: rgba(0, 88, 68, 0.40); |
| color: var(--green-dark); |
| } |
| |
| .wiz-step { |
| border: 1px solid rgba(7, 22, 19, 0.30); |
| border-radius: 18px; |
| background: |
| radial-gradient(circle at top right, rgba(0, 108, 91, 0.08), transparent 50%), |
| #fffdf7; |
| padding: 14px 15px; |
| margin-bottom: 8px; |
| } |
| |
| .wiz-head { |
| display: flex; |
| gap: 10px; |
| align-items: flex-start; |
| margin-bottom: 12px; |
| } |
| |
| .wiz-head .wiz-num { |
| flex: none; |
| width: 24px; |
| height: 24px; |
| border-radius: 999px; |
| background: var(--green); |
| color: #fff; |
| font-size: 13px; |
| font-weight: 900; |
| text-align: center; |
| line-height: 24px; |
| } |
| |
| .wiz-head b { |
| display: block; |
| font-family: Georgia, "Times New Roman", ui-serif, serif; |
| color: var(--ink); |
| font-size: 19px; |
| letter-spacing: -0.01em; |
| } |
| |
| .wiz-head span { |
| display: block; |
| margin-top: 2px; |
| color: var(--muted); |
| font-size: 14px; |
| font-weight: 750; |
| line-height: 1.4; |
| } |
| |
| .wiz-nav { |
| gap: 10px; |
| margin: 6px 0 2px; |
| } |
| |
| .hero-compact { |
| padding: clamp(14px, 2.2vw, 22px); |
| } |
| |
| .hero-compact h1 { |
| font-size: clamp(28px, 4vw, 44px); |
| margin: 10px 0 6px; |
| } |
| |
| .hero-compact p { |
| font-size: clamp(15px, 1.8vw, 18px); |
| } |
| |
| @media (prefers-reduced-motion: no-preference) { |
| .wiz-step { |
| animation: wiz-step-in 280ms ease-out; |
| } |
| |
| @keyframes wiz-step-in { |
| from { opacity: 0; transform: translateY(7px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| |
| .wiz-active .wiz-bub { |
| animation: wiz-dot-pop 320ms ease-out; |
| } |
| |
| @keyframes wiz-dot-pop { |
| 0% { transform: scale(0.7); } |
| 60% { transform: scale(1.14); } |
| 100% { transform: scale(1); } |
| } |
| } |
| |
| @media (max-width: 640px) { |
| .wiz-lab { |
| display: none; |
| } |
| } |
| |
| .runtime-label { |
| margin: 4px 0 -4px; |
| color: var(--green-dark); |
| font-size: 14px; |
| font-weight: 850; |
| letter-spacing: 0.12em; |
| text-transform: uppercase; |
| } |
| |
| .final-sheet { |
| border: 1px solid rgba(7, 22, 19, 0.42); |
| border-radius: 24px; |
| background: |
| radial-gradient(circle at top right, rgba(189, 143, 34, 0.25), transparent 34%), |
| linear-gradient(135deg, rgba(0, 98, 79, 0.13), #fffef9); |
| padding: clamp(16px, 3vw, 24px); |
| color: var(--ink); |
| } |
| |
| .sheet-kicker { |
| color: var(--coral); |
| font-size: 12px; |
| font-weight: 800; |
| letter-spacing: 0.12em; |
| text-transform: uppercase; |
| } |
| |
| .final-sheet h2 { |
| margin: 4px 0 14px; |
| font-family: Georgia, "Times New Roman", ui-serif, serif; |
| font-size: clamp(27px, 4vw, 42px); |
| line-height: 0.98; |
| letter-spacing: -0.045em; |
| } |
| |
| .sheet-grid { |
| display: grid; |
| grid-template-columns: repeat(2, minmax(0, 1fr)); |
| gap: 14px; |
| } |
| |
| .sheet-grid h3 { |
| margin: 0 0 8px; |
| color: var(--blue); |
| font-weight: 900; |
| } |
| |
| .sheet-rule { |
| border-left: 4px solid var(--green); |
| margin: 12px 0 0; |
| padding: 12px 14px; |
| border-radius: 12px; |
| background: rgba(0, 108, 91, 0.09); |
| font-weight: 700; |
| } |
| |
| .sheet-action, |
| .sheet-proof, |
| .sheet-warning { |
| margin: 12px 0 0; |
| padding: 12px 14px; |
| border-radius: 12px; |
| background: rgba(31, 85, 116, 0.10); |
| } |
| |
| .sheet-proof { |
| border: 1px solid rgba(31, 85, 116, 0.20); |
| } |
| |
| .sheet-warning { |
| border: 1px solid rgba(183, 67, 54, 0.24); |
| background: rgba(183, 67, 54, 0.10); |
| } |
| |
| .sheet-footer { |
| margin: 10px 0 0; |
| color: var(--muted); |
| font-size: 15px; |
| font-weight: 750; |
| } |
| |
| .demo-cases { |
| margin-top: 14px; |
| border: 1px dashed rgba(7, 22, 19, 0.36); |
| border-radius: 18px; |
| background: #fffaf0; |
| box-shadow: none; |
| padding: 12px; |
| } |
| |
| .demo-cases h2 { |
| margin: 0 0 6px; |
| font-family: Georgia, "Times New Roman", ui-serif, serif; |
| color: var(--ink); |
| font-size: 25px; |
| letter-spacing: -0.02em; |
| } |
| |
| .demo-cases p { |
| margin: 0 0 12px; |
| color: var(--muted); |
| font-size: 15px; |
| font-weight: 750; |
| } |
| |
| .case-list { |
| gap: 8px; |
| } |
| |
| .case-button button { |
| justify-content: flex-start !important; |
| width: 100%; |
| min-height: 44px; |
| border: 1px solid rgba(0, 88, 68, 0.36) !important; |
| border-radius: 15px !important; |
| background: #fffef9 !important; |
| color: var(--ink) !important; |
| font-size: 15px !important; |
| font-weight: 800 !important; |
| text-align: left !important; |
| } |
| |
| .case-button button:hover { |
| border-color: rgba(0, 108, 91, 0.36) !important; |
| background: rgba(0, 108, 91, 0.08) !important; |
| } |
| |
| .claim-strip { |
| display: grid; |
| grid-template-columns: repeat(3, minmax(0, 1fr)); |
| gap: 12px; |
| margin-top: 14px; |
| } |
| |
| .claim-card { |
| border: 1px solid rgba(7, 22, 19, 0.30); |
| border-radius: 16px; |
| background: #fffef9; |
| padding: 12px; |
| box-shadow: none; |
| } |
| |
| .claim-card b { |
| display: block; |
| color: var(--green-dark); |
| font-size: 14px; |
| letter-spacing: 0.10em; |
| text-transform: uppercase; |
| } |
| |
| .claim-card span { |
| display: block; |
| margin-top: 6px; |
| color: var(--muted); |
| font-size: 15px; |
| font-weight: 750; |
| line-height: 1.42; |
| } |
| |
| .proof-details { |
| margin-top: 18px; |
| border: 1px solid rgba(7, 22, 19, 0.30); |
| border-radius: 20px; |
| background: #fffaf0; |
| padding: 12px 14px; |
| } |
| |
| .proof-details summary { |
| cursor: pointer; |
| color: var(--green-dark); |
| font-size: 15px; |
| font-weight: 900; |
| } |
| |
| .proof-details p { |
| color: var(--muted); |
| font-size: 15px; |
| font-weight: 750; |
| line-height: 1.5; |
| } |
| |
| .hackathon-footer { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 8px; |
| align-items: center; |
| justify-content: center; |
| margin-top: 20px; |
| padding: 14px; |
| border: 1px solid rgba(7, 22, 19, 0.22); |
| border-radius: 18px; |
| background: #fffaf0; |
| } |
| |
| .hackathon-footer span { |
| border: 1px solid rgba(0, 88, 68, 0.30); |
| border-radius: 999px; |
| background: #fffef9; |
| color: var(--green-dark); |
| font-size: 13px; |
| font-weight: 850; |
| letter-spacing: 0.04em; |
| padding: 6px 12px; |
| } |
| |
| .runtime-note-tag { |
| display: inline-block; |
| margin: 0 0 6px; |
| color: var(--green-dark); |
| font-size: 13px; |
| font-weight: 850; |
| letter-spacing: 0.06em; |
| text-transform: uppercase; |
| } |
| |
| .coach-card { |
| border: 1px solid rgba(7, 22, 19, 0.30); |
| border-radius: 18px; |
| background: #fffef9; |
| padding: 14px 16px; |
| margin-bottom: 10px; |
| } |
| |
| .coach-idle { |
| color: var(--muted); |
| font-size: 15px; |
| font-weight: 750; |
| line-height: 1.45; |
| } |
| |
| .coach-live { |
| background: radial-gradient(circle at top right, rgba(0, 108, 91, 0.16), transparent 50%), #fffef9; |
| border-color: rgba(0, 88, 68, 0.42); |
| } |
| |
| .coach-now { |
| color: var(--green-dark); |
| font-size: 18px; |
| font-weight: 900; |
| letter-spacing: 0.02em; |
| } |
| |
| .coach-time { |
| font-family: Georgia, "Times New Roman", ui-serif, serif; |
| color: var(--coral); |
| font-size: clamp(34px, 6vw, 46px); |
| line-height: 1.05; |
| letter-spacing: -0.03em; |
| margin: 2px 0; |
| } |
| |
| .coach-next { |
| color: var(--muted); |
| font-size: 14px; |
| font-weight: 800; |
| } |
| |
| .coach-action, |
| .coach-proof { |
| margin-top: 8px; |
| color: var(--ink); |
| font-size: 15px; |
| font-weight: 800; |
| line-height: 1.45; |
| } |
| |
| .coach-proof { |
| border-left: 4px solid var(--gold); |
| border-radius: 10px; |
| background: rgba(189, 143, 34, 0.10); |
| padding: 9px 11px; |
| } |
| |
| .coach-done { |
| background: rgba(0, 108, 91, 0.12); |
| border-color: rgba(0, 88, 68, 0.42); |
| color: var(--green-dark); |
| font-size: 16px; |
| font-weight: 850; |
| } |
| |
| @media (prefers-reduced-motion: no-preference) { |
| .primary-action button, |
| .secondary-action button { |
| transition: transform 150ms ease-out, background-color 150ms ease-out, box-shadow 150ms ease-out; |
| } |
| |
| .primary-action button:hover, |
| .secondary-action button:hover { |
| transform: translateY(-1px); |
| } |
| } |
| |
| @media (max-width: 940px) { |
| .hero { |
| grid-template-columns: 1fr; |
| } |
| |
| .demo-status, |
| .model-budget { |
| grid-template-columns: 1fr; |
| } |
| |
| #main-workspace { |
| flex-direction: column !important; |
| } |
| |
| #main-workspace > .column, |
| #main-workspace > div { |
| width: 100% !important; |
| min-width: 100% !important; |
| } |
| } |
| |
| @media (max-width: 640px) { |
| .app-shell { |
| padding: 12px 10px 24px; |
| } |
| |
| .hero { |
| border-radius: 22px; |
| padding: 18px; |
| } |
| |
| .hero-steps span { |
| width: 100%; |
| } |
| |
| .hero-proof { |
| grid-template-columns: 1fr; |
| } |
| |
| .input-card, |
| .output-stack { |
| border-radius: 20px; |
| padding: 12px; |
| } |
| |
| .sheet-grid { |
| grid-template-columns: 1fr; |
| } |
| |
| .claim-strip { |
| grid-template-columns: 1fr; |
| } |
| |
| .primary-action, |
| .secondary-action { |
| flex: 1 1 100%; |
| } |
| } |
| |
| /* ----- Output tabs: Live Coach / Rescue Plan / Final Sheet / Resources ----- |
| Selectors are deliberately doubled (Gradio class names + ARIA roles) so the |
| cream/green styling lands regardless of the exact Gradio 6 tab DOM. */ |
| #output-tabs { |
| border: none; |
| margin-top: 2px; |
| } |
| |
| #output-tabs > .tab-nav, |
| #output-tabs [role="tablist"] { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 7px; |
| border: none !important; |
| margin-bottom: 12px; |
| } |
| |
| #output-tabs .tab-nav button, |
| #output-tabs button[role="tab"] { |
| border: 1px solid rgba(7, 22, 19, 0.30) !important; |
| border-radius: 14px !important; |
| background: #fffdf7 !important; |
| color: var(--muted) !important; |
| font-size: 14.5px !important; |
| font-weight: 850 !important; |
| padding: 8px 14px !important; |
| min-height: 40px; |
| } |
| |
| #output-tabs .tab-nav button:hover, |
| #output-tabs button[role="tab"]:hover { |
| border-color: rgba(0, 108, 91, 0.40) !important; |
| background: rgba(0, 108, 91, 0.07) !important; |
| } |
| |
| #output-tabs .tab-nav button.selected, |
| #output-tabs button[role="tab"][aria-selected="true"] { |
| background: var(--green) !important; |
| border-color: var(--green) !important; |
| color: #fff !important; |
| box-shadow: 0 8px 18px rgba(0, 108, 91, 0.24); |
| } |
| |
| #output-tabs .tabitem, |
| #output-tabs [role="tabpanel"] { |
| padding-top: 6px; |
| } |
| |
| @media (prefers-reduced-motion: no-preference) { |
| #output-tabs .tabitem, |
| #output-tabs [role="tabpanel"] { |
| animation: epr-tab-in 240ms ease-out; |
| } |
| |
| @keyframes epr-tab-in { |
| from { opacity: 0; transform: translateY(5px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| } |
| |
| /* ----- Clearer wizard step states (active stands out, done is checked) ----- */ |
| .wiz-active .wiz-bub { |
| box-shadow: 0 0 0 4px rgba(0, 108, 91, 0.18), 0 8px 18px rgba(0, 108, 91, 0.30); |
| } |
| |
| .wiz-active .wiz-lab { |
| color: var(--green-dark); |
| font-weight: 900; |
| } |
| |
| .wiz-done .wiz-bub { |
| background: rgba(0, 108, 91, 0.18); |
| border-color: rgba(0, 88, 68, 0.55); |
| color: var(--green-dark); |
| } |
| |
| .wiz-todo .wiz-bub { |
| opacity: 0.78; |
| } |
| """ |
|
|
|
|
| HERO_HTML = """ |
| <section class="hero hero-compact"> |
| <div> |
| <div class="eyebrow">Exam Panic Rescue</div> |
| <h1>When time is low, stop rereading everything.</h1> |
| <p>Five guided steps: tell us the exam, share what you have (or a photo), and get a ranked plan, drills, and a live coach.</p> |
| </div> |
| </section> |
| """ |
|
|
|
|
| CLAIM_STATUS_HTML = """ |
| <details class="proof-details"> |
| <summary>Hackathon build proof and claim status</summary> |
| <p><strong>How to review fast:</strong> load a sample scenario only to understand the flow, replace it with real exam details when using the product, build the rescue packet, then check the proof target/final sheet and runtime note.</p> |
| <section class="claim-strip" aria-label="Public claim status"> |
| <div class="claim-card"> |
| <b>Claim now</b> |
| <span>Backyard AI main track, OpenBMB MiniCPM on ZeroGPU, OpenAI Codex evidence, and Off-Brand custom UI.</span> |
| </div> |
| <div class="claim-card"> |
| <b>Claim after links</b> |
| <span>Best Demo, Community Choice, and Sharing-style build trace once the public video/social links exist.</span> |
| </div> |
| <div class="claim-card"> |
| <b>Do not claim yet</b> |
| <span>Modal, Nemotron, Tiny Titan, fine-tuning, or Best Agent unless matching evidence exists.</span> |
| </div> |
| </section> |
| <section class="model-budget" aria-label="Runtime claim status"> |
| <div class="budget-card"><b>Model budget</b><span>MiniCPM-V 4.6 fits the <=32B rule; hardware is the real gate.</span></div> |
| <div class="budget-card"><b>ZeroGPU verified</b><span>Live Space smoke generated with MiniCPM on CUDA/ZeroGPU; keep calls focused inside quota.</span></div> |
| <div class="budget-card"><b>Default target</b><span>OpenBMB MiniCPM stays the submission-aligned model path when hardware can run it.</span></div> |
| </section> |
| </details> |
| """ |
|
|
|
|
| FOOTER_HTML = """ |
| <footer class="hackathon-footer"> |
| <span>Built for the Build Small Hackathon</span> |
| <span>Backyard AI track</span> |
| <span>OpenBMB MiniCPM · ≤32B</span> |
| <span>Runs as a Gradio Space on Hugging Face</span> |
| </footer> |
| """ |
|
|
|
|
| @spaces.GPU(duration=120) |
| def _gpu_build_plan( |
| student_name: str, |
| subject: str, |
| time_left_minutes: int, |
| exam_format: str, |
| panic_note: str, |
| known_material: str, |
| confidence: int, |
| model_choice: str = "openbmb/MiniCPM-V-4.6", |
| image_path: str | None = None, |
| ): |
| return build_rescue_plan( |
| student_name, |
| subject, |
| time_left_minutes, |
| exam_format, |
| panic_note, |
| known_material, |
| confidence, |
| model_id=model_choice, |
| image_path=image_path, |
| ) |
|
|
|
|
| def generate( |
| student_name: str, |
| subject: str, |
| time_left_minutes: int, |
| exam_format: str, |
| panic_note: str, |
| known_material: str, |
| confidence: int, |
| model_choice: str = "openbmb/MiniCPM-V-4.6", |
| image_path: str | None = None, |
| ): |
| |
| ensure_weights(model_choice) |
| rewarm_after = False |
| if not _is_default_vlm(model_choice) and "gguf" not in (model_choice or "").lower(): |
| |
| |
| _clear_vram_for_other_model() |
| rewarm_after = True |
| if "gguf" in (model_choice or "").lower(): |
| |
| |
| plan = build_rescue_plan( |
| student_name, |
| subject, |
| time_left_minutes, |
| exam_format, |
| panic_note, |
| known_material, |
| confidence, |
| model_id=model_choice, |
| image_path=image_path, |
| ) |
| return ( |
| plan.rescue_plan_markdown, |
| plan.drill_markdown, |
| plan.triage_markdown, |
| plan.final_sheet_html, |
| plan.demo_receipt_markdown, |
| plan.model_note, |
| ) |
| try: |
| plan = _gpu_build_plan( |
| student_name, |
| subject, |
| time_left_minutes, |
| exam_format, |
| panic_note, |
| known_material, |
| confidence, |
| model_choice, |
| image_path, |
| ) |
| except Exception as exc: |
| |
| |
| |
| |
| plan = build_rescue_plan( |
| student_name, |
| subject, |
| time_left_minutes, |
| exam_format, |
| panic_note, |
| known_material, |
| confidence, |
| force_fallback=True, |
| ) |
| reason = classify_gpu_failure(exc) |
| note = f"{plan.model_note} {reason}".strip() if reason else plan.model_note |
| if rewarm_after: |
| _rewarm_default_vlm() |
| return ( |
| plan.rescue_plan_markdown, |
| plan.drill_markdown, |
| plan.triage_markdown, |
| plan.final_sheet_html, |
| plan.demo_receipt_markdown, |
| note, |
| ) |
| if rewarm_after: |
| _rewarm_default_vlm() |
| return ( |
| plan.rescue_plan_markdown, |
| plan.drill_markdown, |
| plan.triage_markdown, |
| plan.final_sheet_html, |
| plan.demo_receipt_markdown, |
| plan.model_note, |
| ) |
|
|
|
|
| @spaces.GPU(duration=120) |
| def _gpu_extract_from_photo(image_path): |
| return extract_topics_from_image(image_path) |
|
|
|
|
| def extract_from_photo(image_path): |
| if not image_path: |
| return gr.update(), "Upload a photo first, or just type your topics." |
| ensure_weights(VISION_MODEL_ID) |
| try: |
| topics, note = _gpu_extract_from_photo(image_path) |
| except Exception: |
| return gr.update(), "Photo reading is busy right now (GPU hiccup). Try again, or just type your topics in." |
| if topics: |
| return topics, note |
| return gr.update(), note |
|
|
|
|
| def download_packet(rescue, drill, triage, final_sheet_html, receipt): |
| md = packet_to_markdown(rescue, drill, triage, final_sheet_html, receipt) |
| path = os.path.join(tempfile.gettempdir(), "exam-panic-rescue-packet.md") |
| with open(path, "w", encoding="utf-8") as handle: |
| handle.write(md) |
| return path |
|
|
|
|
| @spaces.GPU(duration=120) |
| def _gpu_show_answers(drill_markdown, subject, model_choice): |
| answers, _note = answer_drills(drill_markdown, subject, model_choice) |
| return answers |
|
|
|
|
| def show_answers(drill_markdown, subject, model_choice="openbmb/MiniCPM-V-4.6"): |
| ensure_weights(model_choice) |
| rewarm_after = False |
| if not _is_default_vlm(model_choice) and "gguf" not in (model_choice or "").lower(): |
| _clear_vram_for_other_model() |
| rewarm_after = True |
| try: |
| return _gpu_show_answers(drill_markdown, subject, model_choice) |
| except Exception: |
| return "### Worked answers\n\nThe model is busy right now (GPU hiccup). Try again in a moment — or self-check each drill against your notes and mark it right or wrong." |
| finally: |
| if rewarm_after: |
| _rewarm_default_vlm() |
|
|
|
|
| def load_example(): |
| return ( |
| EXAMPLE_INPUT["student_name"], |
| EXAMPLE_INPUT["subject"], |
| EXAMPLE_INPUT["time_left_minutes"], |
| EXAMPLE_INPUT["exam_format"], |
| EXAMPLE_INPUT["panic_note"], |
| EXAMPLE_INPUT["known_material"], |
| EXAMPLE_INPUT["confidence"], |
| ) |
|
|
|
|
| def load_case(index: int): |
| case = DEMO_CASES[index] |
| return ( |
| case["student_name"], |
| case["subject"], |
| case["time_left_minutes"], |
| case["exam_format"], |
| case["panic_note"], |
| case["known_material"], |
| case["confidence"], |
| ) |
|
|
|
|
| def load_biology_case(): |
| return load_case(0) |
|
|
|
|
| def load_physics_case(): |
| return load_case(1) |
|
|
|
|
| def load_history_case(): |
| return load_case(2) |
|
|
|
|
| def load_math_case(): |
| return load_case(3) |
|
|
|
|
| CASE_LOADERS = [load_biology_case, load_physics_case, load_history_case, load_math_case] |
|
|
|
|
| WIZARD_LABELS = [ |
| "Tell us the exam", |
| "Share what you have", |
| "Scan your image", |
| "Generate plan", |
| "Live coach", |
| ] |
|
|
|
|
| def wizard_progress_html(step: int) -> str: |
| """Render the step progress bar plus the CSS that shows only the current step. |
| |
| The show/hide is done with CSS (re-emitted on every step change) instead of Gradio |
| visible= updates: all five step columns stay mounted, which keeps field values warm |
| and swaps steps instantly and reliably. |
| """ |
| current = max(1, min(len(WIZARD_LABELS), int(step or 1))) |
| dots = [] |
| for index, label in enumerate(WIZARD_LABELS, start=1): |
| state = "wiz-done" if index < current else ("wiz-active" if index == current else "wiz-todo") |
| glyph = "✓" if state == "wiz-done" else str(index) |
| aria = ' aria-current="step"' if state == "wiz-active" else "" |
| dots.append( |
| f'<li class="wiz-dot {state}"{aria}><span class="wiz-bub">{glyph}</span>' |
| f'<span class="wiz-lab">{label}</span></li>' |
| ) |
| hidden = ", ".join(f"#wiz-step-{i}" for i in range(1, len(WIZARD_LABELS) + 1) if i != current) |
| style = ( |
| f"<style>{hidden} {{ display: none !important; }} " |
| f"#wiz-step-{current} {{ display: flex !important; }}</style>" |
| ) |
| return style + '<ol class="wiz-progress" aria-label="Rescue flow progress">' + "".join(dots) + "</ol>" |
|
|
|
|
| def _boot_warmup() -> None: |
| """Warm the default model before the first student clicks. |
| |
| Downloads the MiniCPM-V 4.6 weights and loads them resident (the documented |
| ZeroGPU main-process pattern). Runs in a daemon thread at startup so the UI is |
| never blocked; on CPU-only or local runs every step is a guarded no-op. This |
| removes the silent 60-90 s first-click penalty that previously followed every |
| Space rebuild. |
| """ |
| try: |
| ensure_weights(DEFAULT_MODEL_ID) |
| load_resident_vlm(DEFAULT_MODEL_ID) |
| except Exception: |
| pass |
|
|
|
|
| threading.Thread(target=_boot_warmup, name="boot-warmup", daemon=True).start() |
|
|
|
|
| def _is_default_vlm(model_id: str) -> bool: |
| return "minicpm-v" in (model_id or "").lower() |
|
|
|
|
| def _clear_vram_for_other_model() -> None: |
| """Evict the resident MiniCPM-V in the MAIN process, before a GPU window opens. |
| |
| ZeroGPU pages main-process CUDA tensors into every @spaces.GPU window, so an |
| eviction that happens inside the worker (engine-level) is too late: the 16 GB |
| VLM still gets paged in alongside the alternate Nemotron pipeline and can starve |
| or stall the call. Freeing here, in the main process, is what actually releases it. |
| """ |
| free_resident_vlm() |
|
|
|
|
| def _rewarm_default_vlm() -> None: |
| """Re-load the resident MiniCPM-V in the background after another model ran.""" |
| threading.Thread( |
| target=load_resident_vlm, args=(DEFAULT_MODEL_ID,), name="vlm-rewarm", daemon=True |
| ).start() |
|
|
|
|
| GEN_BUSY_HTML = ( |
| '<p class="coach-hint status-busy">⏳ <b>Working…</b> normally ~10–25 s once warm; the first ' |
| 'run right after a restart can take longer while the small model wakes up. ' |
| 'Elapsed: <span class="epr-elapsed">0s</span>. Your packet appears in the results panel.</p>' |
| ) |
|
|
|
|
| def _gen_busy(): |
| return GEN_BUSY_HTML |
|
|
|
|
| def _gen_done(): |
| return "" |
|
|
|
|
| def _vision_busy(): |
| return "⏳ Reading your photo with MiniCPM-V 4.6 — usually well under a minute when the model is warm." |
|
|
|
|
| def _answers_busy(): |
| return "### Worked answers\n\n⏳ Writing worked answers with the selected small model…" |
|
|
|
|
| def _plain_text(value) -> str: |
| text = re.sub(r"<[^>]+>", " ", str(value or "")) |
| return re.sub(r"\s+", " ", text).strip() |
|
|
|
|
| def _triage_item(triage_markdown, label: str) -> str: |
| pattern = rf"-\s*{re.escape(label)}:\s*(.+)" |
| match = re.search(pattern, str(triage_markdown or ""), re.IGNORECASE) |
| return match.group(1).strip() if match else "" |
|
|
|
|
| def _first_csv_item(value: str) -> str: |
| return next((item.strip() for item in value.split(",") if item.strip()), "") |
|
|
|
|
| def _coach_instruction(label: str, triage_markdown, final_sheet_html) -> tuple[str, str]: |
| current = (label or "").lower() |
| proof = _triage_item(triage_markdown, "Proof target") |
| first_topic = _first_csv_item(_triage_item(triage_markdown, "Topics extracted")) |
| first_leak = _first_csv_item(_triage_item(triage_markdown, "Weaknesses to attack")) |
| topic = first_topic or "your first protected mark" |
| leak = first_leak or "one visible weak spot" |
|
|
| if "reset" in current: |
| action = f"Write what you remember about {topic}, circle {leak}, then commit to the first drill." |
| stop = proof or "Stop this block once the first target is chosen." |
| elif "core" in current or "recall" in current: |
| action = f"Protect the marks you already know. Rebuild {topic} from memory before checking notes." |
| stop = "Stop when you can explain the first protected topic without reopening every chapter." |
| elif "drill" in current or "practice" in current or "quick test" in current: |
| action = "Answer the drills without notes first. Mark mistakes instead of hiding them." |
| stop = proof or "Stop when one drill is answered and one mistake is repaired." |
| elif "patch" in current or "weak" in current or "second" in current: |
| action = f"Patch {leak}. Repair one mistake, then rerun the same style of question." |
| stop = "Stop when the mistake has a corrected version you can repeat." |
| elif "break" in current: |
| action = "Take the break. Stand up, drink water, and do not open a new topic." |
| stop = "Return when the timer moves to the next block." |
| elif "final" in current: |
| action = "Read only the final sheet. No new chapters, no new videos, no last-minute syllabus expansion." |
| stop = "Stop when the final sheet has been read once calmly." |
| else: |
| action = _plain_text(final_sheet_html)[:180] or "Follow the current block from your triage clock." |
| stop = proof or "Move on when the timer changes blocks." |
| return action, stop |
|
|
|
|
| def _coach_card(state: dict, triage_markdown, final_sheet_html, panic_until=None) -> str: |
| if panic_until and time.time() < float(panic_until): |
| remaining = max(0, int(round(float(panic_until) - time.time()))) |
| return ( |
| '<div class="coach-card coach-live">' |
| '<div class="coach-now">Panic reset</div>' |
| f'<div class="coach-time">00:{remaining:02d}</div>' |
| '<div class="coach-action">Put both feet down, unclench your jaw, breathe out slowly, then name the next tiny action.</div>' |
| '<div class="coach-proof">When this ends, return to the same block. Do not restart the whole plan.</div>' |
| "</div>" |
| ) |
| if state["done"]: |
| return '<div class="coach-card coach-done">Time is up. Read only your final sheet, then walk in.</div>' |
| rs = state["remaining_s"] |
| nxt = f'Next: {state["next"]}' if state["next"] else "Last block: finish with the final sheet" |
| action, stop = _coach_instruction(state["current"], triage_markdown, final_sheet_html) |
| return ( |
| '<div class="coach-card coach-live">' |
| f'<div class="coach-now">Now: {escape(str(state["current"]))}</div>' |
| f'<div class="coach-time">{rs // 60:02d}:{rs % 60:02d} left</div>' |
| f'<div class="coach-next">{escape(nxt)} · block {state["index"] + 1}/{state["count"]}</div>' |
| f'<div class="coach-action"><b>Do now:</b> {escape(action)}</div>' |
| f'<div class="coach-proof"><b>Proof/stop:</b> {escape(stop)}</div>' |
| "</div>" |
| ) |
|
|
|
|
| def _advance_elapsed_to_next_block(minutes, elapsed_seconds: float) -> float: |
| blocks = [(label, mins) for label, mins in time_blocks(int(minutes or 60)) if mins > 0] |
| elapsed_min = max(0.0, elapsed_seconds) / 60.0 |
| acc = 0.0 |
| for _, block_minutes in blocks: |
| block_end = acc + block_minutes |
| if elapsed_min < block_end: |
| return block_end * 60 |
| acc = block_end |
| return sum(block_minutes for _, block_minutes in blocks) * 60 |
|
|
|
|
|
|
|
|
|
|
| |
| |
| |
| |
| APP_JS = """ |
| () => { |
| try { |
| const url = new URL(window.location.href); |
| if (url.searchParams.get('__theme') !== 'light') { |
| url.searchParams.set('__theme', 'light'); |
| window.location.replace(url.toString()); |
| } |
| } catch (e) {} |
| try { |
| let tick = null; |
| let lastStep = ''; |
| const ensureTicker = () => { |
| const els = document.querySelectorAll('.epr-elapsed'); |
| els.forEach((el) => { if (!el.dataset.t0) el.dataset.t0 = String(Date.now()); }); |
| if (els.length && !tick) { |
| tick = setInterval(() => { |
| const live = document.querySelectorAll('.epr-elapsed'); |
| if (!live.length) { clearInterval(tick); tick = null; return; } |
| live.forEach((el) => { |
| el.textContent = Math.round((Date.now() - Number(el.dataset.t0)) / 1000) + 's'; |
| }); |
| }, 1000); |
| } |
| }; |
| const onStepChange = () => { |
| const active = document.querySelector('.wiz-dot.wiz-active .wiz-lab'); |
| const label = active ? active.textContent : ''; |
| if (label && lastStep && label !== lastStep && label !== 'Live coach') { |
| const card = document.querySelector('.input-card'); |
| if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
| } |
| if (label) lastStep = label; |
| }; |
| const mo = new MutationObserver(() => { ensureTicker(); onStepChange(); }); |
| mo.observe(document.body, { childList: true, subtree: true }); |
| } catch (e) {} |
| } |
| """ |
|
|
|
|
| |
| with gr.Blocks(title="Exam Panic Rescue") as demo: |
| gr.HTML(f"<style>{CSS}</style>", container=False) |
| with gr.Column(elem_classes=["app-shell"]): |
| gr.HTML(HERO_HTML, container=False) |
|
|
| with gr.Row(equal_height=False, elem_id="main-workspace"): |
| with gr.Column(scale=5, min_width=320, elem_classes=["input-card"]): |
| gr.HTML( |
| """ |
| <div class="section-title"> |
| <h2>Build your rescue packet</h2> |
| <p>Five quick steps. The flow moves with you.</p> |
| </div> |
| """, |
| container=False, |
| ) |
| wizard_step = gr.State(1) |
| wiz_progress = gr.HTML(wizard_progress_html(1), container=False) |
|
|
| with gr.Column(elem_id="wiz-step-1", elem_classes=["wiz-step"]) as step1_col: |
| gr.HTML( |
| """ |
| <div class="wiz-head"> |
| <span class="wiz-num">1</span> |
| <div><b>Tell us the exam</b><span>Name, subject, time left.</span></div> |
| </div> |
| """, |
| container=False, |
| ) |
| student_name = gr.Textbox( |
| label="Student", |
| value=EXAMPLE_INPUT["student_name"], |
| lines=1, |
| info=None, |
| ) |
| subject = gr.Textbox( |
| label="Exam subject", |
| value=EXAMPLE_INPUT["subject"], |
| lines=2, |
| info=None, |
| ) |
| with gr.Row(): |
| exam_format = gr.Dropdown( |
| label="Exam format", |
| choices=["Mixed", "Multiple choice", "Short answer", "Long answer"], |
| value=EXAMPLE_INPUT["exam_format"], |
| info=None, |
| ) |
| confidence = gr.Slider( |
| label="Confidence", |
| minimum=1, |
| maximum=5, |
| value=EXAMPLE_INPUT["confidence"], |
| step=1, |
| info="1 = frozen · 5 = steady.", |
| ) |
| time_left_minutes = gr.Slider( |
| label="Minutes left", |
| minimum=15, |
| maximum=1440, |
| value=EXAMPLE_INPUT["time_left_minutes"], |
| step=15, |
| info="15 min – full day.", |
| ) |
| example = gr.Button("Load example", elem_classes=["secondary-action"]) |
|
|
| with gr.Column(elem_id="wiz-step-2", elem_classes=["wiz-step"]) as step2_col: |
| gr.HTML( |
| """ |
| <div class="wiz-head"> |
| <span class="wiz-num">2</span> |
| <div><b>Share what you have</b><span>Panic dump, topics — or a photo to scan next.</span></div> |
| </div> |
| """, |
| container=False, |
| ) |
| gr.HTML( |
| """ |
| <div class="privacy-note"> |
| <div class="lock" aria-hidden="true">🔒</div> |
| <div> |
| <b>Privacy-first note</b> |
| <span>This hosted Space is a public demo. The app does not intentionally store normal user sessions or add them to the public trace dataset. For sensitive use, run the repo locally with the small-model path.</span> |
| </div> |
| </div> |
| """, |
| container=False, |
| ) |
| panic_note = gr.Textbox( |
| label="Panic dump", |
| value=EXAMPLE_INPUT["panic_note"], |
| lines=5, |
| info=None, |
| ) |
| known_material = gr.Textbox( |
| label="Syllabus, notes, or weak topics", |
| value=EXAMPLE_INPUT["known_material"], |
| lines=5, |
| info=None, |
| ) |
| with gr.Accordion("📷 Upload syllabus / exam sheet / notes photo", open=True): |
| syllabus_image = gr.Image( |
| label="Syllabus, exam sheet, textbook page, or notes", |
| type="filepath", |
| height=180, |
| ) |
| gr.Markdown( |
| "MiniCPM-V 4.6 reads the image, extracts visible topics, and uses them to build the rescue plan." |
| ) |
|
|
| with gr.Column(elem_id="wiz-step-3", elem_classes=["wiz-step"]) as step3_col: |
| gr.HTML( |
| """ |
| <div class="wiz-head"> |
| <span class="wiz-num">3</span> |
| <div><b>Scan your image</b><span>Optional — pull the photo's topics into your inputs.</span></div> |
| </div> |
| """, |
| container=False, |
| ) |
| extract_btn = gr.Button("Extract topics from photo", elem_classes=["secondary-action"]) |
| vision_note = gr.Markdown( |
| "Upload a photo in Step 2, then tap Extract — MiniCPM-V 4.6 reads it and fills your topics box. Always check what it found." |
| ) |
|
|
| with gr.Column(elem_id="wiz-step-4", elem_classes=["wiz-step"]) as step4_col: |
| gr.HTML( |
| """ |
| <div class="wiz-head"> |
| <span class="wiz-num">4</span> |
| <div><b>Generate your rescue plan</b><span>Build the packet. Topics from your photo are already included.</span></div> |
| </div> |
| """, |
| container=False, |
| ) |
| with gr.Accordion("⚙️ Advanced: choose the small model", open=False): |
| model_choice = gr.Dropdown( |
| label="Generation model (all ≤32B)", |
| choices=[ |
| ("OpenBMB MiniCPM-V 4.6 — default (reads text + your photo)", "openbmb/MiniCPM-V-4.6"), |
| ("NVIDIA Nemotron-Mini-4B (≤4B · Tiny Titan)", "nvidia/Nemotron-Mini-4B-Instruct"), |
| ("OpenBMB MiniCPM4 0.5B · llama.cpp GGUF (CPU runtime · Tiny Titan)", "openbmb/MiniCPM4-0.5B-QAT-Int4-GGUF"), |
| ], |
| value="openbmb/MiniCPM-V-4.6", |
| info="MiniCPM-V 4.6 writes your plan and can read your syllabus photo. Nemotron-4B is a text-only alternate. The 0.5B option runs through the llama.cpp runtime on CPU. The runtime note shows exactly what ran.", |
| ) |
| run = gr.Button("Build my rescue packet", variant="primary", elem_classes=["primary-action"]) |
| gen_status = gr.HTML("", elem_id="gen-status", container=False) |
|
|
| with gr.Column(elem_id="wiz-step-5", elem_classes=["wiz-step"]) as step5_col: |
| gr.HTML( |
| """ |
| <div class="wiz-head"> |
| <span class="wiz-num">5</span> |
| <div><b>Follow with the live coach</b><span>Your plan is ready — the coach walks you through it.</span></div> |
| </div> |
| """, |
| container=False, |
| ) |
| gr.HTML( |
| '<p class="coach-hint">After generating your rescue plan, start the live coach to walk through the time blocks step by step. Open the <b>Live Coach</b> tab in the results panel →</p>', |
| container=False, |
| ) |
|
|
| with gr.Row(elem_classes=["wiz-nav"]): |
| back_btn = gr.Button("← Back", elem_classes=["secondary-action"], interactive=False) |
| next_btn = gr.Button("Next: Share what you have →", elem_classes=["primary-action"]) |
|
|
| inputs = [student_name, subject, time_left_minutes, exam_format, panic_note, known_material, confidence] |
|
|
| with gr.Column(elem_classes=["demo-cases"]): |
| gr.HTML( |
| """ |
| <h2>Try a sample scenario</h2> |
| <p>One click fills every step (demo data, not real users).</p> |
| """, |
| container=False, |
| ) |
| case_buttons = [] |
| with gr.Column(elem_classes=["case-list"]): |
| for index, case in enumerate(DEMO_CASES): |
| label = f"{case['name'].title()} · {case['time_left_minutes']} min · {case['exam_format']}" |
| case_buttons.append( |
| ( |
| gr.Button( |
| label, |
| size="lg", |
| elem_classes=["case-button"], |
| ), |
| index, |
| ) |
| ) |
|
|
| with gr.Column(scale=7, min_width=340, elem_classes=["output-stack"]): |
| gr.HTML( |
| """ |
| <div class="section-title"> |
| <h2>Your low-time learning packet</h2> |
| <p>Four tabs: Live Coach, Rescue Plan, Final Sheet, Resources. Your plan opens automatically.</p> |
| </div> |
| """, |
| container=False, |
| ) |
| |
| |
| coach_start = gr.State(None) |
| coach_panic_until = gr.State(None) |
| coach_timer = gr.Timer(1.0, active=False) |
|
|
| with gr.Tabs(elem_id="output-tabs") as output_tabs: |
| with gr.Tab("Live coach", id="coach"): |
| gr.HTML('<div class="runtime-label">Live coach</div>', container=False) |
| coach_display = gr.HTML( |
| value='<div class="coach-card coach-idle">Build a packet, then press <b>Start coaching</b>. The coach gives the next action, proof target, and timer for each block.</div>', |
| container=False, |
| ) |
| with gr.Row(): |
| coach_start_btn = gr.Button("Start coaching", elem_classes=["primary-action"]) |
| coach_done_btn = gr.Button("I finished this block", elem_classes=["secondary-action"]) |
| panic_reset_btn = gr.Button("20-sec panic reset", elem_classes=["secondary-action"]) |
| coach_reset_btn = gr.Button("Reset", elem_classes=["secondary-action"]) |
| gr.HTML( |
| '<p class="coach-hint">After generating your rescue plan, start the coach. It keeps the current action, proof target, and next block visible while you study.</p>', |
| container=False, |
| ) |
|
|
| with gr.Tab("Rescue plan", id="plan"): |
| rescue_output = gr.Markdown( |
| value="### Rescue plan\n\nAppears after you build the packet (step 4).", |
| elem_classes=["panel"], |
| ) |
| drill_output = gr.Markdown( |
| value="### Drill deck\n\nFive drills land here after generation.", |
| elem_classes=["panel"], |
| ) |
| answers_btn = gr.Button("Show worked answers", elem_classes=["secondary-action"]) |
| answers_output = gr.Markdown( |
| value="### Worked answers\n\nReveal model-written answers after you build a packet.", |
| elem_classes=["panel"], |
| ) |
|
|
| with gr.Tab("Final sheet", id="sheet"): |
| triage_output = gr.Markdown( |
| value="### Triage clock\n\nYour time blocks land here.", |
| elem_classes=["panel"], |
| ) |
| final_sheet_output = gr.HTML( |
| value='<div class="final-sheet"><h3>Final sheet</h3><p>Build a packet to create the one-page sheet to read before the exam.</p></div>', |
| elem_classes=["panel"], |
| ) |
| download_btn = gr.DownloadButton("⬇ Download / print", elem_classes=["secondary-action"]) |
|
|
| with gr.Tab("Resources", id="resources"): |
| demo_receipt_output = gr.Markdown( |
| value="### Study receipt\n\nA short before/after receipt.", |
| elem_classes=["panel"], |
| ) |
| gr.HTML('<div class="runtime-label">Runtime note</div>', container=False) |
| model_note = gr.Markdown( |
| value="No generation yet. This note always reports exactly which small model ran (default OpenBMB MiniCPM-V 4.6 on ZeroGPU) or why a fallback was used.", |
| elem_id="model-note", |
| ) |
| gr.HTML(CLAIM_STATUS_HTML, container=False) |
|
|
| outputs = [ |
| rescue_output, |
| drill_output, |
| triage_output, |
| final_sheet_output, |
| demo_receipt_output, |
| model_note, |
| ] |
| gr.HTML(FOOTER_HTML, container=False) |
| demo.load(js=APP_JS) |
|
|
| def _start_coach(minutes, triage_markdown, final_sheet_html): |
| start_ts = time.time() |
| state = coach_state(time_blocks(int(minutes or 60)), 0) |
| return start_ts, None, gr.Timer(active=True), _coach_card(state, triage_markdown, final_sheet_html) |
|
|
| def _reset_coach(): |
| return ( |
| None, |
| None, |
| gr.Timer(active=False), |
| '<div class="coach-card coach-idle">Coach reset. Press <b>Start coaching</b> to run your triage clock with action prompts.</div>', |
| ) |
|
|
| def _tick_coach(start_ts, panic_until, minutes, triage_markdown, final_sheet_html): |
| if not start_ts: |
| return gr.update() |
| state = coach_state(time_blocks(int(minutes or 60)), time.time() - start_ts) |
| return _coach_card(state, triage_markdown, final_sheet_html, panic_until) |
|
|
| def _finish_current_block(start_ts, minutes, triage_markdown, final_sheet_html): |
| if not start_ts: |
| return None, '<div class="coach-card coach-idle">Start the coach first, then you can advance block by block.</div>' |
| next_elapsed = _advance_elapsed_to_next_block(minutes, time.time() - start_ts) |
| new_start = time.time() - next_elapsed |
| state = coach_state(time_blocks(int(minutes or 60)), next_elapsed) |
| return new_start, _coach_card(state, triage_markdown, final_sheet_html) |
|
|
| def _panic_reset(start_ts, minutes, triage_markdown, final_sheet_html): |
| if not start_ts: |
| return None, '<div class="coach-card coach-idle">Start the coach first, then use panic reset whenever you freeze.</div>' |
| until = time.time() + 20 |
| state = coach_state(time_blocks(int(minutes or 60)), time.time() - start_ts) |
| return until, _coach_card(state, triage_markdown, final_sheet_html, until) |
|
|
| coach_start_btn.click( |
| _start_coach, |
| inputs=[time_left_minutes, triage_output, final_sheet_output], |
| outputs=[coach_start, coach_panic_until, coach_timer, coach_display], |
| ) |
| coach_done_btn.click( |
| _finish_current_block, |
| inputs=[coach_start, time_left_minutes, triage_output, final_sheet_output], |
| outputs=[coach_start, coach_display], |
| ) |
| panic_reset_btn.click( |
| _panic_reset, |
| inputs=[coach_start, time_left_minutes, triage_output, final_sheet_output], |
| outputs=[coach_panic_until, coach_display], |
| ) |
| coach_reset_btn.click(_reset_coach, outputs=[coach_start, coach_panic_until, coach_timer, coach_display]) |
| coach_timer.tick( |
| _tick_coach, |
| inputs=[coach_start, coach_panic_until, time_left_minutes, triage_output, final_sheet_output], |
| outputs=[coach_display], |
| ) |
|
|
| def _wizard_go(step): |
| step = max(1, min(len(WIZARD_LABELS), int(step or 1))) |
| back_update = gr.update(interactive=step > 1) |
| if step < len(WIZARD_LABELS): |
| next_update = gr.update(value=f"Next: {WIZARD_LABELS[step]} →", interactive=True) |
| else: |
| next_update = gr.update(value="All steps done ✓", interactive=False) |
| return [step, wizard_progress_html(step), back_update, next_update] |
|
|
| def _wizard_next(step): |
| return _wizard_go(int(step or 1) + 1) |
|
|
| def _wizard_back(step): |
| return _wizard_go(int(step or 1) - 1) |
|
|
| def _wizard_first(): |
| return _wizard_go(1) |
|
|
| def _wizard_fifth(): |
| return _wizard_go(5) |
|
|
| def _open_plan_tab(): |
| |
| |
| return gr.Tabs(selected="plan") |
|
|
| def _advance_after_extract(note_text, step): |
| |
| |
| if isinstance(note_text, str) and note_text.startswith("Topics read from your photo"): |
| return _wizard_go(4) |
| return _wizard_go(int(step or 3)) |
|
|
| nav_outputs = [wizard_step, wiz_progress, back_btn, next_btn] |
| next_btn.click(_wizard_next, inputs=[wizard_step], outputs=nav_outputs) |
| back_btn.click(_wizard_back, inputs=[wizard_step], outputs=nav_outputs) |
|
|
| run.click(_gen_busy, outputs=[gen_status], queue=False).then( |
| generate, |
| inputs=inputs + [model_choice, syllabus_image], |
| outputs=outputs, |
| scroll_to_output=True, |
| api_name="generate", |
| ).then(_gen_done, outputs=[gen_status], queue=False).then( |
| _wizard_fifth, outputs=nav_outputs, queue=False, |
| ).then(_open_plan_tab, outputs=[output_tabs], queue=False) |
|
|
| extract_btn.click(_vision_busy, outputs=[vision_note], queue=False).then( |
| extract_from_photo, |
| inputs=[syllabus_image], |
| outputs=[known_material, vision_note], |
| api_name="extract_topics", |
| ).then(_advance_after_extract, inputs=[vision_note, wizard_step], outputs=nav_outputs, queue=False) |
| download_btn.click( |
| download_packet, |
| inputs=[rescue_output, drill_output, triage_output, final_sheet_output, demo_receipt_output], |
| outputs=download_btn, |
| ) |
| answers_btn.click(_answers_busy, outputs=[answers_output], queue=False).then( |
| show_answers, |
| inputs=[drill_output, subject, model_choice], |
| outputs=[answers_output], |
| api_name="show_answers", |
| ) |
| example.click(load_example, outputs=inputs, queue=False).then( |
| _wizard_first, outputs=nav_outputs, queue=False, |
| ) |
| for case_button, case_index in case_buttons: |
| case_button.click(CASE_LOADERS[case_index], outputs=inputs, queue=False).then( |
| _wizard_first, outputs=nav_outputs, queue=False, |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| demo.queue(default_concurrency_limit=1).launch( |
| server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"), |
| server_port=int(os.getenv("GRADIO_SERVER_PORT", "7860")), |
| show_error=True, |
| ) |
|
|