{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# PaperMate — Docling PDF Parser Server\n\nServe Docling + `granite-docling-258M` trên Kaggle GPU T4 x2.\n\n**Architecture:**\n- **1 GPU (cuda:0)** — Docling parse PDF → raw structured JSON (GPU/CPU bound)\n- **OpenAI gpt-4o-mini** — enrich formulas → LaTeX, tables → markdown, figures → description (network bound, tất cả parallel qua `asyncio.gather`, không liên quan GPU)\n\n**Output format:**\n- `POST /parse` trả về `{ structured: {...}, markdown: str, pages_total: int }`\n- `structured` = `{ meta: {...}, content: [...] }` — mỗi element có label, bbox, text và VLM-enriched fields\n\n**ntfy flow:**\n- Sau khi ngrok tunnel up, POST URL lên `ntfy.sh/papermate_pdf2md`\n- Backend local poll ntfy để lấy URL động — không cần hardcode trong .env\n\n**Kaggle secrets cần tạo:**\n- `ngrok_auth_token` — lấy từ dashboard.ngrok.com\n- `openai_api_key` — platform.openai.com (dùng gpt-4o-mini cho VLM enrichment)\n","metadata":{}},{"cell_type":"code","source":"!pip install -q fastapi nest-asyncio pyngrok uvicorn docling openai requests pypdf","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-25T10:43:00.709344Z","iopub.execute_input":"2026-06-25T10:43:00.709603Z","iopub.status.idle":"2026-06-25T10:43:34.015669Z","shell.execute_reply.started":"2026-06-25T10:43:00.709568Z","shell.execute_reply":"2026-06-25T10:43:34.014819Z"}},"outputs":[{"name":"stdout","text":"\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m162.6/162.6 kB\u001b[0m \u001b[31m5.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[?25h Preparing metadata (setup.py) ... \u001b[?25l\u001b[?25hdone\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m70.3/70.3 kB\u001b[0m \u001b[31m3.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m604.7/604.7 kB\u001b[0m \u001b[31m20.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m257.9/257.9 kB\u001b[0m \u001b[31m9.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m94.0/94.0 kB\u001b[0m \u001b[31m5.0 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m10.2/10.2 MB\u001b[0m \u001b[31m100.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m00:01\u001b[0m0:01\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m43.0/43.0 kB\u001b[0m \u001b[31m2.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m62.7/62.7 kB\u001b[0m \u001b[31m3.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m61.7/61.7 kB\u001b[0m \u001b[31m2.9 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m3.7/3.7 MB\u001b[0m \u001b[31m88.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m:00:01\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m253.0/253.0 kB\u001b[0m \u001b[31m14.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m472.8/472.8 kB\u001b[0m \u001b[31m29.4 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m27.2/27.2 MB\u001b[0m \u001b[31m70.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m:00:01\u001b[0m00:01\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m12.2/12.2 MB\u001b[0m \u001b[31m95.5 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m:00:01\u001b[0m0:01\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m2.0/2.0 MB\u001b[0m \u001b[31m63.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m79.2/79.2 kB\u001b[0m \u001b[31m4.1 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m11.2/11.2 MB\u001b[0m \u001b[31m98.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m:00:01\u001b[0m0:01\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m635.4/635.4 kB\u001b[0m \u001b[31m29.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m94.3/94.3 kB\u001b[0m \u001b[31m4.8 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m99.7/99.7 kB\u001b[0m \u001b[31m3.3 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m108.1/108.1 kB\u001b[0m \u001b[31m6.5 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m345.0/345.0 kB\u001b[0m \u001b[31m15.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m175.3/175.3 kB\u001b[0m \u001b[31m8.7 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[2K \u001b[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\u001b[0m \u001b[32m272.8/272.8 kB\u001b[0m \u001b[31m13.2 MB/s\u001b[0m eta \u001b[36m0:00:00\u001b[0m\n\u001b[?25h Building wheel for pylatexenc (setup.py) ... \u001b[?25l\u001b[?25hdone\n\u001b[31mERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.\ngoogle-adk 1.29.0 requires google-cloud-bigquery-storage>=2.0.0, which is not installed.\ndask-cuda 26.2.0 requires cuda-core==0.3.*, but you have cuda-core 1.0.1 which is incompatible.\ndask-cuda 26.2.0 requires numba-cuda<0.23.0,>=0.22.1, but you have numba-cuda 0.30.2 which is incompatible.\ndistributed-ucxx-cu12 0.48.0 requires numba-cuda[cu12]<0.23.0,>=0.22.1, but you have numba-cuda 0.30.2 which is incompatible.\ncuml-cu12 26.2.0 requires numba<0.62.0,>=0.60.0, but you have numba 0.65.1 which is incompatible.\ncuml-cu12 26.2.0 requires numba-cuda[cu12]<0.23.0,>=0.22.1, but you have numba-cuda 0.30.2 which is incompatible.\nucxx-cu12 0.48.0 requires numba-cuda[cu12]<0.23.0,>=0.22.1, but you have numba-cuda 0.30.2 which is incompatible.\ncudf-cu12 26.2.1 requires numba<0.62.0,>=0.60.0, but you have numba 0.65.1 which is incompatible.\ncudf-cu12 26.2.1 requires numba-cuda[cu12]<0.23.0,>=0.22.2, but you have numba-cuda 0.30.2 which is incompatible.\u001b[0m\u001b[31m\n\u001b[0m","output_type":"stream"}],"execution_count":1},{"cell_type":"code","source":"import torch\n\nnum_gpus = torch.cuda.device_count()\nprint(f'Available GPUs: {num_gpus}')\nfor i in range(num_gpus):\n print(f' cuda:{i} — {torch.cuda.get_device_name(i)}')\n\n# Only cuda:0 is used for Docling parsing\nprint('Using cuda:0 for Docling.')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-25T10:43:34.016847Z","iopub.execute_input":"2026-06-25T10:43:34.017198Z","iopub.status.idle":"2026-06-25T10:43:42.176692Z","shell.execute_reply.started":"2026-06-25T10:43:34.017155Z","shell.execute_reply":"2026-06-25T10:43:42.176035Z"}},"outputs":[{"name":"stdout","text":"Available GPUs: 2\n cuda:0 — Tesla T4\n cuda:1 — Tesla T4\nUsing cuda:0 for Docling.\n","output_type":"stream"}],"execution_count":2},{"cell_type":"code","source":"from kaggle_secrets import UserSecretsClient\n\nuser_secrets = UserSecretsClient()\nNGROK_AUTH_TOKEN = user_secrets.get_secret('ngrok_auth_token')\nOPENAI_API_KEY = user_secrets.get_secret('openai_api_key')\n\nNTFY_TOPIC = 'papermate_pdf2md'\n\nprint('Secrets loaded!')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-25T10:43:42.178409Z","iopub.execute_input":"2026-06-25T10:43:42.178839Z","iopub.status.idle":"2026-06-25T10:43:42.366174Z","shell.execute_reply.started":"2026-06-25T10:43:42.178814Z","shell.execute_reply":"2026-06-25T10:43:42.365476Z"}},"outputs":[{"name":"stdout","text":"Secrets loaded!\n","output_type":"stream"}],"execution_count":3},{"cell_type":"code","source":"import threading\nfrom docling.document_converter import DocumentConverter, PdfFormatOption\nfrom docling.datamodel.pipeline_options import PdfPipelineOptions\nfrom docling.datamodel.base_models import InputFormat\n\npipeline_opts = PdfPipelineOptions()\npipeline_opts.accelerator_options.device = 'cuda:0'\npipeline_opts.accelerator_options.num_threads = 4\npipeline_opts.images_scale = 2.0\npipeline_opts.generate_page_images = True\npipeline_opts.generate_picture_images = True\n\nconverter = DocumentConverter(\n format_options={\n InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_opts)\n }\n)\nconverter_lock = threading.Lock()\n\nvram = torch.cuda.memory_allocated(0) / 1024**3\nprint(f'Docling converter ready on cuda:0 (VRAM used: {vram:.2f} GB)')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-25T10:43:42.367013Z","iopub.execute_input":"2026-06-25T10:43:42.367358Z","iopub.status.idle":"2026-06-25T10:44:15.724345Z","shell.execute_reply.started":"2026-06-25T10:43:42.367324Z","shell.execute_reply":"2026-06-25T10:44:15.723492Z"}},"outputs":[{"name":"stdout","text":"Docling converter ready on cuda:0 (VRAM used: 0.00 GB)\n","output_type":"stream"}],"execution_count":4},{"cell_type":"code","source":"from openai import AsyncOpenAI\nfrom io import BytesIO\nimport base64\nimport asyncio\nimport re\n\nasync_openai = AsyncOpenAI(api_key=OPENAI_API_KEY)\nVLM_MODEL = 'gpt-4o-mini'\n\n\ndef _img_to_b64(img) -> str:\n buf = BytesIO()\n img.save(buf, format='PNG')\n return base64.b64encode(buf.getvalue()).decode('utf-8')\n\n\nasync def vlm_formula(image) -> str:\n \"\"\"Crop → LaTeX string.\"\"\"\n resp = await async_openai.chat.completions.create(\n model=VLM_MODEL,\n messages=[{\n 'role': 'system',\n 'content': 'You are a LaTeX expert. Convert the formula image to LaTeX exactly as it appears. Return ONLY the LaTeX string, no explanation, no markdown fence.'\n }, {\n 'role': 'user',\n 'content': [\n {'type': 'text', 'text': 'Convert this formula to LaTeX.'},\n {'type': 'image_url', 'image_url': {'url': f'data:image/png;base64,{_img_to_b64(image)}'}},\n ]\n }],\n temperature=0.0,\n max_tokens=512,\n )\n text = resp.choices[0].message.content.strip()\n text = re.sub(r'^```[a-z]*\\n?', '', text)\n text = re.sub(r'\\n?```$', '', text)\n return text.strip()\n\n\nasync def vlm_table(image, caption: str | None = None) -> str:\n \"\"\"Crop → markdown table string.\"\"\"\n caption_hint = f'Caption: {caption}' if caption else ''\n resp = await async_openai.chat.completions.create(\n model=VLM_MODEL,\n messages=[{\n 'role': 'system',\n 'content': 'You are an academic document parser. Convert the table image to a markdown table exactly as it appears. Return ONLY the markdown table, no explanation.'\n }, {\n 'role': 'user',\n 'content': [\n {'type': 'text', 'text': f'Convert this table to markdown. {caption_hint}'},\n {'type': 'image_url', 'image_url': {'url': f'data:image/png;base64,{_img_to_b64(image)}'}},\n ]\n }],\n temperature=0.0,\n max_tokens=1024,\n )\n text = resp.choices[0].message.content.strip()\n text = re.sub(r'^```[a-z]*\\n?', '', text)\n text = re.sub(r'\\n?```$', '', text)\n return text.strip()\n\n\nasync def vlm_figure(image, caption: str | None = None) -> str:\n \"\"\"Crop → figure description string.\"\"\"\n caption_hint = f'Caption: {caption}' if caption else ''\n resp = await async_openai.chat.completions.create(\n model=VLM_MODEL,\n messages=[{\n 'role': 'system',\n 'content': 'You are an academic document parser. Describe this figure concisely in 2-4 sentences: what it shows, key findings or trends, and its relevance to the paper. Do not start with \"This figure\". Return ONLY the description.'\n }, {\n 'role': 'user',\n 'content': [\n {'type': 'text', 'text': f'Describe this academic figure. {caption_hint}'},\n {'type': 'image_url', 'image_url': {'url': f'data:image/png;base64,{_img_to_b64(image)}'}},\n ]\n }],\n temperature=0.0,\n max_tokens=512,\n )\n return resp.choices[0].message.content.strip()\n\n\nprint(f'OpenAI VLM ready — model: {VLM_MODEL}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-25T10:44:15.725332Z","iopub.execute_input":"2026-06-25T10:44:15.726064Z","iopub.status.idle":"2026-06-25T10:44:17.183669Z","shell.execute_reply.started":"2026-06-25T10:44:15.726038Z","shell.execute_reply":"2026-06-25T10:44:17.183035Z"}},"outputs":[{"name":"stdout","text":"OpenAI VLM ready — model: gpt-4o-mini\n","output_type":"stream"}],"execution_count":5},{"cell_type":"code","source":"from docling_core.types.doc import PictureItem, TableItem\n\n\ndef crop_element(doc, bbox: dict, page_no: int, padding: int = 10):\n \"\"\"Crop a bounding-box region from the page image (local 1-based page_no).\"\"\"\n page = doc.pages.get(page_no)\n if page is None or page.image is None:\n return None\n pil = page.image.pil_image.copy()\n sx = pil.size[0] / page.size.width\n sy = pil.size[1] / page.size.height\n box = (\n max(0, int(bbox['l'] * sx) - padding),\n max(0, int((page.size.height - bbox['t']) * sy) - padding),\n min(pil.size[0], int(bbox['r'] * sx) + padding),\n min(pil.size[1], int((page.size.height - bbox['b']) * sy) + padding),\n )\n return pil.crop(box)\n\n\nasync def batch_enrich_vlm(page_to_doc: dict, content: list) -> None:\n \"\"\"Fill VLM fields for all items in content via asyncio.gather.\n\n Items must carry _doc (DoclingDocument) and _local_page (1-based, local).\n page_to_doc is unused — kept for API compatibility; remove in next cleanup.\n \"\"\"\n tasks = [] # (item_index, field_name, coroutine)\n\n for i, item in enumerate(content):\n bbox = item.get('bbox')\n if not bbox:\n continue\n label = item.get('label', '')\n doc = item.get('_doc')\n page = item.get('_local_page')\n if doc is None or page is None:\n continue\n\n if label == 'formula' and item.get('latex') is None:\n img = crop_element(doc, bbox, page)\n if img:\n tasks.append((i, 'latex', vlm_formula(img)))\n\n elif label == 'table' and item.get('markdown') is None:\n img = crop_element(doc, bbox, page)\n if img:\n caption = None\n if i > 0 and content[i - 1].get('label') == 'caption':\n caption = content[i - 1].get('text')\n elif i + 1 < len(content) and content[i + 1].get('label') == 'caption':\n caption = content[i + 1].get('text')\n tasks.append((i, 'markdown', vlm_table(img, caption)))\n\n elif label in ('picture', 'chart') and item.get('description') is None:\n img = crop_element(doc, bbox, page)\n if img:\n caption = None\n if i > 0 and content[i - 1].get('label') == 'caption':\n caption = content[i - 1].get('text')\n elif i + 1 < len(content) and content[i + 1].get('label') == 'caption':\n caption = content[i + 1].get('text')\n tasks.append((i, 'description', vlm_figure(img, caption)))\n\n if not tasks:\n print('VLM enrichment: 0 items to enrich.')\n return\n\n print(f'VLM enrichment: {len(tasks)} items ({VLM_MODEL})')\n results = await asyncio.gather(*[t[2] for t in tasks], return_exceptions=True)\n\n ok = 0\n for (item_idx, field, _), result in zip(tasks, results):\n if isinstance(result, Exception):\n print(f' VLM error — item {item_idx} ({field}): {result}')\n else:\n content[item_idx][field] = result\n ok += 1\n\n print(f' Done: {ok}/{len(tasks)} succeeded.')\n\n\nprint('crop_element + batch_enrich_vlm defined.')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-25T10:44:17.184558Z","iopub.execute_input":"2026-06-25T10:44:17.184860Z","iopub.status.idle":"2026-06-25T10:44:17.199564Z","shell.execute_reply.started":"2026-06-25T10:44:17.184827Z","shell.execute_reply":"2026-06-25T10:44:17.198561Z"}},"outputs":[{"name":"stdout","text":"crop_element + batch_enrich_vlm defined.\n","output_type":"stream"}],"execution_count":6},{"cell_type":"code","source":"import tempfile\nimport json as _json\nfrom pathlib import Path\n\n\ndef _parse_pdf_sync(pdf_path: str):\n \"\"\"Run Docling synchronously (blocking). Returns DoclingDocument.\"\"\"\n with converter_lock:\n result = converter.convert(pdf_path)\n return result.document\n\n\nasync def parse_pdf(pdf_bytes: bytes, filename: str = 'paper.pdf') -> dict:\n \"\"\"Parse PDF and enrich with VLM in two clean stages:\n\n Stage 1 — Docling (GPU, blocking, run in thread pool):\n PDF → DoclingDocument → raw structured JSON\n\n Stage 2 — OpenAI gpt-4o-mini (network, asyncio.gather, all items parallel):\n formula items → latex field\n table items → markdown field\n picture/chart → description field\n\n Returns: { 'structured': {...}, 'markdown': str, 'pages_total': int }\n Saves enriched JSON to /kaggle/working/_parsed.json for debugging.\n \"\"\"\n with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:\n tmp.write(pdf_bytes)\n tmp_path = tmp.name\n\n try:\n from pypdf import PdfReader\n total_pages = len(PdfReader(tmp_path).pages)\n\n # ── Stage 1: Docling parse (blocking → thread pool) ───────────────────\n loop = asyncio.get_event_loop()\n doc = await loop.run_in_executor(None, _parse_pdf_sync, tmp_path)\n print(f'Docling done — {total_pages} pages')\n\n # Export to structured JSON, keeping _doc + _local_page for cropping\n content = _export_with_refs(doc, page_offset=0)\n\n # Sort by page, assign global index\n content.sort(key=lambda x: x.get('page', 0))\n for idx, item in enumerate(content):\n item['index'] = idx\n\n # ── Stage 2: VLM enrichment (all items parallel, network bound) ───────\n await batch_enrich_vlm({}, content)\n\n # Strip internal refs before serialisation\n _strip_internal(content)\n\n # Build label_counts from content\n label_counts: dict = {}\n for item in content:\n lbl = item['label']\n label_counts[lbl] = label_counts.get(lbl, 0) + 1\n\n structured = {\n 'meta': {\n 'source': filename,\n 'num_pages': total_pages,\n 'label_counts': dict(sorted(label_counts.items(), key=lambda x: -x[1])),\n },\n 'content': content,\n }\n\n # ── Save enriched JSON for debugging ─────────────────────────────────\n stem = Path(filename).stem\n out_path = Path('/kaggle/working') / f'{stem}_parsed.json'\n try:\n with out_path.open('w', encoding='utf-8') as f:\n _json.dump(structured, f, ensure_ascii=False, indent=2)\n size_kb = out_path.stat().st_size // 1024\n n_formula = sum(1 for i in content if i.get('label') == 'formula')\n n_table = sum(1 for i in content if i.get('label') == 'table')\n n_figure = sum(1 for i in content if i.get('label') in ('picture', 'chart'))\n n_formula_ok = sum(1 for i in content if i.get('label') == 'formula' and i.get('latex'))\n n_table_ok = sum(1 for i in content if i.get('label') == 'table' and i.get('markdown'))\n n_figure_ok = sum(1 for i in content if i.get('label') in ('picture', 'chart') and i.get('description'))\n print(f'Saved → {out_path} ({size_kb} KB)')\n print(f' Enrichment — formulas: {n_formula_ok}/{n_formula} tables: {n_table_ok}/{n_table} figures: {n_figure_ok}/{n_figure}')\n except Exception as e:\n print(f'Warning: could not save parsed JSON: {e}')\n\n markdown = _structured_to_markdown(structured)\n\n return {'structured': structured, 'markdown': markdown, 'pages_total': total_pages}\n\n finally:\n Path(tmp_path).unlink(missing_ok=True)\n\n\ndef _export_with_refs(doc, page_offset: int = 0) -> list:\n \"\"\"Export DoclingDocument to list of content items.\n\n Attaches _doc and _local_page (internal, stripped before serialisation)\n so batch_enrich_vlm can crop the correct page image per item.\n \"\"\"\n from docling_core.types.doc import PictureItem, TableItem\n\n content = []\n for element, level in doc.iterate_items():\n label = getattr(element, 'label', None)\n if label is None:\n continue\n\n label_str = label.value if hasattr(label, 'value') else str(label)\n item: dict = {'label': label_str, 'level': level}\n\n prov = getattr(element, 'prov', None)\n local_page = 1\n if prov:\n p = prov[0]\n if hasattr(p, 'page_no'):\n local_page = p.page_no\n item['page'] = local_page + page_offset\n if hasattr(p, 'bbox') and p.bbox:\n b = p.bbox\n item['bbox'] = {'l': round(b.l, 2), 't': round(b.t, 2),\n 'r': round(b.r, 2), 'b': round(b.b, 2)}\n\n item['_doc'] = doc\n item['_local_page'] = local_page\n\n text = getattr(element, 'text', None)\n if text and label_str not in ('table', 'picture', 'chart'):\n item['text'] = text\n\n if isinstance(element, TableItem):\n item['markdown'] = None\n if label_str == 'formula':\n item['latex'] = None\n if isinstance(element, PictureItem):\n annotations = getattr(element.meta, 'classification', []) if element.meta else []\n item['classifications'] = [\n {'label': a.predicted_class, 'confidence': round(a.confidence, 3)}\n for a in annotations if hasattr(a, 'predicted_class')\n ]\n item['description'] = None\n if label_str == 'chart':\n item['description'] = None\n\n content.append(item)\n return content\n\n\ndef _strip_internal(content: list) -> None:\n \"\"\"Remove _doc and _local_page in-place before JSON serialisation.\"\"\"\n for item in content:\n item.pop('_doc', None)\n item.pop('_local_page', None)\n\n\ndef _structured_to_markdown(structured: dict) -> str:\n \"\"\"Reconstruct flat markdown from structured JSON.\"\"\"\n parts = []\n for item in structured['content']:\n label = item.get('label', '')\n if label == 'title':\n parts.append(f\"# {item.get('text', '')}\")\n elif label == 'section_header':\n prefix = '#' * (item.get('level', 1) + 1)\n parts.append(f\"{prefix} {item.get('text', '')}\")\n elif label == 'table':\n if item.get('markdown'):\n parts.append(item['markdown'])\n elif label == 'formula':\n if item.get('latex'):\n parts.append(f\"$${item['latex']}$$\")\n elif item.get('text'):\n parts.append(item['text'])\n elif label in ('picture', 'chart'):\n if item.get('description'):\n parts.append(f\"[Figure: {item['description']}]\")\n elif item.get('text'):\n parts.append(item['text'])\n return '\\n\\n'.join(p for p in parts if p.strip())\n\n\nprint('parse_pdf() defined.')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-25T10:44:17.201161Z","iopub.execute_input":"2026-06-25T10:44:17.201475Z","iopub.status.idle":"2026-06-25T10:44:17.227935Z","shell.execute_reply.started":"2026-06-25T10:44:17.201448Z","shell.execute_reply":"2026-06-25T10:44:17.226910Z"}},"outputs":[{"name":"stdout","text":"parse_pdf() defined.\n","output_type":"stream"}],"execution_count":7},{"cell_type":"code","source":"from fastapi import FastAPI, File, UploadFile, HTTPException\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom pydantic import BaseModel\nfrom typing import Any\n\n\nclass ParseResponse(BaseModel):\n structured: dict[str, Any] # { meta: {...}, content: [...] }\n markdown: str\n pages_total: int\n\n\napp = FastAPI(title='PaperMate Docling Server')\napp.add_middleware(\n CORSMiddleware,\n allow_origins=['*'], allow_methods=['*'], allow_headers=['*'],\n)\n\n\n@app.get('/health')\ndef health():\n return {\n 'status': 'ok',\n 'model': 'granite-docling-258M (via docling)',\n 'vlm': VLM_MODEL,\n 'gpus': torch.cuda.device_count(),\n }\n\n\n@app.post('/parse', response_model=ParseResponse)\nasync def parse(file: UploadFile = File(...)):\n if not file.filename.lower().endswith('.pdf'):\n raise HTTPException(status_code=400, detail='Only PDF files are accepted.')\n\n pdf_bytes = await file.read()\n if len(pdf_bytes) > 50 * 1024 * 1024:\n raise HTTPException(status_code=413, detail='File too large (max 50 MB).')\n\n try:\n result = await parse_pdf(pdf_bytes, filename=file.filename)\n except Exception as e:\n raise HTTPException(status_code=500, detail=f'Parsing failed: {e}')\n\n return ParseResponse(\n structured=result['structured'],\n markdown=result['markdown'],\n pages_total=result['pages_total'],\n )\n\n\nprint('FastAPI app defined.')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-25T10:44:17.229091Z","iopub.execute_input":"2026-06-25T10:44:17.229433Z","iopub.status.idle":"2026-06-25T10:44:17.723043Z","shell.execute_reply.started":"2026-06-25T10:44:17.229408Z","shell.execute_reply":"2026-06-25T10:44:17.722355Z"}},"outputs":[{"name":"stdout","text":"FastAPI app defined.\n","output_type":"stream"}],"execution_count":8},{"cell_type":"code","source":"import requests as req_lib\nimport nest_asyncio\nimport uvicorn\nfrom pyngrok import ngrok\n\nPORT = 8767\n\n# ── Start ngrok tunnel ────────────────────────────────────────────────────────\nngrok.set_auth_token(NGROK_AUTH_TOKEN)\ntunnel = ngrok.connect(PORT)\npublic_url = tunnel.public_url\nprint(f'Public URL: {public_url}')\n\n# ── Publish URL to ntfy.sh ────────────────────────────────────────────────────\ntry:\n resp = req_lib.post(\n f'https://ntfy.sh/{NTFY_TOPIC}',\n data=public_url.encode(),\n headers={\n 'Title': 'PaperMate Docling Server Ready',\n 'Priority': 'high',\n 'Tags': 'white_check_mark',\n },\n timeout=10,\n )\n print(f'URL published to ntfy.sh/{NTFY_TOPIC} (HTTP {resp.status_code})')\nexcept Exception as e:\n print(f'ntfy publish failed: {e}')\n\nprint()\nprint('Endpoints:')\nprint(f' GET {public_url}/health')\nprint(f' POST {public_url}/parse (multipart/form-data, field=\"file\")')\n\n# ── Run server ────────────────────────────────────────────────────────────────\nnest_asyncio.apply()\nconfig = uvicorn.Config(app=app, host='0.0.0.0', port=PORT, log_level='info')\nserver = uvicorn.Server(config)\nloop = asyncio.get_event_loop()\nloop.create_task(server.serve())\ntry:\n loop.run_forever()\nexcept KeyboardInterrupt:\n print('Server stopped.')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-06-25T10:44:17.724972Z","iopub.execute_input":"2026-06-25T10:44:17.725262Z"}},"outputs":[{"name":"stdout","text":"Public URL: https://4208-136-116-138-18.ngrok-free.app \n","output_type":"stream"},{"name":"stderr","text":"INFO: Started server process [58]\nINFO: Waiting for application startup.\nINFO: Application startup complete.\nINFO: Uvicorn running on http://0.0.0.0:8767 (Press CTRL+C to quit)\n","output_type":"stream"},{"name":"stdout","text":"URL published to ntfy.sh/papermate_pdf2md (HTTP 200)\n\nEndpoints:\n GET https://4208-136-116-138-18.ngrok-free.app/health\n POST https://4208-136-116-138-18.ngrok-free.app/parse (multipart/form-data, field=\"file\")\n","output_type":"stream"},{"name":"stderr","text":"Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.\n","output_type":"stream"},{"output_type":"display_data","data":{"text/plain":"Loading weights: 0%| | 0/770 [00:00