_run_service_extract_merge now records extraction_meta {engine, slug, audit
(cover-total reconciliation), counts} on the template (migration 76) — a durable
trust signal the setup UI shows. New GET /templates/{id}/digital-text proxies the
service's /api/replica for the paper (resolved via extraction_meta.slug), returning
the digital-replica markdown. exam_extract.get_replica client added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
87 lines
3.4 KiB
Python
87 lines
3.4 KiB
Python
"""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)
|