diff --git a/modules/services/exam_extract.py b/modules/services/exam_extract.py new file mode 100644 index 0000000..583c31b --- /dev/null +++ b/modules/services/exam_extract.py @@ -0,0 +1,73 @@ +"""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 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) diff --git a/tests/test_exam_extract_mapping.py b/tests/test_exam_extract_mapping.py new file mode 100644 index 0000000..6c434e4 --- /dev/null +++ b/tests/test_exam_extract_mapping.py @@ -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"]