"""Docling document conversion for NAS files.""" from __future__ import annotations import json import logging import os from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone from pathlib import Path from typing import Any import psycopg2 log = logging.getLogger("doc-ingest.docling") _executor = ThreadPoolExecutor(max_workers=1) DB_HOST = os.getenv("DB_HOST", "10.4.7.18") DB_USER = os.getenv("DB_USER", "aissa") DB_PASSWORD = os.getenv("DB_PASSWORD", "Foodlinkk#2026") DB_NAME = os.getenv("DB_NAME", "foodlinkk") SUPPORTED_SUFFIXES = { ".pdf", ".docx", ".pptx", ".xlsx", ".html", ".htm", ".md", ".txt", ".png", ".jpg", ".jpeg", ".webp", ".tiff", ".tif", ".bmp", ".gif", } EXPORT_FORMATS = ("markdown", "html", "json", "doctags", "text", "yaml") def _db(): return psycopg2.connect(host=DB_HOST, user=DB_USER, password=DB_PASSWORD, dbname=DB_NAME) def file_signature(path: Path) -> str: st = path.stat() return f"{int(st.st_mtime)}_{st.st_size}" def capabilities() -> dict[str, Any]: try: import docling # noqa: F401 installed = True version = getattr(docling, "__version__", "unknown") except Exception: installed = False version = None return { "installed": installed, "version": version, "supported_suffixes": sorted(SUPPORTED_SUFFIXES), "export_formats": list(EXPORT_FORMATS), "pipeline_options": [ {"id": "ocr", "label": "OCR", "default": True}, {"id": "tables", "label": "Tabelstructuur", "default": True}, {"id": "page_images", "label": "Pagina-afbeeldingen", "default": False}, {"id": "picture_images", "label": "Figuur-afbeeldingen", "default": True}, {"id": "force_full_page_ocr", "label": "Volledige pagina OCR", "default": False}, ], } def _build_converter(options: dict[str, Any]): from docling.document_converter import DocumentConverter, PdfFormatOption from docling.datamodel.base_models import InputFormat from docling.datamodel.pipeline_options import PdfPipelineOptions pipeline_options = PdfPipelineOptions() pipeline_options.do_ocr = bool(options.get("ocr", True)) pipeline_options.do_table_structure = bool(options.get("tables", True)) pipeline_options.generate_page_images = bool(options.get("page_images", False)) pipeline_options.generate_picture_images = bool(options.get("picture_images", True)) if hasattr(pipeline_options, "force_full_page_ocr"): pipeline_options.force_full_page_ocr = bool(options.get("force_full_page_ocr", False)) return DocumentConverter( format_options={ InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options), } ) def _export_document(doc: Any, fmt: str) -> str | dict | list: if fmt == "markdown": return doc.export_to_markdown() if fmt == "html": return doc.export_to_html() if fmt == "json": if hasattr(doc, "export_to_dict"): return doc.export_to_dict() if hasattr(doc, "model_dump"): return doc.model_dump() return json.loads(doc.model_dump_json()) if hasattr(doc, "model_dump_json") else {} if fmt == "doctags": if hasattr(doc, "export_to_doctags"): return doc.export_to_doctags() if hasattr(doc, "export_to_document_tokens"): return doc.export_to_document_tokens() return "" if fmt == "text": if hasattr(doc, "export_to_text"): return doc.export_to_text() return doc.export_to_markdown() if fmt == "yaml": if hasattr(doc, "export_to_yaml"): return doc.export_to_yaml() return "" raise ValueError(f"Unknown export format: {fmt}") def _extract_tables(doc: Any) -> list[dict[str, Any]]: tables: list[dict[str, Any]] = [] for idx, table in enumerate(getattr(doc, "tables", []) or []): row: dict[str, Any] = {"index": idx} try: if hasattr(table, "export_to_markdown"): row["markdown"] = table.export_to_markdown() if hasattr(table, "export_to_dataframe"): df = table.export_to_dataframe() row["csv"] = df.to_csv(index=False) row["rows"] = df.values.tolist() row["columns"] = list(df.columns) except Exception as exc: row["error"] = str(exc)[:200] tables.append(row) return tables def _extract_pictures(doc: Any) -> list[dict[str, Any]]: pictures: list[dict[str, Any]] = [] for idx, pic in enumerate(getattr(doc, "pictures", []) or []): item: dict[str, Any] = {"index": idx} prov = getattr(pic, "prov", None) or [] if prov: item["page"] = getattr(prov[0], "page_no", None) if hasattr(pic, "caption_text"): item["caption"] = pic.caption_text() pictures.append(item) return pictures def convert_path(path: Path, options: dict[str, Any]) -> dict[str, Any]: if not path.is_file(): raise FileNotFoundError(str(path)) if path.suffix.lower() not in SUPPORTED_SUFFIXES: raise ValueError(f"Unsupported type: {path.suffix}") formats = options.get("formats") or ["markdown", "json"] formats = [f for f in formats if f in EXPORT_FORMATS] if not formats: formats = ["markdown"] converter = _build_converter(options) result = converter.convert(str(path)) doc = result.document exports: dict[str, Any] = {} for fmt in formats: try: exports[fmt] = _export_document(doc, fmt) except Exception as exc: exports[fmt] = {"error": str(exc)[:300]} meta = {} if hasattr(doc, "name"): meta["name"] = doc.name if hasattr(result, "input") and result.input: meta["input"] = str(result.input.file) if hasattr(result.input, "file") else str(result.input) return { "exports": exports, "tables": _extract_tables(doc), "pictures": _extract_pictures(doc), "page_count": len(getattr(doc, "pages", []) or []), "metadata": meta, } def _upsert_result( storage_path: str, file_sig: str, status: str, options: dict, payload: dict | None = None, error: str | None = None, ) -> int: payload = payload or {} conn = _db() conn.autocommit = True with conn.cursor() as cur: cur.execute( """ INSERT INTO docling_results (storage_path, file_sig, status, options, exports, tables_data, pictures, page_count, metadata, error_text, completed_at) VALUES (%s, %s, %s, %s::jsonb, %s::jsonb, %s::jsonb, %s::jsonb, %s, %s::jsonb, %s, %s) ON CONFLICT (storage_path, file_sig) DO UPDATE SET status = EXCLUDED.status, options = EXCLUDED.options, exports = EXCLUDED.exports, tables_data = EXCLUDED.tables_data, pictures = EXCLUDED.pictures, page_count = EXCLUDED.page_count, metadata = EXCLUDED.metadata, error_text = EXCLUDED.error_text, completed_at = EXCLUDED.completed_at RETURNING id """, ( storage_path, file_sig, status, json.dumps(options), json.dumps(payload.get("exports", {})), json.dumps(payload.get("tables", [])), json.dumps(payload.get("pictures", [])), payload.get("page_count", 0), json.dumps(payload.get("metadata", {})), error, datetime.now(timezone.utc) if status in ("done", "failed") else None, ), ) row_id = cur.fetchone()[0] conn.close() return row_id def get_result(storage_path: str, file_sig: str | None = None) -> dict | None: conn = _db() with conn.cursor() as cur: if file_sig: cur.execute( """ SELECT id, storage_path, file_sig, status, options, exports, tables_data, pictures, page_count, metadata, error_text, created_at, completed_at FROM docling_results WHERE storage_path = %s AND file_sig = %s """, (storage_path, file_sig), ) else: cur.execute( """ SELECT id, storage_path, file_sig, status, options, exports, tables_data, pictures, page_count, metadata, error_text, created_at, completed_at FROM docling_results WHERE storage_path = %s ORDER BY completed_at DESC NULLS LAST, created_at DESC LIMIT 1 """, (storage_path,), ) row = cur.fetchone() conn.close() if not row: return None cols = [ "id", "storage_path", "file_sig", "status", "options", "exports", "tables_data", "pictures", "page_count", "metadata", "error_text", "created_at", "completed_at", ] out = dict(zip(cols, row)) for key in ("created_at", "completed_at"): if out.get(key): out[key] = out[key].isoformat() return out def list_history(limit: int = 40) -> list[dict[str, Any]]: limit = max(1, min(limit, 100)) conn = _db() with conn.cursor() as cur: cur.execute( """ SELECT id, storage_path, file_sig, status, page_count, jsonb_array_length(COALESCE(tables_data, '[]'::jsonb)) AS table_count, jsonb_array_length(COALESCE(pictures, '[]'::jsonb)) AS picture_count, completed_at, error_text, LEFT(COALESCE(exports->>'markdown', ''), 120) AS md_preview FROM docling_results ORDER BY completed_at DESC NULLS LAST, created_at DESC LIMIT %s """, (limit,), ) rows = cur.fetchall() conn.close() cols = [ "id", "storage_path", "file_sig", "status", "page_count", "table_count", "picture_count", "completed_at", "error_text", "md_preview", ] out = [] for row in rows: item = dict(zip(cols, row)) if item.get("completed_at"): item["completed_at"] = item["completed_at"].isoformat() out.append(item) return out def convert_nas_file(nas_root: Path, rel_path: str, options: dict[str, Any]) -> dict[str, Any]: path = (nas_root / rel_path).resolve() if not str(path).startswith(str(nas_root.resolve())): raise ValueError("Path outside NAS root") sig = file_signature(path) _upsert_result(rel_path, sig, "running", options) try: payload = convert_path(path, options) job_id = _upsert_result(rel_path, sig, "done", options, payload) return {"ok": True, "id": job_id, "storage_path": rel_path, "file_sig": sig, **payload} except Exception as exc: job_id = _upsert_result(rel_path, sig, "failed", options, error=str(exc)[:500]) return {"ok": False, "id": job_id, "storage_path": rel_path, "error": str(exc)[:500]} def convert_nas_batch(nas_root: Path, rel_paths: list[str], options: dict[str, Any]) -> dict[str, Any]: results = [] ok_count = 0 for rel in rel_paths: try: out = convert_nas_file(nas_root, rel, options) if out.get("ok"): ok_count += 1 results.append(out) except Exception as exc: results.append({"ok": False, "storage_path": rel, "error": str(exc)[:300]}) return {"ok": True, "converted": ok_count, "total": len(rel_paths), "results": results}