This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
chaim 1051ed3332 feat(legal-aid): async parse jobs to survive multi-minute vision latency
Claude Vision via ai-gateway's claude-code OAuth provider runs ~140s per
rendered page (a 3-page report measured at 417s), which blows past any safe
synchronous HTTP timeout — the EspoCRM controller's 240s curl gave up with
"0 bytes received" while ai-gateway kept working.

parse-pdf-report now accepts ?mode=async: it returns {jobId, status:"pending"}
immediately and runs the parse in the background, persisting the result/error
to a file-based job store under /opt/data (survives multi-worker + restarts).
Poll GET /parse-pdf-report/jobs/{jobId}. Default sync mode is unchanged so
n8n and other server-side callers keep working.

Refs prod ai-gateway log id cee4c17c (claudeMs=417513).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:56:21 +00:00

451 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Legal Aid PDF parser endpoint.
Parses a monthly Legal Aid (סיוע משפטי) payment-report PDF and returns the
JSON shape that GreenInvoiceBilling's LegalAidPaymentReportService.ingest()
expects.
v3 (2026-05-03): switched extraction from pdfplumber to Claude Vision via
ai-gateway. The Legal Aid PDF format has multi-line cells, RTL Hebrew column
ordering quirks, and amounts that wrap across rows ("1478.5\\n4" → 1478.54).
pdfplumber's table detection couldn't get past those cleanly. Claude reads
the rendered page image natively and returns structured rows.
Pattern mirrors api/services/ocr.py — render each PDF page with PyMuPDF,
send the PNG to ai-gateway with a JSON-output prompt, then assemble the
result. Auth is X-Api-Key as for the other admin endpoints.
"""
from __future__ import annotations
import asyncio
import base64
import io
import json
import logging
import os
import re
import time
import uuid
from typing import Any
from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile
logger = logging.getLogger("shira.legal_aid")
router = APIRouter(prefix="/api/legal-aid", tags=["legal-aid"])
def _verify_admin(request: Request) -> None:
expected = os.environ.get("ADMIN_API_KEY") or os.environ.get("API_KEY", "")
if not expected:
raise HTTPException(status_code=503, detail="Admin API key not configured")
provided = request.headers.get("X-Admin-Key") or request.headers.get("X-Api-Key") or ""
if provided != expected:
raise HTTPException(status_code=401, detail="Unauthorized")
_PDF_RENDER_DPI = 180
_PDF_MAX_PAGES = 8 # safety cap; real reports are 1-2 pages
# --- Async job store ---------------------------------------------------
# Claude Vision via ai-gateway's claude-code OAuth provider runs ~140s per
# rendered page (a 3-page report measured at 417s). That far exceeds the
# EspoCRM controller's synchronous curl timeout, so manual uploads run as a
# background job: POST ?mode=async returns a jobId immediately and the caller
# polls GET /parse-pdf-report/jobs/{jobId}. Jobs are persisted as files under
# /opt/data (a named volume) so they survive across uvicorn workers and short
# restarts. n8n / other server-side callers can still use the default sync mode.
_JOBS_DIR = os.environ.get("LEGAL_AID_JOBS_DIR", "/opt/data/legal-aid-jobs")
_JOB_TTL_SECONDS = 3600 # finished jobs are reaped after 1h
_running_jobs: set[asyncio.Task] = set()
def _jobs_dir() -> str:
os.makedirs(_JOBS_DIR, exist_ok=True)
return _JOBS_DIR
def _job_path(job_id: str) -> str:
# job_id is a server-generated uuid hex; sanitize defensively anyway so a
# crafted id from the status route can never escape the jobs directory.
safe = re.sub(r"[^a-f0-9]", "", job_id.lower())
if not safe:
safe = "_invalid_"
return os.path.join(_jobs_dir(), f"{safe}.json")
def _write_job(job_id: str, data: dict[str, Any]) -> None:
path = _job_path(job_id)
tmp = f"{path}.tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
os.replace(tmp, path) # atomic publish
def _read_job(job_id: str) -> dict[str, Any] | None:
path = _job_path(job_id)
if not os.path.exists(path):
return None
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except (OSError, ValueError):
return None
def _cleanup_old_jobs() -> None:
try:
now = time.time()
for name in os.listdir(_jobs_dir()):
if not name.endswith(".json"):
continue
p = os.path.join(_JOBS_DIR, name)
try:
if now - os.path.getmtime(p) > _JOB_TTL_SECONDS:
os.remove(p)
except OSError:
pass
except OSError:
pass
_EXTRACT_PROMPT = """\
אתה מחלץ נתונים מדוח תשלומים חודשי של הסיוע המשפטי (Legal Aid, עברית RTL).
תוציא JSON תקני בלבד (בלי backticks, בלי טקסט נוסף) בסכמה הזו:
{
"issuerOffice": "Jerusalem" | "TelAviv" | "Haifa" | "BeerSheva" | "Nazareth" | "Center" | "National" | null,
"reportDate": "YYYY-MM-DD" | null,
"reportMonth": "YYYY-MM" | null,
"totalExclVat": number,
"totalVat": number,
"totalInclVat": number,
"lines": [
{
"invoiceNumber": "<16 ספרות מתוך 'מספר חשבונית'>",
"requestNumber": "<7 ספרות מתוך 'מספר בקשה'>",
"legalAidCaseNumber": "<7 ספרות מתוך 'מספר תיק'>",
"clientName": "<שם לקוח בעברית, מאוחד אם נשבר לשני שורות>",
"activityCategory": "<עמודת 'פעולה' — קטגוריית האב, למשל: הודעה, לימוד ענין, התיעצות / פגישה, בקשה, דיון>",
"activitySubTypeHebrew": "<עמודת 'תת סוג פעולה' — תווית מלאה, למשל: הודעה בהתאם להחלטה, לימוד ענין מלא, התייעצות עם מסמכים>",
"activityDate": "<עמודת 'תאריך פעילות' בפורמט YYYY-MM-DD>",
"amountExclVat": <עמודת 'סכום' (ללא מע\"מ) כמספר עשרוני>,
"vat": <עמודת 'מע\"מ' כמספר עשרוני>,
"amountInclVat": <עמודת 'סה\"כ' כמספר עשרוני>
}
],
"travelReimbursementLines": []
}
הנחיות חשובות:
- הטבלה הראשית בעמוד 1 היא "פעולות חייבות במע\"מ" — חלץ ממנה את כל השורות.
- אם תא בטבלה משתרע על שתי שורות (כמו "הודעה בהתאם" + "להחלטה"), אחד אותם לטקסט אחד עם רווח.
- אם סכום נשבר לשורות (כמו "1478.5" ו-"4"), אחד אותם ל-1478.54.
- "מספר חשבונית" הוא תמיד 16 ספרות; "מספר בקשה" ו-"מספר תיק" 7 ספרות כל אחד.
- שדה reportMonth מתוך "עבור חודש: M/YYYY""YYYY-MM".
- שדה reportDate מתוך התאריך בכותרת הראשית של הדוח (DD/MM/YYYY → YYYY-MM-DD).
- אם אין שדה — null. אם הטבלה ריקה (כמו טבלת נסיעות בעמוד 2) — אל תכניס את שורותיה ל-lines.
- ה-issuerOffice לפי "מחוז ירושלים""Jerusalem" וכו'.
החזר JSON בלבד.
"""
async def _parse_pdf_to_payload(
pdf_bytes: bytes, subject: str | None, debug: bool
) -> dict[str, Any]:
"""Render the PDF pages and run Claude Vision to produce the ingest payload.
Shared by both the synchronous route and the async job runner. Raises
HTTPException on any failure (the job runner converts those to a stored
error status).
"""
if not pdf_bytes:
raise HTTPException(status_code=400, detail="Empty file")
if pdf_bytes[:4] != b"%PDF":
raise HTTPException(status_code=400, detail="File is not a PDF")
try:
import fitz # PyMuPDF
except ImportError as e:
raise HTTPException(status_code=503, detail=f"PyMuPDF not installed: {e}")
images_b64: list[tuple[str, str]] = []
page_count = 0
try:
with fitz.open(stream=pdf_bytes, filetype="pdf") as doc:
page_count = min(len(doc), _PDF_MAX_PAGES)
for i in range(page_count):
pix = doc[i].get_pixmap(dpi=_PDF_RENDER_DPI)
# JPEG (not PNG): a rendered table page is mostly white, and
# PNG of it is several × larger than a q85 JPEG. Bundling all
# pages as PNG blew past ai-gateway's body limit (413). JPEG
# q85 keeps the table text crisp enough for Claude Vision.
jpg = pix.tobytes("jpeg", jpg_quality=85)
images_b64.append(("image/jpeg", base64.b64encode(jpg).decode("ascii")))
except Exception as e:
logger.exception("Legal Aid PDF render failed")
raise HTTPException(status_code=422, detail=f"PDF render error: {e}")
if not images_b64:
raise HTTPException(status_code=422, detail="PDF contained no pages")
raw_response = await _call_vision_for_json(images_b64, _EXTRACT_PROMPT)
parsed = _parse_json_response(raw_response)
sysref = None
if subject:
m = re.search(r"\[סימוכין\s+(\d+)\]", subject)
if m:
sysref = m.group(1)
out: dict[str, Any] = {
"subject": subject,
"sysref": sysref,
"issuerOffice": parsed.get("issuerOffice"),
"reportDate": parsed.get("reportDate"),
"reportMonth": parsed.get("reportMonth"),
"totalExclVat": _to_float(parsed.get("totalExclVat")),
"totalVat": _to_float(parsed.get("totalVat")),
"totalInclVat": _to_float(parsed.get("totalInclVat")),
"lines": _normalize_lines(parsed.get("lines") or []),
"travelReimbursementLines": parsed.get("travelReimbursementLines") or [],
}
if debug:
out["_debug"] = {
"pageCount": page_count,
"rawResponse": raw_response,
}
return out
async def _run_parse_job(
job_id: str, pdf_bytes: bytes, subject: str | None, debug: bool
) -> None:
"""Background runner: parse the PDF and persist the result/error to disk."""
try:
result = await _parse_pdf_to_payload(pdf_bytes, subject, debug)
_write_job(
job_id,
{"jobId": job_id, "status": "done", "result": result, "finishedAt": time.time()},
)
except HTTPException as e:
_write_job(
job_id,
{"jobId": job_id, "status": "error", "detail": str(e.detail), "finishedAt": time.time()},
)
except Exception as e: # noqa: BLE001 — anything unexpected becomes a stored error
logger.exception("Legal Aid async parse job %s failed", job_id)
_write_job(
job_id,
{"jobId": job_id, "status": "error", "detail": str(e), "finishedAt": time.time()},
)
@router.post("/parse-pdf-report")
async def parse_pdf_report(
request: Request,
file: UploadFile = File(..., description="Legal Aid monthly payment report PDF"),
subject: str | None = None,
mode: str = Query(default="sync", description="'sync' (inline) or 'async' (job + poll)"),
debug: bool = Query(default=False),
) -> dict[str, Any]:
"""Parse a Legal Aid monthly payment-report PDF via Claude Vision.
Auth: X-Api-Key.
Body: multipart with `file` (the PDF bytes).
Optional `subject` query: if supplied, used to extract `[סימוכין NNN]` →
sysref. Manual uploads typically omit this.
Optional `mode=async`: returns `{jobId, status:"pending"}` immediately and
runs the parse in the background; poll `/parse-pdf-report/jobs/{jobId}`.
Default `sync` runs inline (the vision call can take several minutes —
only safe for callers without a short request timeout, e.g. n8n).
Optional `debug=true`: also returns the raw model response + page count.
"""
_verify_admin(request)
pdf_bytes = await file.read()
# Validate up front so an obviously-bad upload fails fast in BOTH modes
# (rather than surfacing as a background-job error a moment later).
if not pdf_bytes:
raise HTTPException(status_code=400, detail="Empty file")
if pdf_bytes[:4] != b"%PDF":
raise HTTPException(status_code=400, detail="File is not a PDF")
if mode == "async":
_cleanup_old_jobs()
job_id = uuid.uuid4().hex
_write_job(job_id, {"jobId": job_id, "status": "pending", "createdAt": time.time()})
task = asyncio.create_task(_run_parse_job(job_id, pdf_bytes, subject, debug))
_running_jobs.add(task) # keep a strong ref so the task isn't GC'd mid-run
task.add_done_callback(_running_jobs.discard)
return {"jobId": job_id, "status": "pending"}
return await _parse_pdf_to_payload(pdf_bytes, subject, debug)
@router.get("/parse-pdf-report/jobs/{job_id}")
async def get_parse_job(request: Request, job_id: str) -> dict[str, Any]:
"""Poll an async parse job. Returns `{status: pending|done|error, ...}`.
`done` carries `result` (the ingest payload); `error` carries `detail`.
"""
_verify_admin(request)
job = _read_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found (expired or invalid)")
return job
# ----------------------------------------------------------------------
# AI Gateway / Claude Vision call
# ----------------------------------------------------------------------
async def _call_vision_for_json(
images_b64: list[tuple[str, str]], prompt: str
) -> str:
"""Send page images to Claude Vision via ai-gateway and return raw text.
Mirrors the pattern in api/services/ocr.py — uses the OpenAI-compatible
chat completions endpoint that ai-gateway exposes at /v1.
"""
try:
from openai import AsyncOpenAI
except ImportError as e:
raise HTTPException(status_code=503, detail=f"openai sdk missing: {e}")
base_url = os.environ.get("AI_GATEWAY_URL", "http://localhost:3000").rstrip("/") + "/v1"
api_key = os.environ.get("AI_GATEWAY_API_KEY", "")
if not api_key:
raise HTTPException(
status_code=503,
detail="AI_GATEWAY_API_KEY is not configured on shira-hermes",
)
client = AsyncOpenAI(base_url=base_url, api_key=api_key)
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
for mime, b64 in images_b64:
content.append(
{
"type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"},
}
)
try:
response = await client.chat.completions.create(
model=os.environ.get("CLAUDE_MODEL", "sonnet"),
messages=[{"role": "user", "content": content}],
max_tokens=4096,
temperature=0,
)
except Exception as e:
logger.exception("Legal Aid: ai-gateway call failed")
raise HTTPException(status_code=502, detail=f"ai-gateway error: {e}")
return (response.choices[0].message.content or "").strip()
# ----------------------------------------------------------------------
# Response parsing + normalization
# ----------------------------------------------------------------------
def _parse_json_response(text: str) -> dict[str, Any]:
"""Extract a JSON object from the model's raw text.
Handles a few common Claude failure modes:
- markdown fences (```json ... ```)
- trailing commentary after the JSON block
- smart-quotes in keys/values
"""
if not text:
return {}
# Strip code fences first.
fence = re.match(r"^\s*```(?:json)?\s*\n(.*?)\n?\s*```\s*$", text, re.DOTALL)
if fence:
text = fence.group(1)
# Take the substring from first `{` to matching last `}`.
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1 or end <= start:
logger.warning("Legal Aid: no JSON object found in model response: %r", text[:300])
return {}
candidate = text[start : end + 1]
try:
return json.loads(candidate)
except json.JSONDecodeError:
# Try one fix-up pass: replace smart quotes, then retry.
cleaned = (
candidate.replace("", '"')
.replace("", '"')
.replace("", "'")
.replace("", "'")
)
try:
return json.loads(cleaned)
except json.JSONDecodeError as e:
logger.warning(
"Legal Aid: model response is not valid JSON: %s; raw=%r",
e,
candidate[:300],
)
return {}
def _normalize_lines(lines: list[Any]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for raw in lines:
if not isinstance(raw, dict):
continue
# Drop rows that don't carry the minimum identifying info.
if not (raw.get("invoiceNumber") or raw.get("legalAidCaseNumber")):
continue
out.append(
{
"invoiceNumber": _str_or_none(raw.get("invoiceNumber")),
"requestNumber": _str_or_none(raw.get("requestNumber")),
"legalAidCaseNumber": _str_or_none(raw.get("legalAidCaseNumber")),
"clientName": _str_or_none(raw.get("clientName")),
"activityCategory": _str_or_none(raw.get("activityCategory")),
"activitySubTypeHebrew": _str_or_none(raw.get("activitySubTypeHebrew")),
"activityDate": _str_or_none(raw.get("activityDate")),
"amountExclVat": _to_float(raw.get("amountExclVat")),
"vat": _to_float(raw.get("vat")),
"amountInclVat": _to_float(raw.get("amountInclVat")),
}
)
return out
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
def _to_float(v: Any) -> float:
if v is None:
return 0.0
if isinstance(v, (int, float)):
return float(v)
s = str(v).replace(",", "").replace("", "").strip()
try:
return float(s)
except ValueError:
return 0.0
def _str_or_none(v: Any) -> str | None:
if v is None:
return None
s = str(v).strip()
return s or None