HIMANSHUKUMARJHA's picture
Upgrade default model to MiniCPM-V 4.6; compact output panel into tabs
faae00e verified
Raw
History Blame Contribute Delete
61.2 kB
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: # Local tests should not require the HF Spaces runtime package.
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,
):
# Download weights on CPU first so a cold model never eats the GPU duration budget.
ensure_weights(model_choice)
rewarm_after = False
if not _is_default_vlm(model_choice) and "gguf" not in (model_choice or "").lower():
# Alternate GPU model (Nemotron): make sure the resident 16 GB VLM is not
# paged into the same window (16 + 8 GB would crowd the 24 GB ceiling).
_clear_vram_for_other_model()
rewarm_after = True
if "gguf" in (model_choice or "").lower():
# The llama.cpp engine runs on CPU — skip the @spaces.GPU wrapper so it does not hold
# (or wait on) the GPU. build_rescue_plan routes the GGUF through llama-cpp-python.
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:
# A ZeroGPU worker timeout/abort is raised here in the main process and is not
# catchable inside the GPU call, so fall back to the deterministic packet rather
# than surfacing an error to the student — and say WHY, honestly, so the student
# knows what would make the model run (sign in / retry / pick the CPU model).
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
# One global client-side script (installed at load): forces the light theme the design
# is built for, runs the elapsed-seconds ticker whenever a busy status is on screen, and
# smooth-scrolls the wizard card into view when the active step changes. Implemented as a
# MutationObserver so it never interferes with Gradio's own event/update plumbing.
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) {}
}
"""
# Build Small Hackathon submission Space (resume).
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,
)
# Invisible state + timer live outside the tabs so coaching keeps running
# no matter which tab is open.
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():
# After generating, surface the fresh packet by opening the Rescue plan tab.
# (The left wizard still advances to the Live coach step.)
return gr.Tabs(selected="plan")
def _advance_after_extract(note_text, step):
# Move to step 4 only when the photo actually yielded topics; otherwise stay on
# step 3 so the student can read what went wrong (or just type topics instead).
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,
)