Codex commited on
Commit ·
0921a5d
0
Parent(s):
Build Pocket Tutor Space
Browse files- .gitignore +7 -0
- README.md +90 -0
- app.py +19 -0
- core/__init__.py +1 -0
- core/analyzer.py +186 -0
- core/inference.py +217 -0
- core/parser.py +166 -0
- env/__init__.py +1 -0
- env/config.py +22 -0
- env/runtime.py +49 -0
- modal/CARD.md +57 -0
- modal/dataset.py +158 -0
- modal/tune.py +174 -0
- pyrightconfig.json +4 -0
- requirements.txt +10 -0
- run.sh +66 -0
- ui/__init__.py +1 -0
- ui/examples.py +94 -0
- ui/layout.py +184 -0
- 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 |
+
"""
|