Compare commits

..
Author SHA1 Message Date
kcar 931092672c spike: preserve exam spec-tagging prototype 2026-08-09 19:17:08 +00:00
8 changed files with 139 additions and 45 deletions
+7
View File
@@ -0,0 +1,7 @@
Feature: add GET /database/timetable/timetables endpoint for TimetableListPage.
- Added router file: routers/database/timetable/timetables.py
- Wired new timetable router from run/routers.py
- Provided pragmatic /database/timetables/timetables GET and GET/{id} shapes that return Timetable lists
Note: TimetableListPage uses /database/timetable/timetables (singular timetable segment) even though the underlying router is now under /database/timetables. Curl checks below use that path.
+2 -2
View File
@@ -16,7 +16,7 @@ question labels from a RapidOCR per-page pass. v2 generalises across exam boards
per-part marks (N).
* OCR <- sequential top-level integers followed by question text, parts (a)/(i),
marks [N]; `(b)*` flags an extended-response part.
* REGIONS <- Docling layout labels mapped to taxonomy + gemma4:e4b `answer_regions`
* REGIONS <- Docling layout labels mapped to taxonomy + gemma4:e4b-131k `answer_regions`
(taxonomy #3 — the one structure no deterministic pass emits) merged by part.
* TABLES <- Docling `tables` carried through; parts on a table page flagged has_table.
* COVERAGE <- recall vs a ground-truth label set: built-in physics GT (regression guard)
@@ -611,7 +611,7 @@ def _norm_region_type(kind):
def merge_gemma(parts, gemma_dir):
"""Attach gemma4:e4b answer_regions (#3) to parts by for_part; gap-fill missing marks."""
"""Attach gemma4:e4b-131k answer_regions (#3) to parts by for_part; gap-fill missing marks."""
n_reg = n_fill = 0
for fn in sorted(glob.glob(os.path.join(gemma_dir, "p*.json"))):
d = json.load(open(fn))
@@ -1,14 +0,0 @@
-- G1: Homework as a first-class lesson/planner field.
-- Apply to DEV Supabase only before promoting app/api changes.
alter table if exists public.taught_lessons
add column if not exists homework text not null default '';
alter table if exists public.planned_lessons
add column if not exists homework text not null default '';
comment on column public.taught_lessons.homework is
'Teacher-authored homework for this taught lesson; surfaced in lesson/timetable views.';
comment on column public.planned_lessons.homework is
'Default homework carried by a lesson plan and copied to a taught lesson when delivered.';
@@ -53,7 +53,6 @@ class PlannedLessonNode(CCBaseNode):
subject: str
teacher_code: str
planning_status: str
homework: Optional[str] = None
topic_code: Optional[str] = None
topic_name: Optional[str] = None
lesson_code: Optional[str] = None
+123
View File
@@ -0,0 +1,123 @@
"""AI spec-point suggestion (R3.5.3) — classify each question's CROP to its AQA spec topic with a vision LLM.
The app's questions carry geometry (bounds) but no text, so we render the source PDF at the app's 780px
canvas width (fitz) — the same space the bounds live in — crop each question, and ask a vision model
(Ollama on the AI host) which topic it assesses, constrained to the paper's subject catalogue. Suggestions
are written to exam_questions.spec_ref by the caller (only where empty — never overwriting a teacher's tag);
the teacher confirms + syncs ASSESSES. Validated: qwen3-vl:4b classifies a question crop in ~4s.
"""
from __future__ import annotations
import base64
import io
import os
import re
from typing import Any, Dict, List, Optional, Tuple
import fitz # PyMuPDF
import requests
from PIL import Image
from modules.logger_tool import initialise_logger
from run.initialization.init_exam_graph import SPECIFICATIONS # import-safe: no Neo4j connection at import
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
CANVAS_WIDTH = 780 # the app renders the PDF (and emits bounds) at this width
VISION_MODEL = os.getenv("EXAM_SPECTAG_MODEL", "qwen3-vl:4b")
def _ollama_url() -> str:
explicit = os.getenv("OLLAMA_URL") or os.getenv("OLLAMA_BASE_URL")
if explicit:
return explicit.rstrip("/")
return f"http://{os.getenv('HOST_OLLAMA', '192.168.0.39')}:{os.getenv('PORT_OLLAMA', '11434')}"
def resolve_spec(subject: Optional[str], exam_code: Optional[str]) -> Optional[Dict[str, Any]]:
"""Find the seeded spec for a paper: match its code digits (e.g. '8463/1' → AQA-PHYS-8463),
else a unique subject match. Returns the SPECIFICATIONS entry or None."""
digits = re.sub(r"\D", "", exam_code or "")
for spec in SPECIFICATIONS:
code = re.sub(r"\D", "", spec["spec_code"])
if code and code in digits:
return spec
subj = (subject or "").strip().lower()
cands = [s for s in SPECIFICATIONS if s["subject"] == subj]
return cands[0] if len(cands) == 1 else None
def _render_pages(pdf_bytes: bytes) -> Tuple[List[Image.Image], List[int]]:
"""Render every page at CANVAS_WIDTH; return (page images, stacked-top y offsets)."""
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
pages: List[Image.Image] = []
tops: List[int] = []
acc = 0
for page in doc:
zoom = CANVAS_WIDTH / page.rect.width
pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom), alpha=False)
pages.append(Image.frombytes("RGB", (pix.width, pix.height), pix.samples))
tops.append(acc)
acc += pix.height
doc.close()
return pages, tops
def _crop_b64(pages: List[Image.Image], tops: List[int], page: Any, bounds: Dict[str, Any]) -> Optional[str]:
try:
idx = int(page) - 1
except (TypeError, ValueError):
return None
if idx < 0 or idx >= len(pages):
return None
img, top = pages[idx], tops[idx]
x = max(0, int(bounds.get("x", 0)))
y = max(0, int(bounds.get("y", 0)) - top)
x2 = min(x + int(bounds.get("w", img.width)), img.width)
y2 = min(y + int(bounds.get("h", 80)), img.height)
if x2 - x < 3 or y2 - y < 3:
return None
buf = io.BytesIO()
img.crop((x, y, x2, y2)).save(buf, "PNG")
return base64.b64encode(buf.getvalue()).decode()
def _classify(img_b64: str, spec: Dict[str, Any], valid: set) -> Optional[str]:
tlist = "\n".join(f"{ref} {name}" for ref, name in spec["topics"])
prompt = (
f"This is an AQA {spec['award_code']} {spec['subject'].title()} exam question. Which specification "
f"topic does it mainly assess?\n\nTOPICS:\n{tlist}\n\n"
f"Answer with ONLY the topic ref from [{' '.join(sorted(valid))}]. Ref only, no words."
)
body = {"model": VISION_MODEL, "prompt": prompt, "images": [img_b64], "stream": False,
"options": {"temperature": 0, "seed": 0}}
resp = requests.post(f"{_ollama_url()}/api/generate", json=body, timeout=90)
resp.raise_for_status()
text = resp.json().get("response", "")
for ref in re.findall(r"\d+\.\d+", text):
if ref in valid:
return ref
return None
def suggest(subject: Optional[str], exam_code: Optional[str], questions: List[Dict[str, Any]],
pdf_bytes: bytes, limit: int = 60) -> Tuple[Dict[str, str], Optional[str]]:
"""questions = [{id, page, bounds}] already filtered to un-tagged with bounds. Returns ({id: ref}, spec_code)."""
spec = resolve_spec(subject, exam_code)
if not spec:
return {}, None
valid = {ref for ref, _ in spec["topics"]}
pages, tops = _render_pages(pdf_bytes)
out: Dict[str, str] = {}
for q in questions[:limit]:
b64 = _crop_b64(pages, tops, q.get("page"), q.get("bounds") or {})
if not b64:
continue
try:
ref = _classify(b64, spec, valid)
except Exception as exc: # noqa: BLE001 - one bad crop/timeout must not sink the batch
logger.warning(f"spec-tag classify failed for question {q.get('id')}: {exc}")
continue
if ref:
out[q["id"]] = ref
return out, spec["spec_code"]
+2 -18
View File
@@ -83,7 +83,6 @@ class CreatePlanRequest(BaseModel):
estimated_duration_minutes: Optional[int] = None
objectives: Optional[List[Dict[str, Any]]] = None
activities: Optional[List[Dict[str, Any]]] = None
homework: Optional[str] = None
status: Optional[str] = "draft"
tags: Optional[List[str]] = None
topic_code: Optional[str] = None
@@ -100,7 +99,6 @@ class UpdatePlanRequest(BaseModel):
estimated_duration_minutes: Optional[int] = None
objectives: Optional[List[Dict[str, Any]]] = None
activities: Optional[List[Dict[str, Any]]] = None
homework: Optional[str] = None
status: Optional[str] = None
tags: Optional[List[str]] = None
topic_code: Optional[str] = None
@@ -251,7 +249,6 @@ async def create_plan(
"status": body.status or "draft",
"objectives": body.objectives or [],
"activities": body.activities or [],
"homework": body.homework or "",
"tags": body.tags or [],
}
for field in (
@@ -365,7 +362,7 @@ async def update_plan(
updates: Dict[str, Any] = {}
for field in (
"title", "class_id", "subject", "year_group", "estimated_duration_minutes",
"objectives", "activities", "homework", "status", "tags", "topic_code",
"objectives", "activities", "status", "tags", "topic_code",
"whiteboard_room_id", "course_id", "sequence_number",
):
val = getattr(body, field)
@@ -428,7 +425,7 @@ async def deliver_plan(
plan_res = (
sb.supabase.table("planned_lessons")
.select("id, whiteboard_room_id, homework")
.select("id, whiteboard_room_id")
.eq("id", plan_id)
.eq("institute_id", institute_id)
.single()
@@ -459,19 +456,6 @@ async def deliver_plan(
res = sb.supabase.table("lesson_deliveries").insert(row).execute()
delivery = (res.data or [{}])[0]
# If the plan has default homework and was delivered into a taught lesson,
# copy it onto the lesson so homework appears in daily/timetable views.
plan_homework = plan_res.data.get("homework")
if body.taught_lesson_id and plan_homework is not None:
try:
sb.supabase.table("taught_lessons").update({
"homework": plan_homework,
"updated_at": datetime.utcnow().isoformat(),
}).eq("id", body.taught_lesson_id).eq("teacher_id", user_id).execute()
except Exception as e:
logger.warning(f"Could not copy homework to taught_lesson {body.taught_lesson_id}: {e}")
logger.info(f"Lesson delivery created: {delivery.get('id')} for plan {plan_id} by {user_id}")
return delivery
@@ -4,7 +4,7 @@ Taught Lessons Router — materialization and lesson CRUD.
POST /materialize — slot template × academic_periods → taught_lessons rows
GET /lessons — teacher's lessons for a date range
GET /lessons/{id} — single lesson detail
PATCH /lessons/{id} — update lesson_plan, homework, notes, status (teacher-owned)
PATCH /lessons/{id} — update lesson_plan, notes, status (teacher-owned)
"""
import os
from collections import defaultdict
@@ -44,7 +44,6 @@ def _require_institute(user_id: str) -> str:
class UpdateLessonRequest(BaseModel):
lesson_plan: Optional[Dict[str, Any]] = None
homework: Optional[str] = None
notes: Optional[str] = None
status: Optional[str] = None # planned | in_progress | completed | cancelled | substituted
@@ -311,7 +310,7 @@ async def get_lessons(
lessons = (
sb.supabase.table("taught_lessons")
.select(
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,homework,notes,whiteboard_room_id,"
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,notes,whiteboard_room_id,"
"class_id,academic_period_id"
)
.eq("teacher_id", user_id)
@@ -419,15 +418,13 @@ async def update_lesson(
body: UpdateLessonRequest,
credentials: dict = Depends(SupabaseBearer()),
) -> Dict[str, Any]:
"""Teacher updates their own lesson content: plan, homework, notes, status."""
"""Teacher updates their own lesson content: plan, notes, status."""
user_id = credentials.get("sub", "")
sb = _sb()
updates: Dict[str, Any] = {}
if body.lesson_plan is not None:
updates["lesson_plan"] = body.lesson_plan
if body.homework is not None:
updates["homework"] = body.homework
if body.notes is not None:
updates["notes"] = body.notes
if body.status is not None:
@@ -511,7 +508,7 @@ async def get_student_lessons(
lessons = (
sb.supabase.table("taught_lessons")
.select(
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,homework,notes,whiteboard_room_id,"
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,notes,whiteboard_room_id,"
"class_id,academic_period_id,teacher_id"
)
.in_("class_id", class_ids)
@@ -675,7 +675,7 @@ def _sync_taught_lessons_to_neo4j(
"""
lessons = (
sb.supabase.table("taught_lessons")
.select("id,neo4j_node_id,academic_period_id,teacher_id,date,period_code,week_cycle,day_of_week,status,homework")
.select("id,neo4j_node_id,academic_period_id,teacher_id,date,period_code,week_cycle,day_of_week,status")
.eq("institute_id", institute_id)
.execute()
.data or []
@@ -728,7 +728,6 @@ def _sync_taught_lessons_to_neo4j(
SET tl.date = date($date), tl.period_code = $pcode,
tl.week_cycle = $wc, tl.day_of_week = $dow,
tl.status = $status,
tl.homework = $homework,
tl.node_storage_path = $path
""",
id=tl_id,
@@ -737,7 +736,6 @@ def _sync_taught_lessons_to_neo4j(
wc=lesson.get("week_cycle", ""),
dow=lesson.get("day_of_week", ""),
status=lesson.get("status", "planned"),
homework=lesson.get("homework") or "",
path=f"taught_lessons/{tl_id}",
)
count += 1