Codex commited on
Commit
0921a5d
·
0 Parent(s):

Build Pocket Tutor Space

Browse files
Files changed (20) hide show
  1. .gitignore +7 -0
  2. README.md +90 -0
  3. app.py +19 -0
  4. core/__init__.py +1 -0
  5. core/analyzer.py +186 -0
  6. core/inference.py +217 -0
  7. core/parser.py +166 -0
  8. env/__init__.py +1 -0
  9. env/config.py +22 -0
  10. env/runtime.py +49 -0
  11. modal/CARD.md +57 -0
  12. modal/dataset.py +158 -0
  13. modal/tune.py +174 -0
  14. pyrightconfig.json +4 -0
  15. requirements.txt +10 -0
  16. run.sh +66 -0
  17. ui/__init__.py +1 -0
  18. ui/examples.py +94 -0
  19. ui/layout.py +184 -0
  20. ui/styles.py +158 -0
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ .venv/
2
+ __pycache__/
3
+ .ruff_cache/
4
+ .pyright/
5
+ .env
6
+ *.pyc
7
+ *.log
README.md ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Pocket Tutor
3
+ emoji: 🎒
4
+ colorFrom: green
5
+ colorTo: yellow
6
+ sdk: gradio
7
+ sdk_version: 6.17.3
8
+ app_file: app.py
9
+ python_version: "3.12"
10
+ short_description: Photo-first homework coaching for students and parents
11
+ pinned: false
12
+ tags:
13
+ - build-small-hackathon
14
+ - backyard-ai
15
+ - openbmb
16
+ - off-brand
17
+ - best-agent
18
+ - multimodal
19
+ - modal
20
+ - well-tuned
21
+ ---
22
+
23
+ # Pocket Tutor
24
+
25
+ Pocket Tutor is a multimodal homework coach for students and parents. It accepts
26
+ a worksheet photo, a typed question, or microphone input, then returns a compact
27
+ tutoring plan: what the problem is asking, known information, a strategy, worked
28
+ steps, a quality check, the next hint, and a parent support note.
29
+
30
+ ## Model Plan
31
+
32
+ - Primary model: `openbmb/MiniCPM-V-4.6` for image + text tutoring
33
+ - Text fallback: `openbmb/MiniCPM3-4B`
34
+ - Speech input: `openai/whisper-small` local transcription
35
+ - Fine-tuned adapter: `build-small-hackathon/pocket-tutor-minicpmv-socratic`
36
+ - Training: Modal A10G QLoRA on current app-format tutoring examples
37
+ - Parameter cap: every planned model is under the 32B hackathon limit
38
+
39
+ The app does not call an external answer API. If local model execution is still
40
+ loading or unavailable, it returns a deterministic tutoring scaffold instead of
41
+ silently failing.
42
+
43
+ ## Hackathon Alignment
44
+
45
+ | Requirement | Pocket Tutor implementation |
46
+ |---|---|
47
+ | Gradio Space in `build-small-hackathon` | `build-small-hackathon/pocket-tutor` |
48
+ | Track | Backyard AI |
49
+ | Sponsor focus | OpenBMB MiniCPM-V multimodal tutoring |
50
+ | Merit targets | Off-Brand, Best Agent, Well-Tuned |
51
+ | Multimodal input | Image upload/webcam, typed question, microphone transcript |
52
+ | Fine-tuning | Modal QLoRA adapter trained on app-format Socratic tutoring examples |
53
+ | Demo/social links | Add final demo video and social post links after recording |
54
+
55
+ ## Links
56
+
57
+ - GitHub Repo: https://github.com/awilliams88/pocket-tutor
58
+ - Hugging Face Space: https://huggingface.co/spaces/build-small-hackathon/pocket-tutor
59
+ - Fine-tuned Model: https://huggingface.co/build-small-hackathon/pocket-tutor-minicpmv-socratic
60
+ - Demo Video: pending final recording
61
+ - Social Post: pending final post
62
+
63
+ ## Local Development
64
+
65
+ ```bash
66
+ ./run.sh setup
67
+ ./run.sh app
68
+ ./run.sh verify
69
+ ```
70
+
71
+ ## Codebase
72
+
73
+ | Path | Purpose |
74
+ |---|---|
75
+ | `app.py` | Hugging Face Spaces entry point |
76
+ | `env/` | Runtime patches, constants, model IDs, links |
77
+ | `core/` | Prompt orchestration, speech transcription, inference, parsing |
78
+ | `ui/` | Gradio layout, examples, custom chalkboard CSS |
79
+ | `modal/` | Modal QLoRA training job, synthetic dataset, adapter card |
80
+
81
+ ## Safety
82
+
83
+ Pocket Tutor is meant to teach reasoning, not help students cheat. Requests for
84
+ active tests or exams should be redirected toward study guidance.
85
+
86
+ ## Training Data
87
+
88
+ The Modal dataset includes math, science, reading, writing, statistics, blurry
89
+ image recovery, follow-up confusion, and explicit cheating refusal examples. The
90
+ training target is the current production UI format, not an earlier seed format.
app.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from env.runtime import patch_asyncio_cleanup_warning
5
+ from ui.layout import create_app, get_theme
6
+ from ui.styles import CUSTOM_CSS
7
+
8
+ # Keep Spaces startup predictable for this custom Gradio layout.
9
+ os.environ.setdefault("GRADIO_SSR_MODE", "false")
10
+
11
+ # Hide a harmless local Gradio teardown warning.
12
+ patch_asyncio_cleanup_warning()
13
+
14
+ # Build the app once for Hugging Face Spaces discovery.
15
+ demo = create_app()
16
+
17
+ if __name__ == "__main__":
18
+ # Direct Python launch is used by run.sh and Spaces.
19
+ demo.launch(theme=get_theme(), css=CUSTOM_CSS)
core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from __future__ import annotations
core/analyzer.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ try:
8
+ import spaces
9
+ except ImportError:
10
+ # Use a no-op GPU decorator during local development.
11
+ class _LocalSpacesFallback:
12
+ @staticmethod
13
+ def GPU(
14
+ duration: int = 30,
15
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
16
+ def decorator(function: Callable[..., Any]) -> Callable[..., Any]:
17
+ return function
18
+
19
+ return decorator
20
+
21
+ spaces = _LocalSpacesFallback()
22
+
23
+ from core.inference import run_tutor_inference, transcribe_audio
24
+ from core.parser import parse_sections, stringify_content, validate_image_path
25
+ from env.config import MODEL_ID, PARAMETER_COUNT, QUESTION_LIMIT, TRANSCRIPT_LIMIT
26
+
27
+ # The tutor prompt keeps the app educational rather than answer-dumping.
28
+ TUTOR_SYSTEM_PROMPT = (
29
+ "You are Pocket Tutor, a patient multimodal homework coach. "
30
+ "Help the learner understand the problem without shaming them or simply dumping final answers. "
31
+ "For math and science, show compact steps and check the answer. "
32
+ "For writing or reading, explain the reasoning and offer a revision or clue. "
33
+ "If the uploaded image is unclear, say what information is missing. "
34
+ "Do not solve active graded tests, exams, or requests to cheat; offer study guidance instead."
35
+ )
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class TutorReport:
40
+ """Structure containing the prompt context, execution logs, and parsed tutoring result."""
41
+
42
+ student_context: str
43
+ model_path: str
44
+ problem: str
45
+ knowns: str
46
+ strategy: str
47
+ steps: str
48
+ check: str
49
+ hint: str
50
+ parent: str
51
+
52
+
53
+ def build_tutor_prompt(
54
+ question: str,
55
+ transcript: str,
56
+ grade_band: str,
57
+ help_mode: str,
58
+ image_status: str,
59
+ ) -> str:
60
+ """Builds the multimodal tutoring prompt expected by the fine-tuned adapter."""
61
+ combined_question = "\n".join(
62
+ part for part in [question.strip(), transcript.strip()] if part
63
+ ).strip()
64
+ return f"""{TUTOR_SYSTEM_PROMPT}
65
+
66
+ Student grade band: {grade_band}
67
+ Help mode: {help_mode}
68
+ Image status: {image_status}
69
+
70
+ Return exactly these sections:
71
+
72
+ === PROBLEM READ ===
73
+ [Briefly restate what the learner is asking.]
74
+
75
+ === KNOWNS ===
76
+ - [List useful givens, facts, or constraints.]
77
+
78
+ === STRATEGY ===
79
+ [Explain the plan in 2-4 sentences.]
80
+
81
+ === WORKED STEPS ===
82
+ [Give concise step-by-step help. For hint mode, stop before the final answer.]
83
+
84
+ === CHECK ===
85
+ [Check units, logic, or answer quality.]
86
+
87
+ === NEXT HINT ===
88
+ [Ask one short follow-up question or give the smallest next hint.]
89
+
90
+ === PARENT NOTE ===
91
+ [One sentence a parent/tutor can use to support the learner.]
92
+
93
+ Learner question:
94
+ {combined_question[:QUESTION_LIMIT] or "The learner uploaded an image and did not type a question."}
95
+ """
96
+
97
+
98
+ def analyze_homework(
99
+ image_file: object | None,
100
+ question: Any,
101
+ audio_path: object | None,
102
+ grade_band: str,
103
+ help_mode: str,
104
+ ) -> TutorReport:
105
+ """Orchestrates image validation, speech transcription, inference, and parsing."""
106
+ # Convert text and microphone input before building the model prompt.
107
+ typed_question = stringify_content(question)[:QUESTION_LIMIT]
108
+ transcript, transcript_log = transcribe_audio(audio_path)
109
+ transcript = transcript[:TRANSCRIPT_LIMIT]
110
+ image_path, image_status = validate_image_path(image_file)
111
+
112
+ # Build a traceable prompt that matches the Modal training format.
113
+ prompt = build_tutor_prompt(
114
+ typed_question,
115
+ transcript,
116
+ grade_band,
117
+ help_mode,
118
+ image_status,
119
+ )
120
+ response, inference_log = run_tutor_inference(prompt, image_path)
121
+ sections = parse_sections(response)
122
+
123
+ # Keep diagnostics clear but never hide whether fallback logic ran.
124
+ model_path = "\n".join(
125
+ [
126
+ f"Primary model: {MODEL_ID}",
127
+ f"Parameters: {PARAMETER_COUNT}",
128
+ "Execution flow: local Space runtime; no external answer API",
129
+ "---",
130
+ image_status,
131
+ transcript_log,
132
+ inference_log,
133
+ ]
134
+ )
135
+ student_context = "\n".join(
136
+ part
137
+ for part in [
138
+ f"Grade band: {grade_band}",
139
+ f"Help mode: {help_mode}",
140
+ f"Typed question: {typed_question}" if typed_question else "",
141
+ f"Voice transcript: {transcript}" if transcript else "",
142
+ image_status,
143
+ ]
144
+ if part
145
+ )
146
+ return TutorReport(student_context, model_path, *sections)
147
+
148
+
149
+ @spaces.GPU(duration=45)
150
+ def analyze_homework_ui(
151
+ image_file: object | None,
152
+ question: Any,
153
+ audio_path: object | None,
154
+ grade_band: str,
155
+ help_mode: str,
156
+ ) -> tuple[str, str, str, str, str, str, str, str, str]:
157
+ """Gradio-compatible GPU entry point for tutoring analysis."""
158
+ # Convert the report into stable component outputs.
159
+ report = analyze_homework(image_file, question, audio_path, grade_band, help_mode)
160
+ return (
161
+ report.student_context,
162
+ report.model_path,
163
+ report.problem,
164
+ report.knowns,
165
+ report.strategy,
166
+ report.steps,
167
+ report.check,
168
+ report.hint,
169
+ report.parent,
170
+ )
171
+
172
+
173
+ def reset_outputs() -> tuple[str, str, str, str, str, str, str, str, str]:
174
+ """Clears visible outputs before a fresh tutoring run."""
175
+ # Keep click feedback immediate while the model loads.
176
+ return (
177
+ "Preparing tutoring context...",
178
+ "Starting local model execution...",
179
+ "",
180
+ "",
181
+ "",
182
+ "",
183
+ "",
184
+ "",
185
+ "",
186
+ )
core/inference.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from typing import Any
6
+
7
+ from env.config import ADAPTER_REPO_ID, FALLBACK_MODEL_ID, MODEL_ID, SPEECH_MODEL_ID
8
+
9
+ # Keep model instances warm after first use.
10
+ _vl_model: Any = None
11
+ _vl_tokenizer: Any = None
12
+ _text_model: Any = None
13
+ _text_tokenizer: Any = None
14
+ _speech_pipeline: Any = None
15
+ _adapter_applied = False
16
+
17
+ _THINK_BLOCK_PATTERN = re.compile(r"<think>.*?</think>", re.IGNORECASE | re.DOTALL)
18
+ _THINK_START_PATTERN = re.compile(r"<think>.*", re.IGNORECASE | re.DOTALL)
19
+
20
+
21
+ def clean_generated_text(text: str) -> str:
22
+ """Removes hidden reasoning and chat-template leftovers from model output."""
23
+ # Prefer visible content after a completed thinking block.
24
+ if "</think>" in text.lower():
25
+ text = re.split(r"</think>", text, flags=re.IGNORECASE, maxsplit=1)[-1]
26
+ text = _THINK_BLOCK_PATTERN.sub("", text)
27
+ text = _THINK_START_PATTERN.sub("", text)
28
+ for marker in ("<|im_end|>", "<|im_start|>", "\nUser:", "\nAssistant:"):
29
+ if marker in text:
30
+ text = text.split(marker, 1)[0]
31
+ text = re.sub(r"[ \t]+", " ", text)
32
+ text = "\n".join(line.strip() for line in text.splitlines())
33
+ return re.sub(r"\n{3,}", "\n\n", text).strip()
34
+
35
+
36
+ def _format_chat_prompt(tokenizer: Any, messages: list[dict[str, str]]) -> str:
37
+ """Formats chat input while disabling hidden reasoning when supported."""
38
+ try:
39
+ return tokenizer.apply_chat_template(
40
+ messages,
41
+ tokenize=False,
42
+ add_generation_prompt=True,
43
+ enable_thinking=False,
44
+ )
45
+ except TypeError:
46
+ return tokenizer.apply_chat_template(
47
+ messages,
48
+ tokenize=False,
49
+ add_generation_prompt=True,
50
+ )
51
+
52
+
53
+ def transcribe_audio(audio_path: object | None) -> tuple[str, str]:
54
+ """Transcribes optional microphone input using a local speech model when available."""
55
+ global _speech_pipeline
56
+ if not audio_path:
57
+ return "", "No microphone input provided."
58
+ try:
59
+ from transformers import pipeline
60
+
61
+ if _speech_pipeline is None:
62
+ _speech_pipeline = pipeline(
63
+ "automatic-speech-recognition",
64
+ model=SPEECH_MODEL_ID,
65
+ token=os.environ.get("HF_TOKEN"),
66
+ )
67
+ result = _speech_pipeline(str(audio_path))
68
+ transcript = str(result.get("text", "")).strip()
69
+ return transcript, f"Transcribed microphone input with {SPEECH_MODEL_ID}."
70
+ except Exception as exc:
71
+ return "", f"Speech transcription unavailable: {exc}"
72
+
73
+
74
+ def _load_text_model(log_lines: list[str]) -> tuple[Any, Any]:
75
+ """Loads the compact fallback tutor model for text-only requests."""
76
+ global _adapter_applied, _text_model, _text_tokenizer
77
+ if _text_model is None:
78
+ import torch
79
+ from peft import PeftModel
80
+ from transformers import AutoModelForCausalLM, AutoTokenizer
81
+
82
+ # Load tokenizer and base text model for local fallback tutoring.
83
+ log_lines.append(f"Loading tokenizer: {FALLBACK_MODEL_ID}")
84
+ _text_tokenizer = AutoTokenizer.from_pretrained(
85
+ FALLBACK_MODEL_ID,
86
+ trust_remote_code=True,
87
+ token=os.environ.get("HF_TOKEN"),
88
+ )
89
+ dtype = torch.bfloat16 if torch.cuda.is_available() else torch.float32
90
+ log_lines.append(f"Loading model: {FALLBACK_MODEL_ID}")
91
+ _text_model = AutoModelForCausalLM.from_pretrained(
92
+ FALLBACK_MODEL_ID,
93
+ dtype=dtype,
94
+ low_cpu_mem_usage=True,
95
+ trust_remote_code=True,
96
+ token=os.environ.get("HF_TOKEN"),
97
+ )
98
+ try:
99
+ log_lines.append(f"Loading LoRA adapter: {ADAPTER_REPO_ID}")
100
+ _text_model = PeftModel.from_pretrained(
101
+ _text_model,
102
+ ADAPTER_REPO_ID,
103
+ token=os.environ.get("HF_TOKEN"),
104
+ )
105
+ _adapter_applied = True
106
+ log_lines.append("LoRA adapter applied to fallback text model.")
107
+ except Exception as exc:
108
+ _adapter_applied = False
109
+ log_lines.append(f"Adapter unavailable for fallback model: {exc}")
110
+ if torch.cuda.is_available():
111
+ _text_model = _text_model.to("cuda")
112
+ else:
113
+ adapter_status = "with LoRA adapter" if _adapter_applied else "without adapter"
114
+ log_lines.append(f"Using cached fallback text model {adapter_status}.")
115
+ return _text_model, _text_tokenizer
116
+
117
+
118
+ def run_tutor_inference(prompt: str, image_path: str | None) -> tuple[str, str]:
119
+ """Executes local tutoring inference, preferring vision-language when an image exists."""
120
+ log_lines: list[str] = []
121
+ if image_path:
122
+ response, logs = _run_vision_inference(prompt, image_path)
123
+ if response:
124
+ return response, logs
125
+ log_lines.append(logs)
126
+
127
+ # Text-only fallback keeps typed and transcribed questions usable.
128
+ try:
129
+ log_lines.append("Initializing text tutoring fallback...")
130
+ model, tokenizer = _load_text_model(log_lines)
131
+ device = str(model.device)
132
+ messages = [{"role": "user", "content": prompt}]
133
+ text = _format_chat_prompt(tokenizer, messages)
134
+ model_inputs = tokenizer([text], return_tensors="pt").to(device)
135
+ generated_ids = model.generate(
136
+ **model_inputs,
137
+ max_new_tokens=640,
138
+ do_sample=False,
139
+ repetition_penalty=1.08,
140
+ )
141
+ generated_ids = [
142
+ output_ids[len(input_ids) :]
143
+ for input_ids, output_ids in zip(model_inputs.input_ids, generated_ids)
144
+ ]
145
+ response = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0]
146
+ log_lines.append("Text tutoring fallback completed.")
147
+ return clean_generated_text(response), "\n".join(log_lines)
148
+ except Exception as exc:
149
+ log_lines.append(f"Local model execution failed: {exc}")
150
+ log_lines.append("Returning a deterministic tutoring scaffold.")
151
+ return _fallback_tutoring_response(prompt), "\n".join(log_lines)
152
+
153
+
154
+ def _run_vision_inference(prompt: str, image_path: str) -> tuple[str, str]:
155
+ """Runs MiniCPM-V when the runtime supports its remote-code interface."""
156
+ global _vl_model, _vl_tokenizer
157
+ log_lines: list[str] = []
158
+ try:
159
+ import torch
160
+ from PIL import Image
161
+ from transformers import AutoModel, AutoTokenizer
162
+
163
+ if _vl_model is None:
164
+ # MiniCPM-V uses custom modeling code, so trust_remote_code is required.
165
+ log_lines.append(f"Loading tokenizer: {MODEL_ID}")
166
+ _vl_tokenizer = AutoTokenizer.from_pretrained(
167
+ MODEL_ID,
168
+ trust_remote_code=True,
169
+ token=os.environ.get("HF_TOKEN"),
170
+ )
171
+ dtype = torch.bfloat16 if torch.cuda.is_available() else torch.float32
172
+ log_lines.append(f"Loading vision-language model: {MODEL_ID}")
173
+ _vl_model = AutoModel.from_pretrained(
174
+ MODEL_ID,
175
+ trust_remote_code=True,
176
+ dtype=dtype,
177
+ low_cpu_mem_usage=True,
178
+ token=os.environ.get("HF_TOKEN"),
179
+ )
180
+ if torch.cuda.is_available():
181
+ _vl_model = _vl_model.to("cuda")
182
+ _vl_model.eval()
183
+ else:
184
+ log_lines.append("Using cached vision-language model.")
185
+
186
+ # Use the model's chat API when available.
187
+ image = Image.open(image_path).convert("RGB")
188
+ msgs = [{"role": "user", "content": [image, prompt]}]
189
+ response = _vl_model.chat(image=None, msgs=msgs, tokenizer=_vl_tokenizer)
190
+ log_lines.append("Vision tutoring inference completed.")
191
+ return clean_generated_text(str(response)), "\n".join(log_lines)
192
+ except Exception as exc:
193
+ log_lines.append(f"Vision model execution failed: {exc}")
194
+ return "", "\n".join(log_lines)
195
+
196
+
197
+ def _fallback_tutoring_response(prompt: str) -> str:
198
+ """Returns a stable tutor-shaped response when local weights are unavailable."""
199
+ return (
200
+ "=== PROBLEM READ ===\n"
201
+ "I can help, but the local model is still loading or unavailable. I will treat your typed question as the source.\n\n"
202
+ "=== KNOWNS ===\n"
203
+ f"- Student question: {prompt[:240] or 'No question text provided.'}\n\n"
204
+ "=== STRATEGY ===\n"
205
+ "Restate the problem in your own words, identify the given values, then choose the smallest rule or example that applies.\n\n"
206
+ "=== WORKED STEPS ===\n"
207
+ "1. Copy the exact problem statement.\n"
208
+ "2. Underline what is being asked.\n"
209
+ "3. List the givens and the unknown.\n"
210
+ "4. Try one step, then check whether the units or logic still make sense.\n\n"
211
+ "=== CHECK ===\n"
212
+ "The answer should satisfy the original question, use the right units, and not contradict any given information.\n\n"
213
+ "=== NEXT HINT ===\n"
214
+ "Tell me which line confuses you most, and I will give only the next hint.\n\n"
215
+ "=== PARENT NOTE ===\n"
216
+ "Ask the learner to explain their first step out loud before correcting the answer."
217
+ )
core/parser.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import re
5
+ from typing import Any
6
+
7
+ from env.config import SUPPORTED_IMAGE_SUFFIXES
8
+
9
+ # Match headings that the tutoring model is instructed to emit.
10
+ _SECTION_MARKER_PATTERN = re.compile(
11
+ r"(?im)^[ \t]*(?:[-*][ \t]*)?(?:#{1,6}[ \t]*)?"
12
+ r"(?:\*\*)?(?:={2,}[ \t]*)?"
13
+ r"(?P<label>"
14
+ r"problem read|knowns?|strategy|worked steps?|check|next hint|parent note"
15
+ r")"
16
+ r"\b"
17
+ r"(?:[ \t]*={2,})?(?:\*\*)?[ \t]*(?::|-)?[ \t]*"
18
+ r"(?P<trailing>[^\n]*)$"
19
+ )
20
+
21
+ _SECTION_ORDER = (
22
+ "problem",
23
+ "knowns",
24
+ "strategy",
25
+ "steps",
26
+ "check",
27
+ "hint",
28
+ "parent",
29
+ )
30
+
31
+ _SECTION_DEFAULTS = {
32
+ "problem": "Upload a homework image or type the question to begin.",
33
+ "knowns": "- No givens identified yet.",
34
+ "strategy": "No strategy generated yet.",
35
+ "steps": "No worked steps generated yet.",
36
+ "check": "No answer check generated yet.",
37
+ "hint": "Ask for a hint after the first explanation.",
38
+ "parent": "Parent support note will appear here.",
39
+ }
40
+
41
+
42
+ def resolve_path(file_input: object | None) -> Path | None:
43
+ """Normalizes Gradio file payload variants into a local path."""
44
+ # Empty upload components should let text or audio drive the request.
45
+ if not file_input:
46
+ return None
47
+ if isinstance(file_input, (list, tuple)):
48
+ for item in file_input:
49
+ path = resolve_path(item)
50
+ if path:
51
+ return path
52
+ return None
53
+ if isinstance(file_input, dict):
54
+ for key in ("path", "name", "orig_name"):
55
+ value = file_input.get(key)
56
+ if value:
57
+ return Path(str(value))
58
+ return None
59
+ return Path(str(file_input))
60
+
61
+
62
+ def validate_image_path(file_input: object | None) -> tuple[str | None, str]:
63
+ """Returns a usable image path and a short validation message."""
64
+ # Accept common classroom photo formats only.
65
+ path = resolve_path(file_input)
66
+ if not path:
67
+ return None, "No image uploaded."
68
+ suffix = path.suffix.lower()
69
+ if suffix not in SUPPORTED_IMAGE_SUFFIXES:
70
+ return None, f"Unsupported image format: {suffix}."
71
+ if not path.exists():
72
+ return None, "Uploaded image was not found in the local runtime."
73
+ return str(path), f"Image accepted: {path.name}"
74
+
75
+
76
+ def stringify_content(content: Any) -> str:
77
+ """Converts Gradio text, audio transcripts, and message payloads into prompt-safe text."""
78
+ # Plain textbox messages arrive as strings.
79
+ if content is None:
80
+ return ""
81
+ if isinstance(content, str):
82
+ return content.strip()
83
+ if isinstance(content, (list, tuple)):
84
+ parts = [stringify_content(item) for item in content]
85
+ return " ".join(part for part in parts if part).strip()
86
+ if isinstance(content, dict):
87
+ for key in ("text", "value", "path", "url", "name", "alt_text"):
88
+ value = content.get(key)
89
+ if value:
90
+ return stringify_content(value)
91
+ return ""
92
+ return str(content).strip()
93
+
94
+
95
+ def _canonical_section(label: str) -> str:
96
+ """Maps model heading variants onto the app's fixed output cards."""
97
+ normalized = re.sub(r"[^a-z]+", " ", label.lower()).strip()
98
+ if "problem" in normalized:
99
+ return "problem"
100
+ if "known" in normalized:
101
+ return "knowns"
102
+ if "strategy" in normalized:
103
+ return "strategy"
104
+ if "worked" in normalized or "step" in normalized:
105
+ return "steps"
106
+ if "check" in normalized:
107
+ return "check"
108
+ if "hint" in normalized:
109
+ return "hint"
110
+ return "parent"
111
+
112
+
113
+ def parse_sections(response: str) -> tuple[str, str, str, str, str, str, str]:
114
+ """Extracts tutoring sections from a structured model response."""
115
+ # Find candidate headings and keep defaults when a section is absent.
116
+ matches = list(_SECTION_MARKER_PATTERN.finditer(response))
117
+ sections = dict(_SECTION_DEFAULTS)
118
+ if not matches:
119
+ return (
120
+ sections["problem"],
121
+ sections["knowns"],
122
+ sections["strategy"],
123
+ sections["steps"],
124
+ sections["check"],
125
+ sections["hint"],
126
+ sections["parent"],
127
+ )
128
+
129
+ # Prefer the best ordered block of sections in the generation.
130
+ best_values: dict[str, str] = {}
131
+ best_count = -1
132
+ for start_index, _ in enumerate(matches):
133
+ values: dict[str, str] = {}
134
+ last_order_index = -1
135
+ for current_index in range(start_index, len(matches)):
136
+ current = matches[current_index]
137
+ section = _canonical_section(current.group("label"))
138
+ order_index = _SECTION_ORDER.index(section)
139
+ if order_index <= last_order_index:
140
+ break
141
+ next_start = (
142
+ matches[current_index + 1].start()
143
+ if current_index + 1 < len(matches)
144
+ else len(response)
145
+ )
146
+ value = "\n".join(
147
+ [current.group("trailing"), response[current.end() : next_start]]
148
+ ).strip()
149
+ if value:
150
+ values[section] = value
151
+ last_order_index = order_index
152
+ if len(values) >= best_count:
153
+ best_values = values
154
+ best_count = len(values)
155
+
156
+ # Merge extracted values over stable UI defaults.
157
+ sections.update(best_values)
158
+ return (
159
+ sections["problem"],
160
+ sections["knowns"],
161
+ sections["strategy"],
162
+ sections["steps"],
163
+ sections["check"],
164
+ sections["hint"],
165
+ sections["parent"],
166
+ )
env/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from __future__ import annotations
env/config.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ # App copy shown in the Gradio header.
4
+ APP_TITLE = "Pocket Tutor"
5
+ APP_DESCRIPTION = "Photo-first homework coaching for students and parents."
6
+
7
+ # Input limits keep local prompts compact and predictable.
8
+ QUESTION_LIMIT = 3000
9
+ TRANSCRIPT_LIMIT = 1200
10
+ SUPPORTED_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp"}
11
+
12
+ # Public links shown in the Space footer.
13
+ GITHUB_URL = "https://github.com/awilliams88/pocket-tutor"
14
+ SPACE_URL = "https://huggingface.co/spaces/build-small-hackathon/pocket-tutor"
15
+
16
+ # Model metadata keeps docs, logs, and UI aligned.
17
+ MODEL_ID = "openbmb/MiniCPM-V-4.6"
18
+ FALLBACK_MODEL_ID = "openbmb/MiniCPM3-4B"
19
+ SPEECH_MODEL_ID = "openai/whisper-small"
20
+ ADAPTER_REPO_ID = "build-small-hackathon/pocket-tutor-minicpmv-socratic"
21
+ SPONSOR_NAME = "OpenBMB"
22
+ PARAMETER_COUNT = "~1B vision-language"
env/runtime.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio.base_events as base_events
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ def patch_asyncio_cleanup_warning() -> None:
10
+ """Keeps local Gradio teardown from surfacing a known invalid-fd warning."""
11
+ # Skip patching when this Python runtime does not expose the cleanup hook.
12
+ original_del = getattr(base_events.BaseEventLoop, "__del__", None)
13
+ if original_del is None or getattr(original_del, "_pocket_tutor_patched", False):
14
+ return
15
+
16
+ # Preserve normal cleanup while ignoring the harmless file descriptor case.
17
+ def patched_del(self: Any) -> None:
18
+ try:
19
+ original_del(self)
20
+ except ValueError as exc:
21
+ if str(exc) != "Invalid file descriptor: -1":
22
+ raise
23
+
24
+ # Mark the patch so repeated imports stay idempotent.
25
+ setattr(patched_del, "_pocket_tutor_patched", True)
26
+ setattr(base_events.BaseEventLoop, "__del__", patched_del)
27
+
28
+
29
+ def load_env() -> None:
30
+ """Loads simple KEY=value pairs from a local .env file when present."""
31
+ # Search the project folder before the parent hackathon workspace.
32
+ for path in [Path(".env"), Path("../.env")]:
33
+ if path.is_file():
34
+ try:
35
+ with open(path, encoding="utf-8") as f:
36
+ for line in f:
37
+ line = line.strip()
38
+ if line and not line.startswith("#") and "=" in line:
39
+ key, value = line.split("=", 1)
40
+ os.environ.setdefault(
41
+ key.strip(), value.strip().strip("'\"")
42
+ )
43
+ break
44
+ except Exception:
45
+ pass
46
+
47
+
48
+ # Load local secrets before model or Modal clients are used.
49
+ load_env()
modal/CARD.md ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ base_model: openbmb/MiniCPM3-4B
3
+ library_name: peft
4
+ pipeline_tag: text-generation
5
+ language:
6
+ - en
7
+ tags:
8
+ - peft
9
+ - lora
10
+ - qlora
11
+ - tutoring
12
+ - education
13
+ - multimodal
14
+ - build-small-hackathon
15
+ ---
16
+
17
+ # Pocket Tutor Socratic LoRA
18
+
19
+ Pocket Tutor Socratic LoRA is a QLoRA adapter trained for the Pocket Tutor
20
+ Space. The production app uses OpenBMB vision-language input when available and
21
+ falls back to this compact MiniCPM text adapter for typed or transcribed
22
+ homework questions.
23
+
24
+ ## Intended Use
25
+
26
+ - Explaining homework from photos, typed questions, or microphone input
27
+ - Producing concise Socratic hints and step-by-step support
28
+ - Helping parents ask better guiding questions
29
+ - Refusing requests to cheat on active tests or exams
30
+
31
+ ## Output Format
32
+
33
+ The adapter is trained on the current production UI format:
34
+
35
+ ```text
36
+ === PROBLEM READ ===
37
+ === KNOWNS ===
38
+ === STRATEGY ===
39
+ === WORKED STEPS ===
40
+ === CHECK ===
41
+ === NEXT HINT ===
42
+ === PARENT NOTE ===
43
+ ```
44
+
45
+ ## Training Recipe
46
+
47
+ - Base model: `openbmb/MiniCPM3-4B`
48
+ - Method: QLoRA with 4-bit NF4 quantization
49
+ - Hardware: Modal NVIDIA A10G
50
+ - Training data: synthetic app-format tutoring examples and short follow-up turns
51
+ - Sequence length: 1536 tokens
52
+ - Runtime pairing: OpenBMB MiniCPM-V for image context plus adapter-backed text tutoring
53
+
54
+ ## Limitations
55
+
56
+ This model can make mistakes and should not be used to cheat on graded work. It
57
+ is designed to teach process and reasoning, not replace a teacher.
modal/dataset.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ # The training prompt mirrors the production tutoring sections.
4
+ TUTOR_SYSTEM_PROMPT = (
5
+ "You are Pocket Tutor, a patient multimodal homework coach. "
6
+ "Help the learner understand the problem without shaming them or simply dumping final answers. "
7
+ "For math and science, show compact steps and check the answer. "
8
+ "For writing or reading, explain the reasoning and offer a revision or clue. "
9
+ "If the uploaded image is unclear, say what information is missing. "
10
+ "Do not solve active graded tests, exams, or requests to cheat; offer study guidance instead."
11
+ )
12
+
13
+
14
+ def build_training_prompt(question: str, grade_band: str, help_mode: str) -> str:
15
+ """Builds a text-only surrogate prompt for MiniCPM-V tutoring adapter training."""
16
+ return f"""{TUTOR_SYSTEM_PROMPT}
17
+
18
+ Student grade band: {grade_band}
19
+ Help mode: {help_mode}
20
+ Image status: Synthetic text surrogate for worksheet/photo content.
21
+
22
+ Return exactly these sections:
23
+
24
+ === PROBLEM READ ===
25
+ === KNOWNS ===
26
+ === STRATEGY ===
27
+ === WORKED STEPS ===
28
+ === CHECK ===
29
+ === NEXT HINT ===
30
+ === PARENT NOTE ===
31
+
32
+ Learner question:
33
+ {question}
34
+ """
35
+
36
+
37
+ def get_training_examples() -> list[dict[str, str]]:
38
+ """Returns synthetic tutoring examples matching the production output format."""
39
+ return [
40
+ {
41
+ "grade": "Middle school",
42
+ "mode": "Coach me",
43
+ "question": "A recipe uses 3/4 cup of flour for one batch. How much flour is needed for 2 1/2 batches?",
44
+ "response": "=== PROBLEM READ ===\nYou need to scale a recipe from 1 batch to 2 1/2 batches.\n\n=== KNOWNS ===\n- 1 batch uses 3/4 cup of flour\n- You need 2 1/2 batches\n\n=== STRATEGY ===\nMultiply the flour for one batch by the number of batches. Convert the mixed number so the multiplication is easier.\n\n=== WORKED STEPS ===\n1. Rewrite 2 1/2 as 5/2.\n2. Multiply: 3/4 x 5/2 = 15/8.\n3. Convert 15/8 to 1 7/8.\n\n=== CHECK ===\nThe answer is more than 3/4 cup and less than 2 cups, which fits 2 1/2 batches.\n\n=== NEXT HINT ===\nCan you explain why multiplying by 5/2 makes sense here?\n\n=== PARENT NOTE ===\nAsk the learner to draw two full batches and a half batch before simplifying.",
45
+ },
46
+ {
47
+ "grade": "High school",
48
+ "mode": "Step-by-step",
49
+ "question": "If a bike travels 18 meters in 6 seconds, what is its average speed?",
50
+ "response": "=== PROBLEM READ ===\nThe question asks for average speed from distance and time.\n\n=== KNOWNS ===\n- Distance = 18 meters\n- Time = 6 seconds\n\n=== STRATEGY ===\nUse average speed = distance divided by time. Keep the units attached through the calculation.\n\n=== WORKED STEPS ===\n1. Write the formula: speed = distance / time.\n2. Substitute: speed = 18 meters / 6 seconds.\n3. Divide: 18 / 6 = 3.\n4. Attach units: 3 meters per second.\n\n=== CHECK ===\n3 m/s for 6 seconds gives 18 meters, so the result matches the original distance.\n\n=== NEXT HINT ===\nWhat would change if the time doubled but the distance stayed the same?\n\n=== PARENT NOTE ===\nHave the learner say the units out loud as 'meters per second' before calculating.",
51
+ },
52
+ {
53
+ "grade": "Middle school",
54
+ "mode": "Hint only",
55
+ "question": "Which sentence best shows the narrator is nervous? The passage says: My palms stuck to the note cards, and every chair squeaked louder than thunder.",
56
+ "response": "=== PROBLEM READ ===\nYou need to find evidence that reveals the narrator's nervous feeling.\n\n=== KNOWNS ===\n- The narrator notices physical details\n- Palms are sticky\n- Ordinary sounds feel exaggerated\n\n=== STRATEGY ===\nLook for a sentence that shows body reactions or anxious attention instead of a sentence that simply states the emotion.\n\n=== WORKED STEPS ===\nThe phrase about palms sticking to note cards is strong evidence because it shows a physical sign of nerves.\n\n=== CHECK ===\nThe evidence should connect directly to nervousness, not just describe the room.\n\n=== NEXT HINT ===\nWhich detail is about the narrator's body rather than the furniture?\n\n=== PARENT NOTE ===\nAsk the learner to point to the exact words that reveal the feeling.",
57
+ },
58
+ {
59
+ "grade": "High school",
60
+ "mode": "Coach me",
61
+ "question": "My thesis says school uniforms are good. How can I make it stronger?",
62
+ "response": "=== PROBLEM READ ===\nYou want to revise a broad thesis into a specific, arguable claim.\n\n=== KNOWNS ===\n- Topic: school uniforms\n- Current claim: uniforms are good\n- Need: more precise reasoning\n\n=== STRATEGY ===\nName the exact benefit and preview why it matters. A strong thesis should be debatable and focused.\n\n=== WORKED STEPS ===\nTry: 'School uniforms can help students focus on learning by reducing visible income comparisons and simplifying morning routines.' This gives two reasons the essay can develop.\n\n=== CHECK ===\nThe revised thesis is specific, arguable, and gives the reader a roadmap.\n\n=== NEXT HINT ===\nWhich reason do you have the best evidence for: focus, cost, safety, or identity?\n\n=== PARENT NOTE ===\nAsk the learner to underline the claim and circle each reason in the thesis.",
63
+ },
64
+ {
65
+ "grade": "Elementary",
66
+ "mode": "Coach me",
67
+ "question": "There are 4 bags with 6 marbles in each bag. Then 5 marbles are lost. How many marbles are left?",
68
+ "response": "=== PROBLEM READ ===\nThe problem asks you to find a total first, then subtract the marbles that were lost.\n\n=== KNOWNS ===\n- 4 bags\n- 6 marbles in each bag\n- 5 marbles are lost\n\n=== STRATEGY ===\nUse multiplication for equal groups, then subtraction for the lost marbles. This is a two-step problem.\n\n=== WORKED STEPS ===\n1. Find the starting total: 4 x 6 = 24 marbles.\n2. Subtract the lost marbles: 24 - 5 = 19.\n\n=== CHECK ===\nThe answer should be less than 24 because some marbles were lost. 19 fits.\n\n=== NEXT HINT ===\nCan you draw four groups of six and cross out five marbles?\n\n=== PARENT NOTE ===\nAsk the learner which word in the problem tells them to subtract.",
69
+ },
70
+ {
71
+ "grade": "Middle school",
72
+ "mode": "Step-by-step",
73
+ "question": "Solve 3x + 7 = 22. I know I need to move something but I get lost.",
74
+ "response": "=== PROBLEM READ ===\nYou need to isolate x in a one-step-then-divide equation.\n\n=== KNOWNS ===\n- Equation: 3x + 7 = 22\n- Goal: find x\n\n=== STRATEGY ===\nUndo the operations in reverse order. Subtract 7 first, then divide by 3.\n\n=== WORKED STEPS ===\n1. Start with 3x + 7 = 22.\n2. Subtract 7 from both sides: 3x = 15.\n3. Divide both sides by 3: x = 5.\n\n=== CHECK ===\nSubstitute x = 5: 3(5) + 7 = 15 + 7 = 22, so it works.\n\n=== NEXT HINT ===\nWhy do we subtract 7 before dividing by 3?\n\n=== PARENT NOTE ===\nHave the learner say each inverse operation before writing it.",
75
+ },
76
+ {
77
+ "grade": "High school",
78
+ "mode": "Hint only",
79
+ "question": "In the photo, the triangle has sides 6, 8, and x. The worksheet asks for the hypotenuse.",
80
+ "response": "=== PROBLEM READ ===\nThe problem is asking for the longest side of a right triangle.\n\n=== KNOWNS ===\n- One leg is 6\n- One leg is 8\n- The unknown is the hypotenuse\n\n=== STRATEGY ===\nUse the Pythagorean theorem because the problem mentions a right triangle and the hypotenuse.\n\n=== WORKED STEPS ===\nStart with a^2 + b^2 = c^2. Your next setup is 6^2 + 8^2 = x^2. Stop there and simplify the squares next.\n\n=== CHECK ===\nThe hypotenuse must be longer than both 6 and 8.\n\n=== NEXT HINT ===\nWhat are 6 squared and 8 squared?\n\n=== PARENT NOTE ===\nAsk the learner to point to the right angle and label the side across from it.",
81
+ },
82
+ {
83
+ "grade": "Middle school",
84
+ "mode": "Coach me",
85
+ "question": "A graph starts at 2 and goes up by 3 each time. What is the rule?",
86
+ "response": "=== PROBLEM READ ===\nYou need to describe a pattern that begins at 2 and increases by 3 for each step.\n\n=== KNOWNS ===\n- Starting value is 2\n- The value increases by 3 each time\n\n=== STRATEGY ===\nA linear rule uses starting value plus repeated change. Use the step number as the input.\n\n=== WORKED STEPS ===\nIf n is the step number starting at 0, the rule is y = 2 + 3n. If the worksheet starts counting at 1, the rule may be y = 3n - 1.\n\n=== CHECK ===\nFor n = 0, y = 2. For n = 1, y = 5. That matches going up by 3.\n\n=== NEXT HINT ===\nDoes your table start counting the first point as step 0 or step 1?\n\n=== PARENT NOTE ===\nHave the learner write the first four outputs before choosing the equation.",
87
+ },
88
+ {
89
+ "grade": "High school",
90
+ "mode": "Coach me",
91
+ "question": "The chemistry question asks why temperature affects reaction rate. I don't know what to say.",
92
+ "response": "=== PROBLEM READ ===\nThe question asks for a cause-and-effect explanation about temperature and reaction speed.\n\n=== KNOWNS ===\n- Higher temperature means particles have more kinetic energy\n- Reactions need effective collisions\n\n=== STRATEGY ===\nConnect temperature to particle motion, then connect particle motion to collision frequency and energy.\n\n=== WORKED STEPS ===\nA strong answer is: Increasing temperature gives particles more kinetic energy, so they move faster and collide more often. More particles also have enough energy to react, which increases the reaction rate.\n\n=== CHECK ===\nThe explanation names both more frequent collisions and more energetic collisions.\n\n=== NEXT HINT ===\nWhich phrase from your notes mentions activation energy?\n\n=== PARENT NOTE ===\nAsk the learner to explain it using a simple bumper-car analogy first.",
93
+ },
94
+ {
95
+ "grade": "Middle school",
96
+ "mode": "Step-by-step",
97
+ "question": "The prompt asks me to compare two characters. I keep summarizing the plot instead.",
98
+ "response": "=== PROBLEM READ ===\nYou need to compare character traits, not retell every event.\n\n=== KNOWNS ===\n- Task: compare two characters\n- Problem: answer is becoming plot summary\n\n=== STRATEGY ===\nChoose one trait for each character, then use one event as evidence for each trait. Keep the event short and explain what it shows.\n\n=== WORKED STEPS ===\n1. Name character A's trait.\n2. Give one short quote or event that proves it.\n3. Name character B's trait.\n4. Give one short quote or event that proves it.\n5. End with how they are alike or different.\n\n=== CHECK ===\nIf most sentences start with 'then this happened,' you are summarizing. If they explain what a choice reveals, you are comparing.\n\n=== NEXT HINT ===\nWhat is one adjective that describes each character?\n\n=== PARENT NOTE ===\nAsk the learner to make a two-column trait chart before writing.",
99
+ },
100
+ {
101
+ "grade": "College",
102
+ "mode": "Coach me",
103
+ "question": "I need to interpret a p-value of 0.03 for a two-sided test at alpha 0.05.",
104
+ "response": "=== PROBLEM READ ===\nYou need to decide whether the test result is statistically significant and explain the meaning carefully.\n\n=== KNOWNS ===\n- p-value = 0.03\n- alpha = 0.05\n- two-sided test\n\n=== STRATEGY ===\nCompare the p-value with alpha. Then describe evidence against the null hypothesis without saying the null is proven false.\n\n=== WORKED STEPS ===\nBecause 0.03 is less than 0.05, the result is statistically significant at the 5% level. You reject the null hypothesis for this test.\n\n=== CHECK ===\nDo not say there is a 3% chance the null hypothesis is true. The p-value is about the probability of data this extreme if the null were true.\n\n=== NEXT HINT ===\nWhat is the null hypothesis in your specific problem?\n\n=== PARENT NOTE ===\nFor older learners, ask them to separate the decision rule from the real-world conclusion.",
105
+ },
106
+ ]
107
+
108
+
109
+ def get_chat_training_examples() -> list[list[dict[str, str]]]:
110
+ """Returns follow-up examples that teach concise Socratic coaching."""
111
+ return [
112
+ [
113
+ {"role": "system", "content": TUTOR_SYSTEM_PROMPT},
114
+ {"role": "user", "content": "I still don't get why 2 1/2 becomes 5/2."},
115
+ {
116
+ "role": "assistant",
117
+ "content": "Two whole batches are 4 halves, and the extra half makes 5 halves total. That is why 2 1/2 equals 5/2. Can you draw five half-batches and count them?",
118
+ },
119
+ ],
120
+ [
121
+ {"role": "system", "content": TUTOR_SYSTEM_PROMPT},
122
+ {"role": "user", "content": "Just give me the answer, this is due now."},
123
+ {
124
+ "role": "assistant",
125
+ "content": "I can help you finish faster, but I should still show the step that makes the answer trustworthy. Write the formula first, then I will help check your substitution.",
126
+ },
127
+ ],
128
+ [
129
+ {"role": "system", "content": TUTOR_SYSTEM_PROMPT},
130
+ {
131
+ "role": "user",
132
+ "content": "My worksheet photo is blurry but I think it says 3x plus 7 equals 22.",
133
+ },
134
+ {
135
+ "role": "assistant",
136
+ "content": "Thanks for reading the unclear part out loud. If the equation is 3x + 7 = 22, the first move is subtracting 7 from both sides. Can you confirm whether the symbol before 7 is plus or minus?",
137
+ },
138
+ ],
139
+ [
140
+ {"role": "system", "content": TUTOR_SYSTEM_PROMPT},
141
+ {
142
+ "role": "user",
143
+ "content": "I got 15/8 but the answer key says one and seven eighths.",
144
+ },
145
+ {
146
+ "role": "assistant",
147
+ "content": "Those are the same value written in different forms. 15/8 is an improper fraction, and 1 7/8 is the mixed number form. How many whole groups of 8 fit inside 15?",
148
+ },
149
+ ],
150
+ [
151
+ {"role": "system", "content": TUTOR_SYSTEM_PROMPT},
152
+ {"role": "user", "content": "This is an exam question, just solve it."},
153
+ {
154
+ "role": "assistant",
155
+ "content": "I cannot solve an active exam for you, but I can help you study the method. Tell me the topic, and I will walk through a similar practice problem step by step.",
156
+ },
157
+ ],
158
+ ]
modal/tune.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ import modal
7
+
8
+ modal_any: Any = modal
9
+
10
+ # Modal app groups the remote fine-tuning job.
11
+ app = modal_any.App("pocket-tutor-tuner")
12
+
13
+ # Custom container image with the training stack and local dataset code.
14
+ image = (
15
+ modal_any.Image.debian_slim()
16
+ .pip_install(
17
+ "torch",
18
+ "transformers>=4.45.0",
19
+ "peft",
20
+ "trl",
21
+ "accelerate",
22
+ "bitsandbytes",
23
+ "datasets",
24
+ "huggingface_hub",
25
+ "pillow",
26
+ )
27
+ .add_local_file(
28
+ os.path.join(os.path.dirname(__file__), "dataset.py"),
29
+ "/root/dataset.py",
30
+ )
31
+ )
32
+
33
+ # Volume keeps checkpoints available across Modal runs.
34
+ volume = modal_any.Volume.from_name("pocket-tutor-checkpoints", create_if_missing=True)
35
+
36
+ # Base model remains under the hackathon parameter limit.
37
+ MODEL_ID = "openbmb/MiniCPM3-4B"
38
+ ADAPTER_REPO_ID = "build-small-hackathon/pocket-tutor-minicpmv-socratic"
39
+
40
+
41
+ @app.function(
42
+ image=image,
43
+ gpu="A10G",
44
+ timeout=7200,
45
+ volumes={"/checkpoints": volume},
46
+ secrets=[modal_any.Secret.from_name("huggingface-secret")],
47
+ )
48
+ def train_lora(
49
+ model_card_content: str,
50
+ hf_token: str | None = None,
51
+ repo_id: str | None = None,
52
+ ):
53
+ """Fine-tunes a compact MiniCPM tutor adapter on app-format Socratic examples."""
54
+ # Remote-only imports are installed inside the Modal container.
55
+ import io
56
+ import os as remote_os
57
+
58
+ import torch
59
+ from datasets import Dataset
60
+ from huggingface_hub import login, upload_file
61
+ from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
62
+ from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
63
+ from trl import SFTConfig, SFTTrainer
64
+
65
+ from dataset import (
66
+ build_training_prompt,
67
+ get_chat_training_examples,
68
+ get_training_examples,
69
+ )
70
+
71
+ # Load tokenizer and prepare app-format conversations.
72
+ print(f"Loading tokenizer for {MODEL_ID}...")
73
+ tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, trust_remote_code=True)
74
+ formatted_dataset = []
75
+ for item in get_training_examples():
76
+ messages = [
77
+ {
78
+ "role": "user",
79
+ "content": build_training_prompt(
80
+ str(item["question"]),
81
+ str(item["grade"]),
82
+ str(item["mode"]),
83
+ ),
84
+ },
85
+ {"role": "assistant", "content": str(item["response"])},
86
+ ]
87
+ formatted_dataset.append(
88
+ {"text": tokenizer.apply_chat_template(messages, tokenize=False)}
89
+ )
90
+ for messages in get_chat_training_examples():
91
+ formatted_dataset.append(
92
+ {"text": tokenizer.apply_chat_template(messages, tokenize=False)}
93
+ )
94
+ dataset = Dataset.from_list(formatted_dataset)
95
+ print(f"Prepared {len(dataset)} tutoring conversations.")
96
+
97
+ # QLoRA keeps adapter training feasible on a single A10G.
98
+ bnb_config = BitsAndBytesConfig(
99
+ load_in_4bit=True,
100
+ bnb_4bit_quant_type="nf4",
101
+ bnb_4bit_compute_dtype=torch.bfloat16,
102
+ bnb_4bit_use_double_quant=True,
103
+ )
104
+ model = AutoModelForCausalLM.from_pretrained(
105
+ MODEL_ID,
106
+ quantization_config=bnb_config,
107
+ device_map="auto",
108
+ trust_remote_code=True,
109
+ )
110
+ model.config.pad_token_id = tokenizer.eos_token_id
111
+ model = prepare_model_for_kbit_training(model)
112
+
113
+ # Target common attention projections for structured tutoring behavior.
114
+ peft_config = LoraConfig(
115
+ r=16,
116
+ lora_alpha=32,
117
+ target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
118
+ lora_dropout=0.05,
119
+ bias="none",
120
+ task_type="CAUSAL_LM",
121
+ )
122
+ model = get_peft_model(model, peft_config)
123
+ model.print_trainable_parameters()
124
+
125
+ # Train on the current production section format, not an earlier seed format.
126
+ training_args = SFTConfig(
127
+ output_dir="/checkpoints/pocket-tutor-lora",
128
+ per_device_train_batch_size=1,
129
+ gradient_accumulation_steps=4,
130
+ warmup_steps=8,
131
+ max_steps=180,
132
+ learning_rate=2e-4,
133
+ bf16=True,
134
+ logging_steps=5,
135
+ save_strategy="steps",
136
+ save_steps=45,
137
+ save_total_limit=2,
138
+ report_to="none",
139
+ dataset_text_field="text",
140
+ max_length=1536,
141
+ )
142
+ trainer = SFTTrainer(model=model, train_dataset=dataset, args=training_args)
143
+ trainer.train()
144
+
145
+ # Save the final adapter into the persistent Modal volume.
146
+ model.save_pretrained("/checkpoints/pocket-tutor-final")
147
+ tokenizer.save_pretrained("/checkpoints/pocket-tutor-final")
148
+ volume.commit()
149
+
150
+ # Publish the adapter and model card when credentials are available.
151
+ hf_token = hf_token or remote_os.environ.get("HF_TOKEN")
152
+ repo_id = repo_id or ADAPTER_REPO_ID
153
+ if hf_token:
154
+ login(token=hf_token)
155
+ model.push_to_hub(repo_id)
156
+ tokenizer.push_to_hub(repo_id)
157
+ upload_file(
158
+ path_or_fileobj=io.BytesIO(model_card_content.encode("utf-8")),
159
+ path_in_repo="README.md",
160
+ repo_id=repo_id,
161
+ repo_type="model",
162
+ commit_message="Update Pocket Tutor adapter model card",
163
+ )
164
+ else:
165
+ print("HF_TOKEN not set. Skipping Hub publish.")
166
+
167
+
168
+ @app.local_entrypoint()
169
+ def main():
170
+ # Read the model card at launch so edits are included in the run.
171
+ meta_path = os.path.join(os.path.dirname(__file__), "CARD.md")
172
+ with open(meta_path, encoding="utf-8") as f:
173
+ model_card = f.read()
174
+ train_lora.remote(model_card_content=model_card)
pyrightconfig.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "reportMissingImports": "none",
3
+ "reportFunctionMemberAccess": "none"
4
+ }
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio==6.17.3
2
+ ruff==0.15.16
3
+ transformers>=4.45.0
4
+ torch
5
+ accelerate
6
+ peft
7
+ sentencepiece
8
+ pillow
9
+ pyright
10
+ modal
run.sh ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # run.sh — local dev utility for Pocket Tutor.
3
+ # Usage: ./run.sh [setup|verify|app]
4
+ set -euo pipefail
5
+
6
+ # Set ROOT_DIR
7
+ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ cd "$ROOT_DIR"
9
+
10
+ # Set PYTHON and TARGET
11
+ PYTHON=".venv/bin/python"
12
+ TARGET="${1:-app}"
13
+
14
+ # Create the virtual environment and install dependencies.
15
+ setup() {
16
+ if [ ! -x "$PYTHON" ]; then
17
+ echo "Creating virtual environment..."
18
+ python3 -m venv .venv
19
+ fi
20
+ echo "Installing dependencies..."
21
+ "$PYTHON" -m pip install --quiet --upgrade pip
22
+ "$PYTHON" -m pip install --quiet -r requirements.txt
23
+ echo "Setup complete."
24
+ }
25
+
26
+ # Abort early if the virtual environment is missing.
27
+ ensure_venv() {
28
+ if [ ! -x "$PYTHON" ]; then
29
+ echo "Error: run ./run.sh setup first."
30
+ exit 1
31
+ fi
32
+ }
33
+
34
+ case "$TARGET" in
35
+ setup)
36
+ setup
37
+ ;;
38
+
39
+ verify)
40
+ [ ! -x "$PYTHON" ] && setup
41
+ echo "-> format"
42
+ "$PYTHON" -m ruff format app.py env/*.py core/*.py ui/*.py modal/*.py
43
+ echo "-> lint"
44
+ "$PYTHON" -m ruff check --fix app.py env/*.py core/*.py ui/*.py modal/*.py
45
+ echo "-> types"
46
+ "$PYTHON" -m pyright app.py env/*.py core/*.py ui/*.py modal/*.py
47
+ echo "-> compile"
48
+ "$PYTHON" -m compileall -q app.py env/ core/ ui/ modal/
49
+ echo "All checks passed."
50
+ ;;
51
+
52
+ app | run | *)
53
+ ensure_venv
54
+ "$PYTHON" app.py
55
+ ;;
56
+ esac
57
+
58
+ # Remove Python and linter cache dirs on exit.
59
+ cleanup() {
60
+ find "$ROOT_DIR" \
61
+ -not -path "$ROOT_DIR/.git/*" \
62
+ -not -path "$ROOT_DIR/.venv/*" \
63
+ \( -type d -name "__pycache__" -o -type d -name ".ruff_cache" \) \
64
+ -exec rm -rf {} + 2>/dev/null || true
65
+ }
66
+ trap cleanup EXIT
ui/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from __future__ import annotations
ui/examples.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from html import escape
4
+ import gradio as gr
5
+
6
+ # Examples cover math, science, reading, and writing support.
7
+ EXAMPLE_CARDS = [
8
+ {
9
+ "title": "Fraction Word Problem",
10
+ "grade": "Middle school",
11
+ "mode": "Coach me",
12
+ "text": "A recipe uses 3/4 cup of flour for one batch. How much flour is needed for 2 1/2 batches?",
13
+ },
14
+ {
15
+ "title": "Physics Units",
16
+ "grade": "High school",
17
+ "mode": "Step-by-step",
18
+ "text": "If a bike travels 18 meters in 6 seconds, what is its average speed? I keep mixing up the units.",
19
+ },
20
+ {
21
+ "title": "Reading Evidence",
22
+ "grade": "Middle school",
23
+ "mode": "Hint only",
24
+ "text": "The question asks which sentence best shows the narrator is nervous. How do I choose evidence without guessing?",
25
+ },
26
+ {
27
+ "title": "Essay Thesis",
28
+ "grade": "High school",
29
+ "mode": "Coach me",
30
+ "text": "My essay is about whether school uniforms help students focus. My thesis sounds too broad.",
31
+ },
32
+ ]
33
+
34
+
35
+ def _example_card_html(title: str, grade: str, text: str) -> str:
36
+ """Builds a compact example card with clear grade context."""
37
+ return (
38
+ '<div class="pt-example-copy">'
39
+ '<div class="pt-example-head">'
40
+ f"<span>{escape(title)}</span>"
41
+ f"<strong>{escape(grade)}</strong>"
42
+ "</div>"
43
+ f"<p>{escape(text)}</p>"
44
+ "</div>"
45
+ )
46
+
47
+
48
+ def _select_example(
49
+ text: str, grade: str, mode: str
50
+ ) -> tuple[None, str, None, str, str]:
51
+ """Populates the tutor form from an example card."""
52
+ return None, text, None, grade, mode
53
+
54
+
55
+ def render_examples(
56
+ image_input: gr.Image,
57
+ question_input: gr.Textbox,
58
+ audio_input: gr.Audio,
59
+ grade_input: gr.Dropdown,
60
+ mode_input: gr.Radio,
61
+ ) -> gr.Column:
62
+ """Renders examples and wires card buttons to the tutor form."""
63
+ with gr.Column(elem_classes=["pt-examples-section"]) as section:
64
+ gr.Markdown("## Try a Homework Scenario")
65
+ with gr.Row(elem_classes=["pt-example-grid"]):
66
+ for example in EXAMPLE_CARDS:
67
+ with gr.Column(elem_classes=["pt-example-card"]):
68
+ gr.HTML(
69
+ _example_card_html(
70
+ str(example["title"]),
71
+ str(example["grade"]),
72
+ str(example["text"]),
73
+ )
74
+ )
75
+ use_example = gr.Button(
76
+ "Use example",
77
+ size="sm",
78
+ elem_classes=["pt-example-btn"],
79
+ )
80
+ use_example.click(
81
+ fn=lambda text=str(example["text"]), grade=str(example["grade"]), mode=str(example["mode"]): (
82
+ _select_example(text, grade, mode)
83
+ ),
84
+ inputs=[],
85
+ outputs=[
86
+ image_input,
87
+ question_input,
88
+ audio_input,
89
+ grade_input,
90
+ mode_input,
91
+ ],
92
+ queue=False,
93
+ )
94
+ return section
ui/layout.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ import gradio as gr
5
+ from gradio.themes import Soft
6
+
7
+ from core.analyzer import analyze_homework_ui, reset_outputs
8
+ from env.config import APP_DESCRIPTION, APP_TITLE, GITHUB_URL, SPACE_URL
9
+ from ui.examples import render_examples
10
+
11
+
12
+ def get_theme() -> Any:
13
+ """Returns the custom soft theme configured for chalkboard teal styling."""
14
+ # Pair the CSS with a calm tutoring palette.
15
+ return Soft(primary_hue="teal", secondary_hue="amber", neutral_hue="slate")
16
+
17
+
18
+ def create_app() -> gr.Blocks:
19
+ """Creates and lays out the Gradio interface for Pocket Tutor."""
20
+ with gr.Blocks(title=APP_TITLE) as demo:
21
+ # Header gives the product promise without a marketing page.
22
+ gr.Markdown(f"# {APP_TITLE}\n{APP_DESCRIPTION}", elem_id="pt-header")
23
+ gr.Markdown(
24
+ "Snap a worksheet, ask by voice, or type the confusing line. The tutor explains the next useful step.",
25
+ elem_id="pt-kicker",
26
+ )
27
+
28
+ with gr.Row(elem_classes=["pt-main-grid"]):
29
+ # Left column collects multimodal homework context.
30
+ with gr.Column(scale=1, elem_classes=["pt-input-panel"]):
31
+ gr.Markdown("## Homework Input")
32
+ image_input = gr.Image(
33
+ label="Upload or capture a worksheet/photo",
34
+ type="filepath",
35
+ sources=["upload", "webcam", "clipboard"],
36
+ elem_classes=["pt-image-input"],
37
+ )
38
+ question_input = gr.Textbox(
39
+ label="What should we work on?",
40
+ lines=5,
41
+ placeholder="Type the problem, the line that confused you, or what you already tried.",
42
+ elem_id="pt-question-input",
43
+ )
44
+ audio_input = gr.Audio(
45
+ label="Ask with your microphone",
46
+ sources=["microphone", "upload"],
47
+ type="filepath",
48
+ elem_classes=["pt-audio-input"],
49
+ )
50
+ with gr.Row(elem_classes=["pt-control-row"]):
51
+ grade_input = gr.Dropdown(
52
+ ["Elementary", "Middle school", "High school", "College"],
53
+ value="Middle school",
54
+ label="Grade band",
55
+ )
56
+ mode_input = gr.Radio(
57
+ ["Coach me", "Hint only", "Step-by-step"],
58
+ value="Coach me",
59
+ label="Help mode",
60
+ )
61
+ run_button = gr.Button(
62
+ "Tutor This",
63
+ variant="primary",
64
+ elem_classes=["pt-run-btn"],
65
+ )
66
+
67
+ # Right column gives the step-by-step tutoring answer.
68
+ with gr.Column(scale=1, elem_classes=["pt-output-panel"]):
69
+ gr.Markdown("## Tutoring Plan")
70
+ problem_output = gr.Textbox(
71
+ label="Problem Read",
72
+ lines=4,
73
+ interactive=False,
74
+ elem_classes=["pt-output-card", "pt-problem-card"],
75
+ )
76
+ knowns_output = gr.Textbox(
77
+ label="Knowns",
78
+ lines=4,
79
+ interactive=False,
80
+ elem_classes=["pt-output-card", "pt-knowns-card"],
81
+ )
82
+ strategy_output = gr.Textbox(
83
+ label="Strategy",
84
+ lines=5,
85
+ interactive=False,
86
+ elem_classes=["pt-output-card", "pt-strategy-card"],
87
+ )
88
+
89
+ with gr.Column(elem_classes=["pt-analysis-section"]):
90
+ gr.Markdown("## Workbench")
91
+ with gr.Row(elem_classes=["pt-card-grid"]):
92
+ steps_output = gr.Textbox(
93
+ label="Worked Steps",
94
+ lines=7,
95
+ interactive=False,
96
+ elem_classes=["pt-output-card", "pt-steps-card"],
97
+ )
98
+ check_output = gr.Textbox(
99
+ label="Check",
100
+ lines=7,
101
+ interactive=False,
102
+ elem_classes=["pt-output-card", "pt-check-card"],
103
+ )
104
+ with gr.Row(elem_classes=["pt-card-grid"]):
105
+ hint_output = gr.Textbox(
106
+ label="Next Hint",
107
+ lines=5,
108
+ interactive=False,
109
+ elem_classes=["pt-output-card", "pt-hint-card"],
110
+ )
111
+ parent_output = gr.Textbox(
112
+ label="Parent Note",
113
+ lines=5,
114
+ interactive=False,
115
+ elem_classes=["pt-output-card", "pt-parent-card"],
116
+ )
117
+
118
+ render_examples(
119
+ image_input,
120
+ question_input,
121
+ audio_input,
122
+ grade_input,
123
+ mode_input,
124
+ )
125
+
126
+ gr.Markdown(
127
+ f"[GitHub repo]({GITHUB_URL}) | [Hugging Face Space]({SPACE_URL})",
128
+ elem_id="pt-links",
129
+ )
130
+
131
+ with gr.Accordion("Diagnostics & Local Execution Logs", open=False):
132
+ context_output = gr.Textbox(
133
+ label="Student context",
134
+ lines=4,
135
+ interactive=False,
136
+ elem_classes=["pt-log-box"],
137
+ )
138
+ model_output = gr.Textbox(
139
+ label="System execution logs",
140
+ lines=5,
141
+ interactive=False,
142
+ elem_classes=["pt-log-box"],
143
+ )
144
+
145
+ # Reset outputs immediately, then run the GPU-backed tutoring function.
146
+ reset_event = run_button.click(
147
+ fn=reset_outputs,
148
+ inputs=[],
149
+ outputs=[
150
+ context_output,
151
+ model_output,
152
+ problem_output,
153
+ knowns_output,
154
+ strategy_output,
155
+ steps_output,
156
+ check_output,
157
+ hint_output,
158
+ parent_output,
159
+ ],
160
+ queue=False,
161
+ )
162
+ reset_event.then(
163
+ fn=analyze_homework_ui,
164
+ inputs=[
165
+ image_input,
166
+ question_input,
167
+ audio_input,
168
+ grade_input,
169
+ mode_input,
170
+ ],
171
+ outputs=[
172
+ context_output,
173
+ model_output,
174
+ problem_output,
175
+ knowns_output,
176
+ strategy_output,
177
+ steps_output,
178
+ check_output,
179
+ hint_output,
180
+ parent_output,
181
+ ],
182
+ )
183
+
184
+ return demo
ui/styles.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ # Gradio CSS overrides create a distinct chalkboard tutoring interface.
4
+ CUSTOM_CSS = """
5
+ body, .gradio-container {
6
+ background-color: #071512 !important;
7
+ color: #e6fffb !important;
8
+ font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif !important;
9
+ }
10
+
11
+ #pt-header {
12
+ text-align: center;
13
+ margin: 0 auto 0.65rem auto;
14
+ padding: 0.35rem 0 0.65rem 0;
15
+ background: transparent !important;
16
+ border: none !important;
17
+ box-shadow: none !important;
18
+ }
19
+ #pt-header h1 {
20
+ color: #5eead4 !important;
21
+ font-size: 2.7rem !important;
22
+ font-weight: 750 !important;
23
+ letter-spacing: 0 !important;
24
+ margin-bottom: 0.35rem !important;
25
+ }
26
+ #pt-header p {
27
+ color: #a7f3d0 !important;
28
+ font-size: 1.08rem !important;
29
+ margin: 0 !important;
30
+ }
31
+ #pt-kicker {
32
+ width: fit-content;
33
+ max-width: 92%;
34
+ margin: 0 auto 1.5rem auto;
35
+ padding: 0.7rem 1.6rem !important;
36
+ background-color: rgba(20, 184, 166, 0.08) !important;
37
+ border: 1px solid rgba(94, 234, 212, 0.5) !important;
38
+ border-radius: 8px !important;
39
+ text-align: center;
40
+ color: #ccfbf1 !important;
41
+ }
42
+
43
+ .pt-main-grid, .pt-card-grid, .pt-example-grid, .pt-control-row {
44
+ gap: 1rem !important;
45
+ align-items: stretch !important;
46
+ }
47
+ .pt-main-grid > .form, .pt-main-grid > .row, .pt-main-grid > div,
48
+ .pt-card-grid > .form, .pt-card-grid > .row, .pt-card-grid > div,
49
+ .pt-example-grid > .form, .pt-example-grid > .row, .pt-example-grid > div {
50
+ display: flex !important;
51
+ flex-wrap: wrap !important;
52
+ gap: 1rem !important;
53
+ }
54
+ .pt-input-panel, .pt-output-panel, .pt-analysis-section, .pt-examples-section {
55
+ background-color: #0e211d !important;
56
+ border: 1px solid rgba(45, 212, 191, 0.25) !important;
57
+ border-radius: 8px !important;
58
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.28) !important;
59
+ padding: 1.15rem !important;
60
+ }
61
+ .pt-analysis-section, .pt-examples-section {
62
+ margin-top: 1rem !important;
63
+ }
64
+ .pt-input-panel, .pt-output-panel {
65
+ flex: 1 1 330px !important;
66
+ }
67
+ .pt-input-panel h3, .pt-output-panel h3, .pt-analysis-section h3, .pt-examples-section h3 {
68
+ color: #fef3c7 !important;
69
+ margin: 0 0 0.75rem 0 !important;
70
+ }
71
+
72
+ #pt-question-input textarea, .pt-log-box textarea {
73
+ background-color: #081714 !important;
74
+ color: #f8fafc !important;
75
+ border: 1px solid rgba(94, 234, 212, 0.28) !important;
76
+ border-radius: 8px !important;
77
+ line-height: 1.5 !important;
78
+ }
79
+ .pt-image-input, .pt-audio-input {
80
+ border-radius: 8px !important;
81
+ overflow: hidden !important;
82
+ }
83
+ .pt-run-btn {
84
+ background: #f59e0b !important;
85
+ color: #071512 !important;
86
+ border: none !important;
87
+ border-radius: 8px !important;
88
+ font-weight: 750 !important;
89
+ min-height: 50px !important;
90
+ }
91
+ .pt-run-btn:hover {
92
+ opacity: 0.9 !important;
93
+ }
94
+
95
+ .pt-output-card {
96
+ min-width: 0 !important;
97
+ background: transparent !important;
98
+ border: none !important;
99
+ box-shadow: none !important;
100
+ }
101
+ .pt-card-grid .block {
102
+ flex: 1 1 280px !important;
103
+ min-width: 0 !important;
104
+ }
105
+ .pt-output-card textarea {
106
+ background-color: #081714 !important;
107
+ color: #f8fafc !important;
108
+ border-radius: 8px !important;
109
+ font-size: 0.98rem !important;
110
+ line-height: 1.5 !important;
111
+ padding: 0.85rem !important;
112
+ overflow-wrap: anywhere !important;
113
+ }
114
+ .pt-problem-card textarea { border: 1px solid rgba(94, 234, 212, 0.48) !important; }
115
+ .pt-knowns-card textarea { border: 1px solid rgba(250, 204, 21, 0.48) !important; }
116
+ .pt-strategy-card textarea { border: 1px solid rgba(56, 189, 248, 0.48) !important; }
117
+ .pt-steps-card textarea { border: 1px solid rgba(167, 139, 250, 0.48) !important; }
118
+ .pt-check-card textarea { border: 1px solid rgba(52, 211, 153, 0.48) !important; }
119
+ .pt-hint-card textarea { border: 1px solid rgba(251, 146, 60, 0.48) !important; }
120
+ .pt-parent-card textarea { border: 1px solid rgba(244, 114, 182, 0.48) !important; }
121
+
122
+ .pt-example-card {
123
+ flex: 1 1 245px !important;
124
+ background-color: #081714 !important;
125
+ border: 1px solid rgba(148, 163, 184, 0.22) !important;
126
+ border-radius: 8px !important;
127
+ padding: 0.85rem !important;
128
+ }
129
+ .pt-example-copy {
130
+ display: flex;
131
+ flex-direction: column;
132
+ gap: 0.5rem;
133
+ }
134
+ .pt-example-head {
135
+ display: flex;
136
+ justify-content: space-between;
137
+ gap: 0.8rem;
138
+ color: #ccfbf1;
139
+ font-weight: 700;
140
+ }
141
+ .pt-example-head strong {
142
+ color: #fbbf24;
143
+ white-space: nowrap;
144
+ }
145
+ .pt-example-copy p {
146
+ color: #b6e7df;
147
+ margin: 0;
148
+ line-height: 1.45;
149
+ }
150
+ .pt-example-btn {
151
+ border-radius: 8px !important;
152
+ }
153
+ #pt-links {
154
+ text-align: center;
155
+ margin-top: 1rem;
156
+ color: #99f6e4 !important;
157
+ }
158
+ """