Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ba72e4259 | ||
|
|
e48dd73fdf | ||
|
|
547836e04b | ||
|
|
df128508a3 | ||
|
|
81bf44c6cc | ||
|
|
544d858f62 | ||
|
|
7d1876b799 | ||
|
|
c79a161119 | ||
|
|
08af7c8ca1 | ||
|
|
98210f2cff |
@@ -0,0 +1,14 @@
|
||||
-- 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.';
|
||||
@@ -55,6 +55,8 @@ services:
|
||||
- CC_COMPOSE_SERVICE=backend-dev
|
||||
- RUN_INIT=false
|
||||
- INIT_MODE=infra
|
||||
# P2: route exam auto-map through the spike's full recognition pipeline (extraction service)
|
||||
- EXAM_EXTRACT_URL=${EXAM_EXTRACT_URL:-http://192.168.0.203:8899}
|
||||
ports:
|
||||
- "18000:8000"
|
||||
depends_on:
|
||||
|
||||
@@ -53,6 +53,7 @@ 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
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Client for the exam extraction SERVICE (docling-exam-spike, P1).
|
||||
|
||||
The service runs the spike's FULL recognition pipeline (textlayer → question tree → OMR/figure/table
|
||||
sidecars → structure fusion → analyse) and returns the ghost-region contract the app already consumes.
|
||||
This module is a thin HTTP client: POST the paper, poll, return the `analyse` suggestions. The app's
|
||||
auto-map merges them (coordinate-adapted, id-remapped) in routers/exam/templates.py.
|
||||
|
||||
Config: EXAM_EXTRACT_URL (e.g. http://192.168.0.203:8899). If unset, the app keeps its thin first-pass.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from modules.logger_tool import initialise_logger
|
||||
|
||||
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), "default", True)
|
||||
|
||||
|
||||
class ExtractError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def service_url() -> Optional[str]:
|
||||
url = os.getenv("EXAM_EXTRACT_URL")
|
||||
return url.rstrip("/") if url else None
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
return bool(service_url())
|
||||
|
||||
|
||||
def _get(base: str, slug: str, timeout: int = 30) -> Dict[str, Any]:
|
||||
r = requests.get(f"{base}/api/extract/{slug}", timeout=timeout)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def get_replica(slug: str, timeout: int = 30) -> Dict[str, Any]:
|
||||
"""The digital-replica markdown for a paper (P4): {slug, title, n_questions, total_marks, markdown,
|
||||
questions:[{label, marks, markdown}]}. Raises ExtractError if the paper has no replica yet."""
|
||||
base = service_url()
|
||||
if not base:
|
||||
raise ExtractError("EXAM_EXTRACT_URL not configured")
|
||||
r = requests.get(f"{base}/api/replica/{slug}", timeout=timeout)
|
||||
if r.status_code == 404:
|
||||
raise ExtractError(f"no digital replica for {slug}")
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def extract_suggestions(slug: str, pdf_bytes: bytes, *, force: bool = False,
|
||||
poll_timeout: int = 1500, poll_interval: int = 5) -> Dict[str, Any]:
|
||||
"""POST the paper to the service and poll until the analyse contract is ready.
|
||||
|
||||
Returns the full analyse payload: {status, coordinate_space:'page_fraction', margins,
|
||||
suggestions:{questions[], response_areas[], boundaries[]}, meta}. Raises ExtractError on
|
||||
failure/timeout. A cold paper is ~15 min (Docling per masked page); cached papers return instantly.
|
||||
"""
|
||||
base = service_url()
|
||||
if not base:
|
||||
raise ExtractError("EXAM_EXTRACT_URL not configured")
|
||||
payload = {"slug": slug, "pdf_b64": base64.b64encode(pdf_bytes).decode(), "force": force}
|
||||
r = requests.post(f"{base}/api/extract", json=payload, timeout=120)
|
||||
r.raise_for_status()
|
||||
started = r.json()
|
||||
if not started.get("ok", True):
|
||||
raise ExtractError(started.get("error") or "service rejected the request")
|
||||
# cached → fetch the contract straight away; otherwise poll the running job
|
||||
deadline = time.time() + poll_timeout
|
||||
while True:
|
||||
d = _get(base, slug)
|
||||
status = d.get("status")
|
||||
if status == "complete":
|
||||
if not (d.get("suggestions") or {}):
|
||||
raise ExtractError("service returned complete with no suggestions")
|
||||
return d
|
||||
if status == "error":
|
||||
raise ExtractError(f"extraction failed: {d.get('error')}")
|
||||
if time.time() >= deadline:
|
||||
raise ExtractError(f"extraction timed out after {poll_timeout}s (slug={slug})")
|
||||
time.sleep(poll_interval)
|
||||
@@ -83,6 +83,7 @@ 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
|
||||
@@ -99,6 +100,7 @@ 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
|
||||
@@ -249,6 +251,7 @@ 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 (
|
||||
@@ -362,7 +365,7 @@ async def update_plan(
|
||||
updates: Dict[str, Any] = {}
|
||||
for field in (
|
||||
"title", "class_id", "subject", "year_group", "estimated_duration_minutes",
|
||||
"objectives", "activities", "status", "tags", "topic_code",
|
||||
"objectives", "activities", "homework", "status", "tags", "topic_code",
|
||||
"whiteboard_room_id", "course_id", "sequence_number",
|
||||
):
|
||||
val = getattr(body, field)
|
||||
@@ -425,7 +428,7 @@ async def deliver_plan(
|
||||
|
||||
plan_res = (
|
||||
sb.supabase.table("planned_lessons")
|
||||
.select("id, whiteboard_room_id")
|
||||
.select("id, whiteboard_room_id, homework")
|
||||
.eq("id", plan_id)
|
||||
.eq("institute_id", institute_id)
|
||||
.single()
|
||||
@@ -456,6 +459,19 @@ 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, notes, status (teacher-owned)
|
||||
PATCH /lessons/{id} — update lesson_plan, homework, notes, status (teacher-owned)
|
||||
"""
|
||||
import os
|
||||
from collections import defaultdict
|
||||
@@ -44,6 +44,7 @@ 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
|
||||
|
||||
@@ -310,7 +311,7 @@ async def get_lessons(
|
||||
lessons = (
|
||||
sb.supabase.table("taught_lessons")
|
||||
.select(
|
||||
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,notes,whiteboard_room_id,"
|
||||
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,homework,notes,whiteboard_room_id,"
|
||||
"class_id,academic_period_id"
|
||||
)
|
||||
.eq("teacher_id", user_id)
|
||||
@@ -418,13 +419,15 @@ async def update_lesson(
|
||||
body: UpdateLessonRequest,
|
||||
credentials: dict = Depends(SupabaseBearer()),
|
||||
) -> Dict[str, Any]:
|
||||
"""Teacher updates their own lesson content: plan, notes, status."""
|
||||
"""Teacher updates their own lesson content: plan, homework, 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:
|
||||
@@ -508,7 +511,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,notes,whiteboard_room_id,"
|
||||
"id,date,period_code,week_cycle,day_of_week,status,lesson_plan,homework,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")
|
||||
.select("id,neo4j_node_id,academic_period_id,teacher_id,date,period_code,week_cycle,day_of_week,status,homework")
|
||||
.eq("institute_id", institute_id)
|
||||
.execute()
|
||||
.data or []
|
||||
@@ -728,6 +728,7 @@ 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,
|
||||
@@ -736,6 +737,7 @@ 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
|
||||
|
||||
@@ -80,6 +80,9 @@ class ResponseAreaPayload(BaseModel):
|
||||
] = None
|
||||
# Optional Context differentiation (v1 generic; future graph/chart/data_table/diagram/code_block/passage).
|
||||
context_type: Optional[str] = None
|
||||
# Rich recognition payload (75-exam-marker-region-meta.sql): figure name/description, OMR geometry, unit…
|
||||
# Carried on canvas save so a named context figure survives a round-trip.
|
||||
meta: Optional[Dict[str, Any]] = None
|
||||
source: Literal["manual", "ai"] = "manual"
|
||||
confirmed: bool = True
|
||||
confidence: Optional[float] = Field(default=None, ge=0, le=1)
|
||||
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
@@ -30,6 +31,7 @@ from modules.database.services.exam_projection import project_template, project_
|
||||
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
|
||||
from modules.database.supabase.utils.storage import StorageAdmin
|
||||
from modules.upload_validation import read_pdf_upload_bytes
|
||||
from modules.services import exam_extract
|
||||
from modules.logger_tool import initialise_logger
|
||||
from routers.exam.dependencies import ExamContext, get_exam_context, lookup_exam_code
|
||||
from routers.exam.schemas import (
|
||||
@@ -455,6 +457,32 @@ def _y_to_canvas(y_value: float, page_number: int, pages: List[Dict[str, float]]
|
||||
return round(g["page_top"] + (g["page_pt_h"] - (float(y_value) - g["crop_y0"])) / g["page_pt_h"] * g["rendered_h"], 2)
|
||||
|
||||
|
||||
def _frac_box_to_canvas(bounds: Optional[Dict[str, Any]], page_number: int,
|
||||
pages: List[Dict[str, float]]) -> Optional[Dict[str, float]]:
|
||||
"""Page-fraction {x,y,w,h} (0..1 per page, from the extraction service) → 780-wide stacked canvas."""
|
||||
if not bounds:
|
||||
return None
|
||||
g = _page_geom(pages, page_number)
|
||||
try:
|
||||
x, y, w, h = (float(bounds["x"]), float(bounds["y"]), float(bounds["w"]), float(bounds["h"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
return {
|
||||
"x": round(x * g["rendered_w"], 2),
|
||||
"y": round(g["page_top"] + y * g["rendered_h"], 2),
|
||||
"w": round(w * g["rendered_w"], 2),
|
||||
"h": round(h * g["rendered_h"], 2),
|
||||
}
|
||||
|
||||
|
||||
def _frac_y_to_canvas(y_frac: Any, page_number: int, pages: List[Dict[str, float]]) -> Optional[float]:
|
||||
g = _page_geom(pages, page_number)
|
||||
try:
|
||||
return round(g["page_top"] + float(y_frac) * g["rendered_h"], 2)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _ai_id(template_id: str, *parts: Any) -> str:
|
||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, "/".join(["cc-auto-map", template_id, *[str(p) for p in parts]])))
|
||||
|
||||
@@ -660,6 +688,144 @@ def _run_auto_map_job(job_id: str, ctx: ExamContext, template_id: str, pdf_bytes
|
||||
_set_auto_map_status(job_id, {"status": "failed", "template_id": template_id, "error": str(exc)})
|
||||
|
||||
|
||||
_ALLOWED_RESPONSE_FORMS = {"lines", "answer-box", "working", "diagram", "tick-boxes", "table", "blanks"}
|
||||
_ALLOWED_ANSWER_TYPES = {"written", "mcq", "short", "diagram"}
|
||||
_ALLOWED_KINDS = {"response", "context", "question_number", "mark_area", "reference", "furniture"}
|
||||
_BOARD_RE = re.compile(r"^(aqa|edexcel|ocr|wjec|eduqas|ccea)-")
|
||||
|
||||
|
||||
def _extract_slug(ctx: ExamContext, template: Dict[str, Any]) -> str:
|
||||
"""A stable, board-prefixed slug for the extraction-service cache. Prefer the catalogue exam_code
|
||||
(e.g. 'AQA-8463-1H-2022JUN-QP' → 'aqa-8463-1h-2022jun-qp' → board 'aqa' for the right margins);
|
||||
fall back to a template-id slug (structure.py then defaults to AQA content-box margins)."""
|
||||
code = None
|
||||
exam_id = template.get("exam_id")
|
||||
if exam_id:
|
||||
try:
|
||||
row = _first(ctx.supabase.table("eb_exams").select("exam_code").eq("id", exam_id).limit(1).execute())
|
||||
code = (row or {}).get("exam_code")
|
||||
except Exception as exc:
|
||||
logger.info(f"extract slug: eb_exams lookup failed for {exam_id}: {exc}")
|
||||
slug = re.sub(r"[^a-z0-9._-]+", "-", (code or "").lower()).strip("-")
|
||||
if _BOARD_RE.match(slug):
|
||||
return slug
|
||||
return f"aqa-tmpl-{str(template.get('id') or '')[:12]}"
|
||||
|
||||
|
||||
def _map_service_contract_to_rows(template_id: str, contract: Dict[str, Any],
|
||||
pdf_bytes: bytes) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Map the extraction service's page-fraction analyse contract onto the app's canvas-space ghost rows.
|
||||
|
||||
Coordinates: page-fraction → the 780-wide stacked canvas. IDs: the service's deterministic uuid5s are
|
||||
re-namespaced per template via _ai_id so two templates of the same paper don't collide and a re-map of
|
||||
the same template re-emits stable ids (so _refresh_ai_rows preserves confirmed ghosts). FK-safe: parts
|
||||
whose parent/owner question is absent are de-parented / dropped rather than crashing the insert.
|
||||
"""
|
||||
pages = _pdf_page_geometry(pdf_bytes)
|
||||
sug = contract.get("suggestions") or {}
|
||||
|
||||
def qid(uid: Any) -> str:
|
||||
return _ai_id(template_id, "svc-q", uid)
|
||||
|
||||
questions: List[Dict[str, Any]] = []
|
||||
q_ids: set = set()
|
||||
for q in sug.get("questions") or []:
|
||||
uid = q.get("uid")
|
||||
if not uid:
|
||||
continue
|
||||
rid = qid(uid)
|
||||
q_ids.add(rid)
|
||||
at = q.get("answer_type") if q.get("answer_type") in _ALLOWED_ANSWER_TYPES else None
|
||||
questions.append({
|
||||
"id": rid, "template_id": template_id,
|
||||
"parent_id": qid(q["parent_uid"]) if q.get("parent_uid") else None,
|
||||
"label": q.get("label") or "?", "order": q.get("order", len(questions)),
|
||||
"max_marks": _safe_marks(q.get("max_marks")), "answer_type": at,
|
||||
"is_container": bool(q.get("is_container")),
|
||||
"bounds": _frac_box_to_canvas(q.get("bounds"), q.get("page") or 1, pages),
|
||||
"page": q.get("page"), "source": "ai", "confirmed": False,
|
||||
"confidence": _safe_confidence(q.get("confidence")), "derivation": "extract-service",
|
||||
# analyse contract v2 (migration 78): command verb + stem prose per part
|
||||
"command_word": (q.get("command_word") or None) if not q.get("is_container") else None,
|
||||
"preamble": q.get("preamble") or None,
|
||||
})
|
||||
for q in questions: # FK safety: de-parent a dangling parent_id
|
||||
if q["parent_id"] and q["parent_id"] not in q_ids:
|
||||
q["parent_id"] = None
|
||||
|
||||
response_areas: List[Dict[str, Any]] = []
|
||||
for ra in sug.get("response_areas") or []:
|
||||
uid = ra.get("uid")
|
||||
quid = qid(ra.get("question_uid") or "")
|
||||
if not uid or quid not in q_ids: # orphan region → drop (FK safety)
|
||||
continue
|
||||
bounds = _frac_box_to_canvas(ra.get("bounds"), ra.get("page") or 1, pages)
|
||||
if not bounds:
|
||||
continue
|
||||
kind = ra.get("kind") if ra.get("kind") in _ALLOWED_KINDS else "response"
|
||||
form = ra.get("response_form") if ra.get("response_form") in _ALLOWED_RESPONSE_FORMS else None
|
||||
response_areas.append({
|
||||
"id": _ai_id(template_id, "svc-ra", uid), "template_id": template_id,
|
||||
"question_id": quid, "page": ra.get("page"), "bounds": bounds,
|
||||
"kind": kind, "response_form": form if kind == "response" else None,
|
||||
"context_type": ra.get("context_type"), "meta": ra.get("meta") or {},
|
||||
"source": "ai", "confirmed": False,
|
||||
"confidence": _safe_confidence(ra.get("confidence")), "derivation": "extract-service",
|
||||
})
|
||||
|
||||
boundaries: List[Dict[str, Any]] = []
|
||||
for i, b in enumerate(sug.get("boundaries") or []):
|
||||
quid = qid(b.get("question_uid") or "")
|
||||
if quid not in q_ids:
|
||||
continue
|
||||
page_index = b.get("page_index")
|
||||
y = _frac_y_to_canvas(b.get("y"), (page_index or 0) + 1, pages)
|
||||
if y is None:
|
||||
continue
|
||||
boundaries.append({
|
||||
"id": _ai_id(template_id, "svc-b", b.get("question_uid") or i), "template_id": template_id,
|
||||
"question_id": quid, "label": b.get("label") or "", "page_index": page_index,
|
||||
"y": y, "bounds": None, "source": "ai", "confirmed": False,
|
||||
"confidence": _safe_confidence(b.get("confidence")), "derivation": "extract-service",
|
||||
})
|
||||
|
||||
return {"questions": questions, "response_areas": response_areas, "boundaries": boundaries}
|
||||
|
||||
|
||||
def _run_service_extract_merge(ctx: ExamContext, template_id: str, pdf_bytes: bytes, slug: str) -> Dict[str, List[Dict[str, Any]]]:
|
||||
contract = exam_extract.extract_suggestions(slug, pdf_bytes)
|
||||
rows = _map_service_contract_to_rows(template_id, contract, pdf_bytes)
|
||||
_refresh_ai_rows(ctx, template_id, rows)
|
||||
meta = contract.get("meta") or {}
|
||||
# P3: record provenance + the audit cover-reconciliation gate so the setup UI can flag under-reads,
|
||||
# and store the slug so the digital-text view can resolve this paper's replica.
|
||||
updates: Dict[str, Any] = {"extraction_meta": {
|
||||
"engine": "extract-service", "slug": slug, "audit": meta.get("audit") or {},
|
||||
"counts": {k: len(v) for k, v in rows.items()},
|
||||
# question-layout signals for the setup UI: paper sections (e.g. A-level Section A/B) + EITHER/OR choices
|
||||
"sections": meta.get("sections") or [],
|
||||
"choice_groups": meta.get("choice_groups") or [],
|
||||
"marks_confidence": meta.get("marks_confidence"),
|
||||
}}
|
||||
n_pages = meta.get("n_pages") or meta.get("pages")
|
||||
if n_pages:
|
||||
updates["page_count"] = n_pages
|
||||
ctx.supabase.table("exam_templates").update(updates).eq("id", template_id).execute()
|
||||
return rows
|
||||
|
||||
|
||||
def _run_service_extract_job(job_id: str, ctx: ExamContext, template_id: str, pdf_bytes: bytes, slug: str) -> None:
|
||||
_set_auto_map_status(job_id, {"status": "running", "template_id": template_id, "engine": "extract-service", "slug": slug})
|
||||
try:
|
||||
rows = _run_service_extract_merge(ctx, template_id, pdf_bytes, slug)
|
||||
project_template_safe(template_id)
|
||||
_set_auto_map_status(job_id, {"status": "completed", "template_id": template_id,
|
||||
"engine": "extract-service", "counts": {k: len(v) for k, v in rows.items()}})
|
||||
except Exception as exc:
|
||||
logger.exception(f"extract-service job failed for template {template_id}: {exc}")
|
||||
_set_auto_map_status(job_id, {"status": "failed", "template_id": template_id, "engine": "extract-service", "error": str(exc)})
|
||||
|
||||
|
||||
# ─── templates ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -837,6 +1003,14 @@ async def auto_map_template(
|
||||
raise HTTPException(status_code=409, detail="Template has recorded marks; auto-map structural refresh is blocked.")
|
||||
bucket, path, pdf_bytes = _resolve_template_source(ctx, template)
|
||||
source_label = f"{bucket}/{path}"
|
||||
# Extraction-service path (P2): when EXAM_EXTRACT_URL is set, route auto-map through the spike's full
|
||||
# recognition pipeline instead of the thin first-pass. Always async — a cold paper is ~15 min.
|
||||
if exam_extract.is_enabled():
|
||||
slug = _extract_slug(ctx, template)
|
||||
job_id = str(uuid.uuid4())
|
||||
_set_auto_map_status(job_id, {"status": "queued", "template_id": template_id, "engine": "extract-service", "slug": slug})
|
||||
background_tasks.add_task(_run_service_extract_job, job_id, ctx, template_id, pdf_bytes, slug)
|
||||
return JSONResponse(status_code=202, content={"status": "accepted", "job_id": job_id, "engine": "extract-service"})
|
||||
try:
|
||||
fast_path = _pdf_has_text_layer(pdf_bytes)
|
||||
except Exception as exc:
|
||||
@@ -875,6 +1049,24 @@ async def auto_map_status(
|
||||
return body
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}/digital-text")
|
||||
async def template_digital_text(
|
||||
template_id: str,
|
||||
ctx: ExamContext = Depends(get_exam_context),
|
||||
) -> Dict[str, Any]:
|
||||
"""P4: the digital-replica markdown for this template's paper (stem text, parts, marks, answer-space
|
||||
placeholders, spec refs). Resolves the paper via the slug the extraction used."""
|
||||
template = _fetch_template_or_404(ctx, template_id)
|
||||
_require_source_visibility_or_404(ctx, template)
|
||||
if not exam_extract.is_enabled():
|
||||
raise HTTPException(status_code=503, detail="Extraction service not configured")
|
||||
slug = (template.get("extraction_meta") or {}).get("slug") or _extract_slug(ctx, template)
|
||||
try:
|
||||
return exam_extract.get_replica(slug)
|
||||
except exam_extract.ExtractError as exc:
|
||||
raise HTTPException(status_code=404, detail=f"No digital text yet — run auto-map first ({exc})")
|
||||
|
||||
|
||||
@router.put("/templates/{template_id}")
|
||||
async def replace_template(
|
||||
template_id: str,
|
||||
@@ -955,6 +1147,7 @@ async def replace_template(
|
||||
"kind": ra.kind,
|
||||
"response_form": ra.response_form,
|
||||
"context_type": ra.context_type, # 73: optional Context differentiation
|
||||
"meta": ra.meta, # 75: rich recognition payload (name/description/OMR/…)
|
||||
"source": ra.source,
|
||||
"confirmed": ra.confirmed,
|
||||
"confidence": ra.confidence,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Test the extraction-service contract → app ghost-row mapping (P2).
|
||||
|
||||
Mocks the PDF geometry so the mapper is tested in isolation: page-fraction → 780-canvas conversion,
|
||||
per-template uuid remap, FK-safety (orphan regions / dangling parents), and rich meta passthrough.
|
||||
"""
|
||||
import routers.exam.templates as T
|
||||
|
||||
TID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
# two pages, each rendered 780 wide × 1000 tall; page 2 stacked below page 1
|
||||
_GEOM = [
|
||||
{"rendered_w": 780.0, "rendered_h": 1000.0, "page_top": 0.0, "page_pt_w": 595.0, "page_pt_h": 842.0, "crop_x0": 0.0, "crop_y0": 0.0},
|
||||
{"rendered_w": 780.0, "rendered_h": 1000.0, "page_top": 1000.0, "page_pt_w": 595.0, "page_pt_h": 842.0, "crop_x0": 0.0, "crop_y0": 0.0},
|
||||
]
|
||||
|
||||
_CONTRACT = {
|
||||
"coordinate_space": "page_fraction",
|
||||
"suggestions": {
|
||||
"questions": [
|
||||
{"uid": "Q1", "label": "1", "order": 0, "max_marks": 5, "is_container": True, "page": 1},
|
||||
{"uid": "Q1a", "parent_uid": "Q1", "label": "1(a)", "order": 1, "max_marks": 3,
|
||||
"answer_type": "short", "is_container": False, "page": 1},
|
||||
{"uid": "Q1b", "parent_uid": "Q1", "label": "1(b)", "order": 2, "max_marks": 2,
|
||||
"answer_type": "mcq", "is_container": False, "page": 2},
|
||||
],
|
||||
"response_areas": [
|
||||
{"uid": "RA1", "question_uid": "Q1a", "page": 1, "kind": "response", "response_form": "answer-box",
|
||||
"confidence": 0.9, "bounds": {"x": 0.1, "y": 0.2, "w": 0.5, "h": 0.05},
|
||||
"meta": {"unit": "m/s", "quantity": "v", "n_lines": 1}},
|
||||
{"uid": "RA2", "question_uid": "Q1b", "page": 2, "kind": "response", "response_form": "tick-boxes",
|
||||
"bounds": {"x": 0.2, "y": 0.3, "w": 0.4, "h": 0.2},
|
||||
"meta": {"select_n": 2, "n_options": 5, "boxes": [{"x0": 0.2, "y0": 0.3, "fill": 0.8}]}},
|
||||
{"uid": "CTX1", "question_uid": "Q1b", "page": 2, "kind": "context", "response_form": None,
|
||||
"bounds": {"x": 0.1, "y": 0.1, "w": 0.6, "h": 0.15}},
|
||||
{"uid": "ORPH", "question_uid": "GHOST", "page": 1, "kind": "response", "response_form": "lines",
|
||||
"bounds": {"x": 0.1, "y": 0.5, "w": 0.5, "h": 0.1}}, # orphan owner → must be dropped
|
||||
],
|
||||
"boundaries": [
|
||||
{"question_uid": "Q1", "label": "1", "page_index": 0, "y": 0.18, "confidence": 0.85},
|
||||
],
|
||||
},
|
||||
"meta": {"n_pages": 2},
|
||||
}
|
||||
|
||||
|
||||
def test_contract_maps_to_canvas_rows(monkeypatch):
|
||||
monkeypatch.setattr(T, "_pdf_page_geometry", lambda _b: _GEOM)
|
||||
rows = T._map_service_contract_to_rows(TID, _CONTRACT, b"%PDF-1.4")
|
||||
|
||||
q = rows["questions"]
|
||||
assert len(q) == 3
|
||||
container = next(x for x in q if x["label"] == "1")
|
||||
a = next(x for x in q if x["label"] == "1(a)")
|
||||
assert container["is_container"] is True and container["parent_id"] is None
|
||||
assert a["parent_id"] == container["id"] # parent remapped to the container's per-template id
|
||||
assert a["answer_type"] == "short" and a["max_marks"] == 3
|
||||
assert all(x["source"] == "ai" and x["confirmed"] is False for x in q)
|
||||
|
||||
ra = rows["response_areas"]
|
||||
assert len(ra) == 3 # orphan (owner GHOST) dropped
|
||||
ids = {r["id"] for r in ra}
|
||||
assert len(ids) == 3 # ids unique
|
||||
box = next(r for r in ra if r["response_form"] == "answer-box")
|
||||
# page-fraction → 780-canvas: x=0.1*780=78, y=0.2*1000=200, w=0.5*780=390, h=0.05*1000=50
|
||||
assert box["bounds"] == {"x": 78.0, "y": 200.0, "w": 390.0, "h": 50.0}
|
||||
assert box["meta"]["unit"] == "m/s" # rich meta preserved
|
||||
tick = next(r for r in ra if r["response_form"] == "tick-boxes")
|
||||
# page 2 y stacked: 0.3*1000 + page_top(1000) = 1300
|
||||
assert tick["bounds"]["y"] == 1300.0 and tick["meta"]["select_n"] == 2
|
||||
ctx = next(r for r in ra if r["kind"] == "context")
|
||||
assert ctx["response_form"] is None # context carries no answer form
|
||||
assert all(r["question_id"] in {x["id"] for x in q} for r in ra) # FK-safe
|
||||
|
||||
b = rows["boundaries"]
|
||||
assert len(b) == 1 and b[0]["y"] == 180.0 and b[0]["question_id"] == container["id"]
|
||||
Reference in New Issue
Block a user