Merge P2: auto-map via extraction service
Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled
Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled
This commit is contained in:
commit
08af7c8ca1
73
modules/services/exam_extract.py
Normal file
73
modules/services/exam_extract.py
Normal file
@ -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)
|
||||
75
tests/test_exam_extract_mapping.py
Normal file
75
tests/test_exam_extract_mapping.py
Normal file
@ -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"]
|
||||
Loading…
x
Reference in New Issue
Block a user