Download app.py from N4YuC4/docneedle: direct link, hf CLI and curl.
- Browser
- Download file 49.4 kB
-
https://huggingface.co/spaces/N4YuC4/docneedle/resolve/main/app.py
- Command line
-
hf download hf://spaces/N4YuC4/docneedle/app.py
-
curl -L -o app.py https://huggingface.co/spaces/N4YuC4/docneedle/resolve/main/app.py
49.4 kB
| """Hugging Face Spaces and Local Entry Point for DocNeedle. | |
| Provides a standalone, frontend-identical Gradio UI dashboard alongside the complete FastAPI REST API. | |
| Features dynamic field-by-field schema creation matching frontend/src/App.tsx, dual formatted and raw JSON output tabs, | |
| ephemeral OCR deallocation, and model bootstrapping. | |
| Zero dependency on external frontend frameworks. | |
| """ | |
| import html | |
| import json | |
| import logging | |
| import os | |
| import sys | |
| import tempfile | |
| from pathlib import Path | |
| from typing import Any | |
| # ZeroGPU decorator compatibility (applied before CUDA imports) | |
| try: | |
| import spaces | |
| gpu_decorator = spaces.GPU | |
| except Exception: | |
| def gpu_decorator(*args, **kwargs): | |
| if args and callable(args[0]): | |
| return args[0] | |
| return lambda f: f | |
| # Add src and root to sys.path | |
| root_dir = Path(__file__).resolve().parent | |
| src_dir = root_dir / "src" | |
| if src_dir.exists(): | |
| sys.path.insert(0, str(src_dir)) | |
| sys.path.insert(0, str(root_dir)) | |
| # Preload NVIDIA CUDA runtime and cuBLAS shared libraries into global symbol space | |
| # (libcudart.so.12, libcublas.so.12) to ensure llama-cpp-python can link on ZeroGPU | |
| try: | |
| from docneedle.cuda_compat import preload_cuda_runtime_libs | |
| preload_cuda_runtime_libs() | |
| except Exception as e: | |
| logging.getLogger("DocNeedleSpace").debug("CUDA runtime preload info: %s", e) | |
| import gradio as gr # noqa: E402 | |
| from docneedle.config import settings # noqa: E402 | |
| logger = logging.getLogger("DocNeedleSpace") | |
| logging.basicConfig(level=logging.INFO) | |
| # 1. Automatic model bootstrapping for Hugging Face Spaces and local startup | |
| try: | |
| from scripts.download_models import ensure_all_models_present | |
| logger.info("Checking and bootstrapping quantized models...") | |
| ensure_all_models_present(settings.model_storage_dir) | |
| except Exception as e: | |
| logger.warning(f"Automatic model download check failed or bypassed: {e}") | |
| CUSTOM_CSS = """ | |
| /* Dark mode base styles */ | |
| :root, body, .gradio-container { | |
| max-width: 1360px !important; | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important; | |
| background-color: #09090b !important; | |
| color: #f4f4f5 !important; | |
| } | |
| /* Headings and labels */ | |
| .gradio-container .prose *, | |
| .gradio-container h1, | |
| .gradio-container h2, | |
| .gradio-container h3, | |
| .gradio-container h4, | |
| .gradio-container p { | |
| color: #f4f4f5 !important; | |
| } | |
| /* Container blocks */ | |
| .gradio-container .block, | |
| .gradio-container .form { | |
| background-color: #18181b !important; | |
| border: 1px solid #27272a !important; | |
| border-radius: 8px !important; | |
| } | |
| /* Field Card matching frontend */ | |
| .dn-field-card { | |
| background-color: #141416 !important; | |
| border: 1px solid #27272a !important; | |
| border-radius: 8px !important; | |
| padding: 12px 14px !important; | |
| margin-bottom: 10px !important; | |
| transition: border-color 0.15s ease-in-out !important; | |
| } | |
| .dn-field-card:hover { | |
| border-color: #3f3f46 !important; | |
| } | |
| /* Add Field dashed button matching frontend */ | |
| .dn-add-field-btn { | |
| border: 1px dashed #3f3f46 !important; | |
| background-color: transparent !important; | |
| color: #a1a1aa !important; | |
| font-size: 13px !important; | |
| font-weight: 500 !important; | |
| border-radius: 6px !important; | |
| width: 100% !important; | |
| padding: 10px 14px !important; | |
| cursor: pointer !important; | |
| transition: all 0.2s ease-in-out !important; | |
| } | |
| .dn-add-field-btn:hover { | |
| border-color: #71717a !important; | |
| color: #f4f4f5 !important; | |
| background-color: #27272a !important; | |
| } | |
| /* Delete button */ | |
| .dn-del-btn { | |
| background: transparent !important; | |
| border: none !important; | |
| color: #71717a !important; | |
| font-size: 14px !important; | |
| cursor: pointer !important; | |
| padding: 2px 6px !important; | |
| transition: color 0.15s ease-in-out !important; | |
| } | |
| .dn-del-btn:hover { | |
| color: #f43f5e !important; | |
| } | |
| /* Header action container */ | |
| .dn-header-actions { | |
| display: flex !important; | |
| gap: 6px !important; | |
| align-items: center !important; | |
| justify-content: flex-end !important; | |
| } | |
| /* Action button for import, export, clear */ | |
| .dn-action-btn { | |
| background-color: #27272a !important; | |
| border: 1px solid #3f3f46 !important; | |
| color: #e4e4e7 !important; | |
| font-size: 11px !important; | |
| font-weight: 500 !important; | |
| border-radius: 6px !important; | |
| padding: 4px 10px !important; | |
| min-width: 0 !important; | |
| cursor: pointer !important; | |
| transition: all 0.15s ease-in-out !important; | |
| } | |
| .dn-action-btn:hover { | |
| background-color: #3f3f46 !important; | |
| color: #ffffff !important; | |
| border-color: #52525b !important; | |
| } | |
| /* Clear all button */ | |
| .dn-clear-btn { | |
| background: transparent !important; | |
| border: 1px solid transparent !important; | |
| color: #71717a !important; | |
| font-size: 11px !important; | |
| cursor: pointer !important; | |
| } | |
| .dn-clear-btn:hover { | |
| color: #f43f5e !important; | |
| background-color: rgba(244, 63, 94, 0.1) !important; | |
| border-color: rgba(244, 63, 94, 0.2) !important; | |
| } | |
| /* Secondary toolbar button */ | |
| .dn-secondary-btn { | |
| background-color: #18181b !important; | |
| border: 1px solid #3f3f46 !important; | |
| color: #cbd5e1 !important; | |
| font-size: 12px !important; | |
| font-weight: 500 !important; | |
| border-radius: 6px !important; | |
| padding: 6px 12px !important; | |
| cursor: pointer !important; | |
| transition: all 0.15s ease-in-out !important; | |
| } | |
| .dn-secondary-btn:hover { | |
| background-color: #27272a !important; | |
| border-color: #71717a !important; | |
| color: #f4f4f5 !important; | |
| } | |
| .dn-raw-hint { | |
| font-size: 11px !important; | |
| color: #a1a1aa !important; | |
| margin-bottom: 6px !important; | |
| } | |
| .dn-input-tabs { | |
| margin-top: 4px !important; | |
| } | |
| /* Primary Action Button */ | |
| .dn-primary-btn { | |
| background: linear-gradient(135deg, #f97316 0%, #ea580c 100%) !important; | |
| color: #ffffff !important; | |
| font-weight: 600 !important; | |
| border-radius: 8px !important; | |
| padding: 12px 18px !important; | |
| border: none !important; | |
| font-size: 14px !important; | |
| cursor: pointer !important; | |
| transition: all 0.2s ease-in-out !important; | |
| } | |
| .dn-primary-btn:hover { | |
| background: linear-gradient(135deg, #ea580c 0%, #c2410c 100%) !important; | |
| box-shadow: 0 4px 14px rgba(234, 88, 12, 0.35) !important; | |
| } | |
| /* File upload container & preview */ | |
| .gradio-container .file-preview-holder, | |
| .gradio-container .upload-container, | |
| .gradio-container [data-testid="dropzone"] { | |
| background-color: #141416 !important; | |
| border: 1px dashed #3f3f46 !important; | |
| color: #f4f4f5 !important; | |
| } | |
| /* Output Tabs */ | |
| .gradio-container .tab-nav { | |
| border-bottom: 1px solid #27272a !important; | |
| } | |
| .gradio-container .tab-nav button { | |
| color: #a1a1aa !important; | |
| font-size: 13px !important; | |
| font-weight: 500 !important; | |
| border-bottom: 2px solid transparent !important; | |
| } | |
| .gradio-container .tab-nav button.selected { | |
| color: #f4f4f5 !important; | |
| border-bottom-color: #f97316 !important; | |
| font-weight: 600 !important; | |
| } | |
| /* JSON viewer */ | |
| .gradio-container .json-holder { | |
| background-color: #09090b !important; | |
| border: 1px solid #27272a !important; | |
| border-radius: 6px !important; | |
| } | |
| /* Hide Gradio footer completely */ | |
| footer, .gradio-container footer, .gradio-container .footer { | |
| display: none !important; | |
| visibility: hidden !important; | |
| height: 0 !important; | |
| padding: 0 !important; | |
| margin: 0 !important; | |
| } | |
| """ | |
| HEADER_HTML = """ | |
| <div style="display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; background: #18181b; border: 1px solid #27272a; margin: -20px -20px 20px -20px; border-radius: 8px;"> | |
| <div style="display: flex; align-items: center; gap: 14px;"> | |
| <svg viewBox="170 170 684 684" style="width: 38px; height: 38px; flex-shrink: 0;" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| <g> | |
| <path d="M 727 271 L 418 270 L 294 400 L 297 708 L 320 704 L 328 704 L 304 723 L 294 729 L 294 763 L 297 762 L 343 717 L 399 657 L 469 576 L 639 371 Z" fill="#2E46AA" /> | |
| <path d="M 418 270 L 294 400 L 418 400 Z" fill="#256CD2" /> | |
| <path d="M 728 444 L 671 501 L 552 611 L 464 695 L 385 775 L 330 834 L 317 850 L 341 849 L 349 838 L 367 818 L 368 822 L 363 850 L 729 849 L 729 506 L 723 506 L 693 513 L 688 512 L 728 480 Z" fill="#09BDCE" /> | |
| <path d="M 741 338 L 733 335 L 723 335 L 704 343 L 679 363 L 658 385 L 563 502 L 296 819 L 372 748 L 601 528 L 688 448 L 721 415 L 743 386 L 752 364 L 751 350 L 749 346 Z M 726 367 L 725 378 L 719 389 L 709 402 L 685 425 L 677 430 L 671 432 L 665 432 L 659 425 L 660 416 L 665 406 L 678 389 L 693 374 L 705 365 L 715 361 L 719 361 Z" fill="#FFFFFF" stroke="#0F172A" stroke-width="8" stroke-linejoin="round" /> | |
| </g> | |
| </svg> | |
| <div> | |
| <div style="font-weight: 700; font-size: 17px; color: #f4f4f5; letter-spacing: -0.02em;">DocNeedle</div> | |
| <div style="font-size: 12px; color: #a1a1aa; font-weight: 500;">Document Extraction Microservice</div> | |
| </div> | |
| </div> | |
| </div> | |
| """ | |
| FIELD_TYPE_KEYS = [ | |
| "string", | |
| "number", | |
| "money", | |
| "date", | |
| "time", | |
| "percentage", | |
| "person", | |
| "organization", | |
| "id", | |
| "iban", | |
| "email", | |
| "phone", | |
| "address", | |
| "boolean", | |
| "list", | |
| ] | |
| TYPE_MAP = { | |
| "string": "string", | |
| "text": "string", | |
| "metin": "string", | |
| "number": "number", | |
| "integer": "number", | |
| "float": "number", | |
| "sayı": "number", | |
| "money": "money", | |
| "currency": "money", | |
| "amount": "money", | |
| "para": "money", | |
| "date": "date", | |
| "tarih": "date", | |
| "time": "time", | |
| "saat": "time", | |
| "email": "email", | |
| "phone": "phone", | |
| "id": "id", | |
| "iban": "iban", | |
| "percentage": "percentage", | |
| "percent": "percentage", | |
| "oran": "percentage", | |
| "person": "person", | |
| "name": "person", | |
| "organization": "organization", | |
| "company": "organization", | |
| "address": "address", | |
| "boolean": "boolean", | |
| "list": "list", | |
| } | |
| MAX_FIELDS = 10 | |
| INITIAL_STARTER_FIELDS = [ | |
| ("", "", "string"), | |
| ] | |
| DEFAULT_EXAMPLE_SCHEMA: list[dict[str, str]] = [ | |
| { | |
| "key": "example_field", | |
| "description": "Describe what you want to extract from the document", | |
| "type": "string", | |
| } | |
| ] | |
| def resolve_field_type(raw_type: Any) -> str: | |
| """Normalize raw field type string against TYPE_MAP and FIELD_TYPE_KEYS.""" | |
| t = str(raw_type).strip().lower() if raw_type else "string" | |
| if t in ("none", "n/a", ""): | |
| return "string" | |
| resolved = TYPE_MAP.get(t, t) | |
| return resolved if resolved in FIELD_TYPE_KEYS else "string" | |
| def collect_form_items( | |
| q_vals: list[Any] | tuple[Any, ...], | |
| k_vals: list[Any] | tuple[Any, ...], | |
| t_vals: list[Any] | tuple[Any, ...], | |
| vis_vals: list[bool] | None = None, | |
| ) -> list[dict[str, str]]: | |
| """Collect normalized extraction item dicts from form builder inputs.""" | |
| items: list[dict[str, str]] = [] | |
| num_fields = min(len(q_vals), len(k_vals), len(t_vals)) | |
| for i in range(num_fields): | |
| q = str(q_vals[i]).strip() if q_vals[i] else "" | |
| k = str(k_vals[i]).strip() if k_vals[i] else "" | |
| is_vis = vis_vals[i] if vis_vals is not None and i < len(vis_vals) else True | |
| if not is_vis and not (q or k): | |
| continue | |
| if q or k: | |
| items.append( | |
| { | |
| "key": k or q or f"item_{len(items) + 1}", | |
| "description": q or k, | |
| "type": resolve_field_type(t_vals[i]), | |
| } | |
| ) | |
| return items | |
| def unpack_field_args( | |
| field_args: tuple[Any, ...], | |
| num_fields: int = MAX_FIELDS, | |
| ) -> tuple[tuple[Any, ...], tuple[Any, ...], tuple[Any, ...]]: | |
| """Unpack flattened Gradio form arguments into (questions, keys, types) slices.""" | |
| q_vals = field_args[0:num_fields] | |
| k_vals = field_args[num_fields : 2 * num_fields] | |
| t_vals = field_args[2 * num_fields : 3 * num_fields] | |
| return q_vals, k_vals, t_vals | |
| def parse_raw_json_schema(raw_json: str) -> list[dict[str, str]]: | |
| """Parse raw JSON string into a validated list of extraction item dicts.""" | |
| if not raw_json or not raw_json.strip(): | |
| return [] | |
| try: | |
| data = json.loads(raw_json) | |
| except Exception as e: | |
| raise ValueError(f"Invalid JSON syntax: {e}") from e | |
| if isinstance(data, dict): | |
| data = [data] | |
| elif not isinstance(data, list): | |
| raise ValueError("Schema must be a JSON array of objects or a single JSON object.") | |
| parsed_items: list[dict[str, str]] = [] | |
| for idx, item in enumerate(data): | |
| if not isinstance(item, dict): | |
| continue | |
| key = str( | |
| item.get("key") or item.get("name") or item.get("id") or item.get("field") or "" | |
| ).strip() | |
| desc = str( | |
| item.get("description") | |
| or item.get("question") | |
| or item.get("prompt") | |
| or item.get("desc") | |
| or "" | |
| ).strip() | |
| resolved_t = resolve_field_type(item.get("type", "string")) | |
| if key or desc: | |
| final_key = key or desc or f"item_{idx + 1}" | |
| final_desc = desc or key | |
| parsed_items.append( | |
| { | |
| "key": final_key, | |
| "description": final_desc, | |
| "type": resolved_t, | |
| } | |
| ) | |
| return parsed_items | |
| def serialize_form_to_json( | |
| q_vals: list[Any] | tuple[Any, ...], | |
| k_vals: list[Any] | tuple[Any, ...], | |
| t_vals: list[Any] | tuple[Any, ...], | |
| vis_vals: list[bool] | None = None, | |
| ) -> str: | |
| """Convert form fields to formatted JSON string.""" | |
| items = collect_form_to_items_or_default(q_vals, k_vals, t_vals, vis_vals) | |
| return json.dumps(items, indent=2, ensure_ascii=False) | |
| def collect_form_to_items_or_default( | |
| q_vals: list[Any] | tuple[Any, ...], | |
| k_vals: list[Any] | tuple[Any, ...], | |
| t_vals: list[Any] | tuple[Any, ...], | |
| vis_vals: list[bool] | None = None, | |
| ) -> list[dict[str, str]]: | |
| """Collect form items or return the default example schema when empty.""" | |
| items = collect_form_items(q_vals, k_vals, t_vals, vis_vals) | |
| return items if items else list(DEFAULT_EXAMPLE_SCHEMA) | |
| def apply_items_to_form_updates( | |
| items: list[dict[str, str]], | |
| ) -> tuple[list[bool], list[Any], list[Any], list[Any], list[Any], str]: | |
| """Given parsed items, generate updates for vis_state, field_groups, q_inputs, k_inputs, t_inputs, and raw_json.""" | |
| count = min(len(items), MAX_FIELDS) | |
| new_vis = [(i < count) for i in range(MAX_FIELDS)] | |
| if count == 0: | |
| new_vis[0] = True | |
| group_updates = [gr.update(visible=new_vis[i]) for i in range(MAX_FIELDS)] | |
| q_updates: list[Any] = [] | |
| k_updates: list[Any] = [] | |
| t_updates: list[Any] = [] | |
| for i in range(MAX_FIELDS): | |
| if i < count: | |
| it = items[i] | |
| q_updates.append(gr.update(value=it.get("description", ""))) | |
| k_updates.append(gr.update(value=it.get("key", ""))) | |
| t_updates.append(gr.update(value=it.get("type", "string"))) | |
| else: | |
| q_updates.append(gr.update(value="")) | |
| k_updates.append(gr.update(value="")) | |
| t_updates.append(gr.update(value="string")) | |
| json_str = json.dumps( | |
| items if items else DEFAULT_EXAMPLE_SCHEMA, | |
| indent=2, | |
| ensure_ascii=False, | |
| ) | |
| return new_vis, group_updates, q_updates, k_updates, t_updates, json_str | |
| def render_results_html(response_dict: dict[str, Any] | None) -> str: | |
| """Render extracted results matching frontend/src/App.tsx card design.""" | |
| if not response_dict: | |
| return """ | |
| <div style="border: 2px dashed #27272a; border-radius: 8px; padding: 56px 24px; text-align: center; color: #a1a1aa; background: #141416;"> | |
| <svg style="width: 40px; height: 40px; margin: 0 auto 12px; color: #71717a;" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path> | |
| </svg> | |
| <div style="font-weight: 600; font-size: 15px; color: #f4f4f5;">No analysis performed yet</div> | |
| <div style="font-size: 13px; margin-top: 6px; color: #a1a1aa;">Upload documents on the left and define target fields to start extraction.</div> | |
| </div> | |
| """ | |
| if "error" in response_dict: | |
| err_msg = html.escape(str(response_dict["error"])) | |
| return f""" | |
| <div style="border: 1px solid #881337; background: #4c0519; color: #fecdd3; border-radius: 8px; padding: 16px; font-size: 13px;"> | |
| <strong>Extraction Error:</strong> {err_msg} | |
| </div> | |
| """ | |
| total = response_dict.get("total_items", 0) | |
| found = response_dict.get("found_items", 0) | |
| time_ms = response_dict.get("processing_time_ms", 0) | |
| time_s = f"{time_ms / 1000:.2f} s" | |
| results = response_dict.get("results", []) | |
| cards_html = "" | |
| for r in results: | |
| is_found = r.get("found", False) | |
| key = html.escape(str(r.get("key", ""))) | |
| raw_val = r.get("value") | |
| val = ( | |
| html.escape(str(raw_val)) | |
| if raw_val is not None and str(raw_val) != "" | |
| else "Not found in document" | |
| ) | |
| conf = int(r.get("confidence", 0) * 100) | |
| evidence = r.get("evidence") | |
| thinking = r.get("thinking") | |
| badge_html = ( | |
| '<span style="background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;">Found</span>' | |
| if is_found | |
| else '<span style="background: #27272a; color: #a1a1aa; border: 1px solid #3f3f46; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;">Not Found</span>' | |
| ) | |
| conf_html = ( | |
| f'<span style="font-family: monospace; font-size: 11px; color: #a1a1aa;">%{conf} Confidence</span>' | |
| if is_found | |
| else "" | |
| ) | |
| evidence_html = "" | |
| if evidence: | |
| doc_name = html.escape(str(evidence.get("document", ""))) | |
| page_num = evidence.get("page") | |
| raw_snippet = evidence.get("snippet", "") | |
| snippet = html.escape(str(raw_snippet)) if raw_snippet else "" | |
| page_str = f" • Page {int(page_num)}" if isinstance(page_num, int) else "" | |
| snippet_box = ( | |
| f'<div style="margin-top: 6px; padding: 8px 10px; background: #09090b; border-left: 3px solid #3b82f6; border-radius: 4px; font-size: 11px; color: #cbd5e1; font-style: italic;">"{snippet}"</div>' | |
| if snippet | |
| else "" | |
| ) | |
| evidence_html = f""" | |
| <div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #27272a; font-size: 11px; color: #a1a1aa;"> | |
| <span style="font-weight: 600; color: #f4f4f5;">Source Document:</span> {doc_name}{page_str} | |
| {snippet_box} | |
| </div> | |
| """ | |
| thinking_html = "" | |
| if thinking: | |
| safe_thinking = html.escape(str(thinking)) | |
| thinking_html = f""" | |
| <details style="margin-top: 8px; font-size: 11px; color: #a1a1aa;"> | |
| <summary style="cursor: pointer; font-weight: 500; color: #94a3b8;">🧠 Model Reasoning (Thinking Process)</summary> | |
| <div style="margin-top: 4px; padding: 8px; background: #09090b; border: 1px solid #27272a; border-radius: 4px; font-family: monospace; white-space: pre-wrap; color: #e2e8f0; font-size: 11px;">{safe_thinking}</div> | |
| </details> | |
| """ | |
| cards_html += f""" | |
| <div style="background: #18181b; border: 1px solid #27272a; border-radius: 8px; padding: 14px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.3);"> | |
| <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> | |
| <div style="display: flex; align-items: center; gap: 8px;"> | |
| <span style="font-family: monospace; font-size: 13px; font-weight: 700; color: #f4f4f5;">{key}</span> | |
| {badge_html} | |
| </div> | |
| {conf_html} | |
| </div> | |
| <div style="background: #09090b; border: 1px solid #27272a; border-radius: 6px; padding: 10px 12px; font-size: 13px; font-weight: 600; color: #f4f4f5; word-break: break-word;"> | |
| {val} | |
| </div> | |
| {evidence_html} | |
| {thinking_html} | |
| </div> | |
| """ | |
| summary_html = f""" | |
| <div style="background: #18181b; border: 1px solid #27272a; border-radius: 8px; padding: 14px 18px; margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 1px 3px rgba(0,0,0,0.3);"> | |
| <div style="display: flex; gap: 24px;"> | |
| <div> | |
| <span style="font-size: 11px; color: #a1a1aa; display: block; font-weight: 500;">Extracted Fields</span> | |
| <span style="font-family: monospace; font-size: 16px; font-weight: 700; color: #f4f4f5;">{found} / {total}</span> | |
| </div> | |
| <div style="border-left: 1px solid #27272a; padding-left: 24px;"> | |
| <span style="font-size: 11px; color: #a1a1aa; display: block; font-weight: 500;">Processing Time</span> | |
| <span style="font-family: monospace; font-size: 16px; font-weight: 700; color: #f4f4f5;">{time_s}</span> | |
| </div> | |
| </div> | |
| <div> | |
| <span style="background: #27272a; color: #e4e4e7; padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 600; border: 1px solid #3f3f46;">Extraction Completed</span> | |
| </div> | |
| </div> | |
| """ | |
| return summary_html + cards_html | |
| def make_status_html(message: str, is_success: bool = False, is_loading: bool = False) -> str: | |
| border_color = "#10b981" if is_success else ("#f97316" if is_loading else "#27272a") | |
| bg_color = ( | |
| "rgba(16, 185, 129, 0.08)" | |
| if is_success | |
| else ("rgba(249, 115, 22, 0.08)" if is_loading else "#141416") | |
| ) | |
| text_color = "#34d399" if is_success else ("#fb923c" if is_loading else "#a1a1aa") | |
| icon = "✅" if is_success else ("⏳" if is_loading else "📄") | |
| return f""" | |
| <div style="padding: 10px 14px; background: {bg_color}; border: 1px solid {border_color}; border-radius: 6px; font-size: 12px; color: {text_color}; margin-top: 10px; display: flex; align-items: center; gap: 8px;"> | |
| <span>{icon}</span> | |
| <span>{message}</span> | |
| </div> | |
| """ | |
| def _format_completion_status(found: int, total: int, total_time_ms: float) -> str: | |
| """Format extraction completion status message with elapsed time.""" | |
| time_s = f"{total_time_ms / 1000:.2f} s" | |
| return ( | |
| f"Extraction complete: {found} of {total} items found in {time_s} ({total_time_ms:.0f} ms)." | |
| ) | |
| async def _async_perform_extraction_ui( | |
| files: Any, | |
| session_chunks: list[Any] | None, | |
| active_tab: str, | |
| raw_json: str, | |
| vis: list[bool], | |
| *field_args: Any, | |
| start_time: float | None = None, | |
| ) -> tuple[str, dict[str, Any], list[Any], str]: | |
| """Execute complete document extraction pipeline from dynamic Gradio UI fields or raw JSON schema. | |
| Uses initial OCR/ingested chunks from session state to avoid redundant OCR runs on queries. | |
| """ | |
| import time | |
| if start_time is None: | |
| start_time = time.perf_counter() | |
| if not files and not session_chunks: | |
| err = {"error": "Please upload at least one document (PDF, DOCX, XLSX, or Image)."} | |
| return ( | |
| render_results_html(err), | |
| err, | |
| [], | |
| make_status_html("Please upload at least one document to start.", is_success=False), | |
| ) | |
| file_list = files if isinstance(files, list) else ([files] if files else []) | |
| if len(file_list) == 0 and not session_chunks: | |
| err = {"error": "Please upload at least one document."} | |
| return ( | |
| render_results_html(err), | |
| err, | |
| [], | |
| make_status_html("Please upload at least one document.", is_success=False), | |
| ) | |
| from docneedle.extraction.schemas import ExtractionItemQuery | |
| from docneedle.extraction.service import ExtractionService | |
| from docneedle.ingest.service import IngestionService | |
| from docneedle.retrieval.store import SessionVectorStore | |
| use_raw = active_tab == "raw" | |
| q_vals, k_vals, t_vals = unpack_field_args(field_args) | |
| form_queries = [ | |
| ExtractionItemQuery(key=it["key"], description=it["description"], type=it["type"]) | |
| for it in collect_form_items(q_vals, k_vals, t_vals, vis) | |
| ] | |
| raw_queries: list[ExtractionItemQuery] = [] | |
| if raw_json and raw_json.strip(): | |
| try: | |
| parsed_items = parse_raw_json_schema(raw_json) | |
| default_ex = DEFAULT_EXAMPLE_SCHEMA[0] | |
| for it in parsed_items: | |
| if ( | |
| it["key"] == default_ex["key"] | |
| and it["description"] == default_ex["description"] | |
| ): | |
| continue | |
| raw_queries.append( | |
| ExtractionItemQuery( | |
| key=it["key"], | |
| description=it["description"], | |
| type=it["type"], | |
| ) | |
| ) | |
| except Exception as e: | |
| if use_raw: | |
| err = {"error": f"Invalid JSON schema: {e}"} | |
| return ( | |
| render_results_html(err), | |
| err, | |
| list(session_chunks) if session_chunks else [], | |
| make_status_html(f"Schema Error: {e}", is_success=False), | |
| ) | |
| queries = raw_queries if (use_raw and raw_queries) else (form_queries or raw_queries) | |
| if not queries: | |
| err = {"error": "Please define at least one field to extract via Form Builder or Raw JSON."} | |
| return ( | |
| render_results_html(err), | |
| err, | |
| list(session_chunks) if session_chunks else [], | |
| make_status_html("Please define at least one field to extract.", is_success=False), | |
| ) | |
| try: | |
| from docneedle.cuda_compat import preload_cuda_runtime_libs | |
| preload_cuda_runtime_libs() | |
| except Exception: | |
| pass | |
| ingest_svc = IngestionService() | |
| extraction_svc = ExtractionService() | |
| store = SessionVectorStore() | |
| try: | |
| chunks_to_use = list(session_chunks) if session_chunks else [] | |
| if not chunks_to_use: | |
| with ingest_svc.parser: | |
| for f in file_list: | |
| file_path = Path(f.name if hasattr(f, "name") else str(f)) | |
| content = file_path.read_bytes() | |
| filename = file_path.name | |
| ingest_res = ingest_svc.ingest_bytes(content=content, filename=filename) | |
| chunks_to_use.extend(ingest_res.chunks) | |
| if not chunks_to_use: | |
| err = {"error": "Could not extract readable text from the uploaded documents."} | |
| return ( | |
| render_results_html(err), | |
| err, | |
| [], | |
| make_status_html( | |
| "Could not extract readable text from documents.", is_success=False | |
| ), | |
| ) | |
| logger.info( | |
| f"--> [PIPELINE] Indexing {len(chunks_to_use)} text chunks across {len(file_list)} files into in-memory vector store..." | |
| ) | |
| store.add_chunks(chunks_to_use) | |
| logger.info( | |
| f"--> [PIPELINE] Starting extraction pipeline for {len(queries)} target field query(s)..." | |
| ) | |
| response = await extraction_svc.extract_items(queries, store) | |
| resp_dict = response.model_dump() | |
| total_time_ms = round((time.perf_counter() - start_time) * 1000.0, 2) | |
| resp_dict["processing_time_ms"] = total_time_ms | |
| status_msg = _format_completion_status( | |
| resp_dict.get("found_items", 0), | |
| resp_dict.get("total_items", 0), | |
| total_time_ms, | |
| ) | |
| return ( | |
| render_results_html(resp_dict), | |
| resp_dict, | |
| chunks_to_use, | |
| make_status_html(status_msg, is_success=True), | |
| ) | |
| except Exception as e: | |
| logger.error(f"UI extraction error: {e}", exc_info=True) | |
| err = {"error": str(e)} | |
| return ( | |
| render_results_html(err), | |
| err, | |
| list(session_chunks) if session_chunks else [], | |
| make_status_html(f"Error: {e}", is_success=False), | |
| ) | |
| finally: | |
| store.clear() | |
| import gc | |
| gc.collect() | |
| if "torch" in sys.modules: | |
| try: | |
| import torch | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| except Exception: | |
| pass | |
| def _estimate_extraction_duration( | |
| files: Any = None, | |
| session_chunks: list[Any] | None = None, | |
| *args: Any, | |
| **kwargs: Any, | |
| ) -> int: | |
| """Dynamically estimate ZeroGPU reservation duration based on uploaded files and cache.""" | |
| if session_chunks: | |
| return 60 | |
| file_list = files if isinstance(files, list) else ([files] if files else []) | |
| num_files = len(file_list) | |
| if num_files <= 1: | |
| return 90 | |
| return min(180, 60 + num_files * 30) | |
| def perform_extraction_ui( | |
| files: Any, | |
| session_chunks: list[Any] | None, | |
| active_tab: str, | |
| raw_json: str, | |
| vis: list[bool], | |
| *field_args: Any, | |
| ) -> tuple[str, dict[str, Any], list[Any], str]: | |
| """Execute complete document extraction pipeline from dynamic Gradio UI fields or raw JSON schema.""" | |
| import asyncio | |
| import concurrent.futures | |
| import time | |
| start_time = time.perf_counter() | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: | |
| results_html, resp_dict, chunks_to_use, status_html = pool.submit( | |
| asyncio.run, | |
| _async_perform_extraction_ui( | |
| files, | |
| session_chunks, | |
| active_tab, | |
| raw_json, | |
| vis, | |
| *field_args, | |
| start_time=start_time, | |
| ), | |
| ).result() | |
| total_time_ms = round((time.perf_counter() - start_time) * 1000.0, 2) | |
| if isinstance(resp_dict, dict) and "results" in resp_dict: | |
| resp_dict["processing_time_ms"] = total_time_ms | |
| results_html = render_results_html(resp_dict) | |
| status_msg = _format_completion_status( | |
| resp_dict.get("found_items", 0), | |
| resp_dict.get("total_items", 0), | |
| total_time_ms, | |
| ) | |
| status_html = make_status_html(status_msg, is_success=True) | |
| return results_html, resp_dict, chunks_to_use, status_html | |
| def check_system_health() -> dict[str, Any]: | |
| return { | |
| "status": "healthy", | |
| "app_name": settings.app_name, | |
| "ocr_model": settings.ocr_model_name, | |
| "ocr_use_cuda": settings.ocr_use_cuda, | |
| "llm_model": settings.llm_model_name, | |
| "embedding_model": settings.embedding_model, | |
| "reranker_model": settings.reranker_model, | |
| "gpu_enabled": settings.gpu_enabled, | |
| } | |
| # 2. Build interactive Gradio Dashboard (dark mode & English) | |
| with gr.Blocks(title="DocNeedle - Document Extraction") as demo: | |
| gr.HTML(HEADER_HTML) | |
| with gr.Row(): | |
| # Left Column: Documents & Dynamic Fields (5 Columns) | |
| with gr.Column(scale=5): | |
| with gr.Group(): | |
| gr.Markdown( | |
| "### 📁 DOCUMENTS\nUpload PDFs, scanned files, spreadsheets, or text files" | |
| ) | |
| file_input = gr.File( | |
| label="Upload Documents (Drag & Drop)", | |
| file_count="multiple", | |
| file_types=[ | |
| ".pdf", | |
| ".docx", | |
| ".doc", | |
| ".xlsx", | |
| ".xls", | |
| ".png", | |
| ".jpg", | |
| ".jpeg", | |
| ".webp", | |
| ".txt", | |
| ], | |
| ) | |
| session_chunks_state = gr.State([]) | |
| doc_status_view = gr.HTML( | |
| value=make_status_html( | |
| "Upload documents to begin. Processing will run when you click Extract Data." | |
| ) | |
| ) | |
| with gr.Group(): | |
| with gr.Row(): | |
| gr.Markdown( | |
| "### 🎯 FIELDS TO EXTRACT\nDefine target fields via form or raw JSON" | |
| ) | |
| with gr.Row(elem_classes=["dn-header-actions"]): | |
| import_schema_btn = gr.UploadButton( | |
| "📥 Import", | |
| file_types=[".json"], | |
| elem_classes=["dn-action-btn"], | |
| size="sm", | |
| ) | |
| export_schema_btn = gr.DownloadButton( | |
| "📤 Export", | |
| elem_classes=["dn-action-btn"], | |
| size="sm", | |
| ) | |
| clear_all_btn = gr.Button( | |
| "🗑️ Clear", | |
| elem_classes=["dn-action-btn", "dn-clear-btn"], | |
| size="sm", | |
| ) | |
| active_tab_state = gr.State("form") | |
| with gr.Tabs(elem_classes=["dn-input-tabs"]) as input_tabs: | |
| with gr.Tab("📝 Form Builder", id="tab_form_input") as tab_form_input: | |
| vis_state = gr.State( | |
| [True] * len(INITIAL_STARTER_FIELDS) | |
| + [False] * (MAX_FIELDS - len(INITIAL_STARTER_FIELDS)) | |
| ) | |
| field_groups: list[Any] = [] | |
| q_inputs: list[Any] = [] | |
| k_inputs: list[Any] = [] | |
| t_inputs: list[Any] = [] | |
| del_btns: list[Any] = [] | |
| for i in range(MAX_FIELDS): | |
| is_init_visible = i < len(INITIAL_STARTER_FIELDS) | |
| init_q = INITIAL_STARTER_FIELDS[i][0] if is_init_visible else "" | |
| init_k = INITIAL_STARTER_FIELDS[i][1] if is_init_visible else "" | |
| init_t = INITIAL_STARTER_FIELDS[i][2] if is_init_visible else "string" | |
| with gr.Group( | |
| visible=is_init_visible, elem_classes=["dn-field-card"] | |
| ) as f_grp: | |
| with gr.Row(): | |
| q_box = gr.Textbox( | |
| value=init_q, | |
| label="Field Question / Prompt", | |
| placeholder="e.g. What is the total invoice amount including VAT?", | |
| scale=9, | |
| ) | |
| d_btn = gr.Button( | |
| "🗑️", | |
| scale=1, | |
| min_width=36, | |
| elem_classes=["dn-del-btn"], | |
| ) | |
| with gr.Row(): | |
| k_box = gr.Textbox( | |
| value=init_k, | |
| label="Target Key (Optional)", | |
| placeholder="e.g. total_amount", | |
| scale=1, | |
| ) | |
| t_dropdown = gr.Dropdown( | |
| choices=FIELD_TYPE_KEYS, | |
| value=init_t, | |
| label="Expected Type", | |
| scale=1, | |
| ) | |
| field_groups.append(f_grp) | |
| q_inputs.append(q_box) | |
| k_inputs.append(k_box) | |
| t_inputs.append(t_dropdown) | |
| del_btns.append(d_btn) | |
| add_field_btn = gr.Button("+ Add Field", elem_classes=["dn-add-field-btn"]) | |
| with gr.Tab("📊 Raw JSON View", id="tab_raw_input") as tab_raw_input: | |
| gr.Markdown( | |
| "*Enter or edit schema directly as a JSON list of objects (`key`, `description`, `type`).*", | |
| elem_classes=["dn-raw-hint"], | |
| ) | |
| raw_json_input = gr.Code( | |
| value=serialize_form_to_json( | |
| [f[0] for f in INITIAL_STARTER_FIELDS], | |
| [f[1] for f in INITIAL_STARTER_FIELDS], | |
| [f[2] for f in INITIAL_STARTER_FIELDS], | |
| ), | |
| language="json", | |
| label="JSON Schema Specification", | |
| lines=12, | |
| ) | |
| with gr.Row(): | |
| apply_raw_to_form_btn = gr.Button( | |
| "⬇️ Apply to Form", | |
| elem_classes=["dn-secondary-btn"], | |
| size="sm", | |
| ) | |
| sync_form_to_raw_btn = gr.Button( | |
| "⬆️ Sync from Form", | |
| elem_classes=["dn-secondary-btn"], | |
| size="sm", | |
| ) | |
| extract_btn = gr.Button( | |
| "Extract Data", | |
| variant="primary", | |
| elem_classes=["dn-primary-btn"], | |
| ) | |
| # Right Column: Result Cards & Provenance (7 Columns) with Dual Views | |
| with gr.Column(scale=7): | |
| with gr.Tabs(elem_classes=["dn-output-tabs"]): | |
| with gr.Tab("📋 Formatted View", id="tab_formatted"): | |
| results_html_view = gr.HTML(value=render_results_html(None)) | |
| with gr.Tab("📊 Raw JSON View", id="tab_raw"): | |
| raw_json_output = gr.JSON(label="Complete JSON Extraction Response") | |
| with gr.Accordion("⚙️ System Health & API Info", open=False): | |
| health_btn = gr.Button("Check System Health", size="sm") | |
| health_json = gr.JSON(label="System Details") | |
| health_btn.click( | |
| check_system_health, outputs=health_json, api_name=False, queue=False | |
| ) | |
| gr.Markdown( | |
| """ | |
| * **Swagger API Documentation:** [/docs](/docs) | |
| * **Extraction Endpoint:** `POST /v1/extract` | |
| * **Health Endpoint:** `GET /healthz` | |
| """ | |
| ) | |
| # Event handlers for dynamic schema builder (all internal UI events with api_name=False) | |
| def on_add_field(vis: list[bool], *field_args: Any) -> list[Any]: | |
| new_vis = list(vis) | |
| q_vals = field_args[0:MAX_FIELDS] | |
| k_vals = field_args[MAX_FIELDS : 2 * MAX_FIELDS] | |
| for idx in range(len(new_vis)): | |
| q = str(q_vals[idx]).strip() if idx < len(q_vals) and q_vals[idx] else "" | |
| k = str(k_vals[idx]).strip() if idx < len(k_vals) and k_vals[idx] else "" | |
| if q or k: | |
| new_vis[idx] = True | |
| for idx in range(len(new_vis)): | |
| if not new_vis[idx]: | |
| new_vis[idx] = True | |
| break | |
| return [new_vis] + [gr.update(visible=v) for v in new_vis] | |
| add_field_btn.click( | |
| on_add_field, | |
| inputs=[vis_state] + q_inputs + k_inputs, | |
| outputs=[vis_state] + field_groups, | |
| api_name=False, | |
| queue=False, | |
| ) | |
| def make_delete_handler(target_idx: int): | |
| def on_delete(vis: list[bool]) -> tuple[Any, ...]: | |
| new_vis = list(vis) | |
| new_vis[target_idx] = False | |
| return ( | |
| new_vis, | |
| gr.update(visible=False), | |
| gr.update(value=""), | |
| gr.update(value=""), | |
| gr.update(value="string"), | |
| ) | |
| return on_delete | |
| for idx, d_btn in enumerate(del_btns): | |
| d_btn.click( | |
| make_delete_handler(idx), | |
| inputs=[vis_state], | |
| outputs=[vis_state, field_groups[idx], q_inputs[idx], k_inputs[idx], t_inputs[idx]], | |
| api_name=False, | |
| queue=False, | |
| ) | |
| def on_clear_all() -> list[Any]: | |
| new_vis, grp_ups, q_ups, k_ups, t_ups, empty_json = apply_items_to_form_updates([]) | |
| return [new_vis] + grp_ups + q_ups + k_ups + t_ups + [empty_json] | |
| clear_all_btn.click( | |
| on_clear_all, | |
| outputs=[vis_state] + field_groups + q_inputs + k_inputs + t_inputs + [raw_json_input], | |
| api_name=False, | |
| queue=False, | |
| ) | |
| def on_sync_form_to_raw(vis: list[bool], *field_args: Any) -> str: | |
| q_vals, k_vals, t_vals = unpack_field_args(field_args) | |
| return serialize_form_to_json(q_vals, k_vals, t_vals, vis) | |
| sync_form_to_raw_btn.click( | |
| on_sync_form_to_raw, | |
| inputs=[vis_state] + q_inputs + k_inputs + t_inputs, | |
| outputs=[raw_json_input], | |
| api_name=False, | |
| queue=False, | |
| ) | |
| def on_apply_raw_to_form(raw_json: str) -> tuple[Any, ...]: | |
| try: | |
| items = parse_raw_json_schema(raw_json) | |
| if not items: | |
| gr.Warning("No valid fields found in raw JSON schema.") | |
| return (gr.skip(),) * (1 + MAX_FIELDS * 4) | |
| new_vis, grp_ups, q_ups, k_ups, t_ups, _ = apply_items_to_form_updates(items) | |
| gr.Info(f"Loaded {len(items)} fields into Form Builder.") | |
| return tuple([new_vis] + grp_ups + q_ups + k_ups + t_ups) | |
| except Exception as e: | |
| gr.Warning(f"Invalid JSON: {e}") | |
| return (gr.skip(),) * (1 + MAX_FIELDS * 4) | |
| apply_raw_to_form_btn.click( | |
| on_apply_raw_to_form, | |
| inputs=[raw_json_input], | |
| outputs=[vis_state] + field_groups + q_inputs + k_inputs + t_inputs, | |
| api_name=False, | |
| queue=False, | |
| ) | |
| def on_import_schema(file: Any) -> tuple[Any, ...]: | |
| if not file: | |
| return (gr.skip(),) * (1 + MAX_FIELDS * 4 + 1) | |
| try: | |
| file_path = Path(file.name if hasattr(file, "name") else str(file)) | |
| content = file_path.read_text(encoding="utf-8") | |
| items = parse_raw_json_schema(content) | |
| if not items: | |
| gr.Warning("No valid extraction fields found in imported JSON file.") | |
| return (gr.skip(),) * (1 + MAX_FIELDS * 4 + 1) | |
| new_vis, grp_ups, q_ups, k_ups, t_ups, json_str = apply_items_to_form_updates(items) | |
| gr.Info(f"Successfully imported {len(items)} fields from file.") | |
| return tuple([new_vis] + grp_ups + q_ups + k_ups + t_ups + [json_str]) | |
| except Exception as e: | |
| gr.Warning(f"Import failed: {e}") | |
| return (gr.skip(),) * (1 + MAX_FIELDS * 4 + 1) | |
| import_schema_btn.upload( | |
| on_import_schema, | |
| inputs=[import_schema_btn], | |
| outputs=[vis_state] + field_groups + q_inputs + k_inputs + t_inputs + [raw_json_input], | |
| api_name=False, | |
| queue=False, | |
| ) | |
| def on_export_schema( | |
| active_tab: str, | |
| raw_json: str, | |
| vis: list[bool], | |
| *field_args: Any, | |
| ) -> str: | |
| items: list[dict[str, str]] = [] | |
| if active_tab == "raw" and raw_json and raw_json.strip(): | |
| try: | |
| items = parse_raw_json_schema(raw_json) | |
| except Exception: | |
| items = [] | |
| if not items: | |
| q_vals, k_vals, t_vals = unpack_field_args(field_args) | |
| items = collect_form_to_items_or_default(q_vals, k_vals, t_vals, vis) | |
| tmp = tempfile.NamedTemporaryFile( | |
| mode="w", | |
| suffix=".json", | |
| prefix="docneedle_schema_", | |
| delete=False, | |
| encoding="utf-8", | |
| ) | |
| json.dump(items, tmp, indent=2, ensure_ascii=False) | |
| tmp.close() | |
| return tmp.name | |
| export_schema_btn.click( | |
| on_export_schema, | |
| inputs=[active_tab_state, raw_json_input, vis_state] + q_inputs + k_inputs + t_inputs, | |
| outputs=[export_schema_btn], | |
| api_name=False, | |
| queue=False, | |
| ) | |
| tab_form_input.select(lambda: "form", outputs=[active_tab_state], api_name=False, queue=False) | |
| def on_select_raw_tab(vis: list[bool], *field_args: Any) -> tuple[str, str]: | |
| q_vals, k_vals, t_vals = unpack_field_args(field_args) | |
| json_str = serialize_form_to_json(q_vals, k_vals, t_vals, vis) | |
| return "raw", json_str | |
| tab_raw_input.select( | |
| on_select_raw_tab, | |
| inputs=[vis_state] + q_inputs + k_inputs + t_inputs, | |
| outputs=[active_tab_state, raw_json_input], | |
| api_name=False, | |
| queue=False, | |
| ) | |
| def on_documents_changed(files: Any) -> tuple[str, list[Any]]: | |
| if not files: | |
| return ( | |
| make_status_html( | |
| "Upload documents to begin. Processing will run when you click Extract Data." | |
| ), | |
| [], | |
| ) | |
| file_list = files if isinstance(files, list) else [files] | |
| if not file_list: | |
| return ( | |
| make_status_html( | |
| "Upload documents to begin. Processing will run when you click Extract Data." | |
| ), | |
| [], | |
| ) | |
| names = [Path(f.name if hasattr(f, "name") else str(f)).name for f in file_list] | |
| total_kb = ( | |
| sum( | |
| Path(f.name if hasattr(f, "name") else str(f)).stat().st_size | |
| for f in file_list | |
| if Path(f.name if hasattr(f, "name") else str(f)).exists() | |
| ) | |
| / 1024 | |
| ) | |
| names_str = ", ".join(names[:3]) + (f" (+{len(names) - 3} more)" if len(names) > 3 else "") | |
| status_text = ( | |
| f"📁 <strong>{len(file_list)} document(s) selected</strong> ({names_str} · {total_kb:.1f} KB). " | |
| "Click <strong>Extract Data</strong> to process." | |
| ) | |
| return make_status_html(status_text, is_success=False), [] | |
| file_input.change( | |
| on_documents_changed, | |
| inputs=[file_input], | |
| outputs=[doc_status_view, session_chunks_state], | |
| api_name=False, | |
| queue=False, | |
| ) | |
| extract_btn.click( | |
| perform_extraction_ui, | |
| inputs=[ | |
| file_input, | |
| session_chunks_state, | |
| active_tab_state, | |
| raw_json_input, | |
| vis_state, | |
| ] | |
| + q_inputs | |
| + k_inputs | |
| + t_inputs, | |
| outputs=[results_html_view, raw_json_output, session_chunks_state, doc_status_view], | |
| api_name=False, | |
| ) | |
| # ZeroGPU keepalive and compatibility handler | |
| try: | |
| def _zerogpu_compat_probe() -> str: | |
| return "" | |
| _zg_btn = gr.Button("ZeroGPU Init", visible=False) | |
| _zg_btn.click(_zerogpu_compat_probe, outputs=[], api_name=False) | |
| except Exception: | |
| pass | |
| # 3. Mount complete FastAPI REST API onto Gradio App | |
| from gradio.routes import App # noqa: E402 | |
| from docneedle.api.routes_extract import router as extract_router # noqa: E402 | |
| from docneedle.api.routes_jobs import router as jobs_router # noqa: E402 | |
| from docneedle.main import health_check # noqa: E402 | |
| gradio_app = App( | |
| title=settings.app_name, | |
| version=settings.app_version, | |
| description="Privacy-first local RAG document extraction microservice", | |
| docs_url="/docs", | |
| redoc_url="/redoc", | |
| ) | |
| gradio_app.include_router(extract_router) | |
| gradio_app.include_router(jobs_router) | |
| gradio_app.add_api_route("/healthz", health_check, methods=["GET"]) | |
| gradio_app.add_api_route("/health", health_check, methods=["GET"]) | |
| if __name__ == "__main__": | |
| app = gradio_app | |
| port = int(os.environ.get("PORT", 7860)) | |
| logger.info(f"--> [STARTUP] Starting DocNeedle on 0.0.0.0:{port}...") | |
| demo.launch( | |
| _app=gradio_app, | |
| server_name="0.0.0.0", | |
| server_port=port, | |
| footer_links=[], | |
| css=CUSTOM_CSS, | |
| ssr_mode=False, | |
| ) | |
| else: | |
| # Mount Gradio dashboard onto root path for external ASGI runners (e.g. uvicorn app:app) | |
| app = gr.mount_gradio_app( | |
| gradio_app, | |
| demo, | |
| path="/", | |
| footer_links=[], | |
| css=CUSTOM_CSS, | |
| ssr_mode=False, | |
| ) | |