feat(cv-import): add bounded model-free Docling extraction
This commit is contained in:
+65
-10
@@ -110,6 +110,10 @@ PARSER_CHILD_PATH = Path(__file__).with_name("parser_child.py")
|
||||
PARSER_TIMEOUT_SECONDS = max(5, min(int(os.getenv("PARSER_TIMEOUT_SECONDS", "25")), 120))
|
||||
PARSER_CPU_SECONDS = max(2, min(int(os.getenv("PARSER_CPU_SECONDS", "20")), PARSER_TIMEOUT_SECONDS))
|
||||
PARSER_MEMORY_BYTES = max(256, min(int(os.getenv("PARSER_MEMORY_MIB", "512")), 2048)) * 1024 * 1024
|
||||
CV_IMPORT_PYTHON = os.getenv("CV_IMPORT_PYTHON", sys.executable)
|
||||
CV_IMPORT_TIMEOUT_SECONDS = max(5, min(int(os.getenv("CV_IMPORT_TIMEOUT_SECONDS", "40")), 120))
|
||||
CV_IMPORT_CPU_SECONDS = max(2, min(int(os.getenv("CV_IMPORT_CPU_SECONDS", "30")), CV_IMPORT_TIMEOUT_SECONDS))
|
||||
CV_IMPORT_MEMORY_BYTES = max(512, min(int(os.getenv("CV_IMPORT_MEMORY_MIB", "1024")), 2048)) * 1024 * 1024
|
||||
PARSER_MAX_RESULT_BYTES = 1024 * 1024
|
||||
_parser_capacity = threading.BoundedSemaphore(value=1)
|
||||
PARSER_TEMP_ROOT = Path(os.getenv("PARSER_TEMP_ROOT", tempfile.gettempdir()), "jobtracker-cv-parser").resolve()
|
||||
@@ -1184,6 +1188,15 @@ _PARSER_ERRORS = {
|
||||
"docx_entry_size": (422, "The DOCX contains an oversized file."),
|
||||
"docx_ratio": (422, "The DOCX compression ratio is not supported."),
|
||||
"docx_expanded_size": (422, "The DOCX expands beyond the supported size."),
|
||||
"docx_unsafe_archive": (422, "The DOCX archive structure is not supported."),
|
||||
"pdf_signature": (400, "The uploaded file does not match its PDF extension."),
|
||||
"pdf_invalid": (422, "The PDF is malformed or unsupported."),
|
||||
"pdf_encrypted": (422, "Encrypted PDFs are not supported."),
|
||||
"pdf_object_limit": (422, "The PDF structure exceeds the supported limits."),
|
||||
"external_content": (422, "Active or external document content is not supported."),
|
||||
"block_limit": (422, "The document contains too many text blocks."),
|
||||
"file_size_limit": (400, "The uploaded file size is not supported."),
|
||||
"docling_unavailable": (503, "Document conversion runtime is unavailable."),
|
||||
"pdf_page_limit": (422, "The PDF contains too many pages."),
|
||||
"pdf_ocr_limit": (422, "The PDF requires too much OCR processing."),
|
||||
"image_dimensions": (422, "The document image dimensions are not supported."),
|
||||
@@ -1203,6 +1216,9 @@ def _parser_environment(temp_root: str) -> dict[str, str]:
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"OMP_NUM_THREADS": "1",
|
||||
"OPENBLAS_NUM_THREADS": "1",
|
||||
"HF_HUB_OFFLINE": "1",
|
||||
"TRANSFORMERS_OFFLINE": "1",
|
||||
"HF_HUB_DISABLE_TELEMETRY": "1",
|
||||
"TMPDIR": temp_root,
|
||||
"TEMP": temp_root,
|
||||
"TMP": temp_root,
|
||||
@@ -1259,7 +1275,7 @@ def _raise_parser_error(code: str) -> None:
|
||||
raise HTTPException(status_code=status_code, detail=detail)
|
||||
|
||||
|
||||
def _run_parser_child(extension: str, data: bytes) -> dict[str, object]:
|
||||
def _run_parser_child(extension: str, data: bytes, *, document: bool = False) -> dict[str, object]:
|
||||
if not _parser_capacity.acquire(blocking=False):
|
||||
raise HTTPException(status_code=503, detail="Document extraction is busy. Try again shortly.")
|
||||
try:
|
||||
@@ -1268,14 +1284,16 @@ def _run_parser_child(extension: str, data: bytes) -> dict[str, object]:
|
||||
output_path = Path(temp_root, "result.json")
|
||||
input_path.write_bytes(data)
|
||||
command = [
|
||||
sys.executable,
|
||||
CV_IMPORT_PYTHON if document else sys.executable,
|
||||
str(PARSER_CHILD_PATH),
|
||||
"--input", str(input_path),
|
||||
"--output", str(output_path),
|
||||
"--extension", extension,
|
||||
"--cpu-seconds", str(PARSER_CPU_SECONDS),
|
||||
"--memory-bytes", str(PARSER_MEMORY_BYTES),
|
||||
"--cpu-seconds", str(CV_IMPORT_CPU_SECONDS if document else PARSER_CPU_SECONDS),
|
||||
"--memory-bytes", str(CV_IMPORT_MEMORY_BYTES if document else PARSER_MEMORY_BYTES),
|
||||
]
|
||||
if document:
|
||||
command.extend(["--mode", "document"])
|
||||
creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
@@ -1291,7 +1309,7 @@ def _run_parser_child(extension: str, data: bytes) -> dict[str, object]:
|
||||
except OSError:
|
||||
_raise_parser_error("parser_failed")
|
||||
try:
|
||||
process.wait(timeout=PARSER_TIMEOUT_SECONDS)
|
||||
process.wait(timeout=CV_IMPORT_TIMEOUT_SECONDS if document else PARSER_TIMEOUT_SECONDS)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
_terminate_parser_process(process)
|
||||
try:
|
||||
@@ -1311,13 +1329,35 @@ def _run_parser_child(extension: str, data: bytes) -> dict[str, object]:
|
||||
text = result.get("text")
|
||||
if not isinstance(text, str) or not text or len(text) > MAX_EXTRACT_CHARS:
|
||||
_raise_parser_error("parser_failed")
|
||||
if document:
|
||||
_validate_document_result(result)
|
||||
return result
|
||||
finally:
|
||||
_parser_capacity.release()
|
||||
|
||||
|
||||
@app.post("/extract-text")
|
||||
async def extract_text(file: UploadFile = File(...)):
|
||||
def _validate_document_result(result: dict[str, object]) -> None:
|
||||
blocks = result.get("blocks")
|
||||
warnings = result.get("warnings")
|
||||
page_count = result.get("page_count")
|
||||
if (result.get("method") not in {"docling-docx-v1", "docling-native-pdf-v1"}
|
||||
or result.get("ocr_used") is not False
|
||||
or not isinstance(blocks, list) or not 1 <= len(blocks) <= 5000
|
||||
or not isinstance(warnings, list) or len(warnings) > 16
|
||||
or any(not isinstance(warning, str) or not re.fullmatch(r"[a-z0-9_]{1,100}", warning) for warning in warnings)
|
||||
or (page_count is not None and (type(page_count) is not int or not 1 <= page_count <= 40))):
|
||||
_raise_parser_error("parser_failed")
|
||||
for index, block in enumerate(blocks):
|
||||
if (not isinstance(block, dict) or block.get("id") != f"b{index + 1:04d}"
|
||||
or not isinstance(block.get("text"), str) or not block["text"].strip()
|
||||
or block.get("kind") not in {"paragraph", "section_header", "title", "list_item", "table"}
|
||||
or (block.get("page") is not None and (type(block["page"]) is not int or page_count is None or not 1 <= block["page"] <= page_count))):
|
||||
_raise_parser_error("parser_failed")
|
||||
if "\n".join(block["text"] for block in blocks) != result.get("text"):
|
||||
_raise_parser_error("parser_failed")
|
||||
|
||||
|
||||
async def _extract_upload(file: UploadFile, *, document: bool = False):
|
||||
filename = file.filename or "document"
|
||||
extension = "." + filename.rsplit(".", 1)[1].lower() if "." in filename else ""
|
||||
data = bytearray()
|
||||
@@ -1331,14 +1371,15 @@ async def extract_text(file: UploadFile = File(...)):
|
||||
raise HTTPException(status_code=400, detail="The uploaded file was empty.")
|
||||
if len(data) > MAX_EXTRACT_FILE_BYTES:
|
||||
raise HTTPException(status_code=400, detail="The uploaded file is too large for AI extraction.")
|
||||
if extension not in {".txt", ".md", ".docx", ".pdf", *IMAGE_EXTENSIONS}:
|
||||
supported = {".docx", ".pdf"} if document else {".txt", ".md", ".docx", ".pdf", *IMAGE_EXTENSIONS}
|
||||
if extension not in supported:
|
||||
raise HTTPException(status_code=400, detail="This file type is not supported for AI extraction.")
|
||||
_validate_file_signature(extension, data)
|
||||
|
||||
result = await asyncio.to_thread(_run_parser_child, extension, data)
|
||||
result = await asyncio.to_thread(_run_parser_child, extension, data, document=True) if document else await asyncio.to_thread(_run_parser_child, extension, data)
|
||||
text = result["text"]
|
||||
|
||||
return {
|
||||
response = {
|
||||
"text": text,
|
||||
"ocr_used": result.get("ocr_used", False),
|
||||
"content_type": file.content_type,
|
||||
@@ -1346,3 +1387,17 @@ async def extract_text(file: UploadFile = File(...)):
|
||||
"characters": len(text),
|
||||
"file_name": filename,
|
||||
}
|
||||
if document:
|
||||
_validate_document_result(result)
|
||||
response.update({key: result[key] for key in ("blocks", "warnings", "method")})
|
||||
return response
|
||||
|
||||
|
||||
@app.post("/extract-text")
|
||||
async def extract_text(file: UploadFile = File(...)):
|
||||
return await _extract_upload(file)
|
||||
|
||||
|
||||
@app.post("/cv-import/extract")
|
||||
async def extract_cv_document(file: UploadFile = File(...)):
|
||||
return await _extract_upload(file, document=True)
|
||||
|
||||
Reference in New Issue
Block a user