Spaces:
Running
Running
Commit ·
f65e025
0
Parent(s):
Deploy: DocAgent backend (deterministic date-anomaly fix)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +28 -0
- .gitignore +34 -0
- Dockerfile +68 -0
- README.md +164 -0
- backend/.dockerignore +22 -0
- backend/.env.example +36 -0
- backend/Dockerfile +61 -0
- backend/app/__init__.py +0 -0
- backend/app/agent/__init__.py +0 -0
- backend/app/agent/orchestrator.py +92 -0
- backend/app/agent/prompts.py +21 -0
- backend/app/agent/tools.py +139 -0
- backend/app/api/__init__.py +0 -0
- backend/app/api/actions.py +88 -0
- backend/app/api/chat.py +75 -0
- backend/app/api/documents.py +79 -0
- backend/app/api/meta.py +30 -0
- backend/app/core/__init__.py +0 -0
- backend/app/core/config.py +97 -0
- backend/app/core/logging.py +26 -0
- backend/app/llm/__init__.py +0 -0
- backend/app/llm/base.py +110 -0
- backend/app/llm/gemini_provider.py +157 -0
- backend/app/llm/openai_provider.py +140 -0
- backend/app/llm/registry.py +81 -0
- backend/app/main.py +61 -0
- backend/app/schemas/__init__.py +0 -0
- backend/app/schemas/documents.py +172 -0
- backend/app/schemas/templates.py +22 -0
- backend/app/services/__init__.py +0 -0
- backend/app/services/anomaly.py +241 -0
- backend/app/services/classification.py +51 -0
- backend/app/services/export.py +99 -0
- backend/app/services/extraction.py +273 -0
- backend/app/services/ingestion.py +243 -0
- backend/app/services/pipeline.py +97 -0
- backend/app/services/qa.py +63 -0
- backend/app/services/storage.py +86 -0
- backend/app/services/summary.py +58 -0
- backend/app/services/vectorstore.py +120 -0
- backend/requirements.txt +29 -0
- backend/scripts/prefetch_models.py +36 -0
- docker-compose.yml +24 -0
- docs/ARCHITECTURE.md +65 -0
- docs/DEPLOY.md +125 -0
- docs/OVERVIEW.md +253 -0
- docs/RESEARCH.md +238 -0
- frontend/app/globals.css +235 -0
- frontend/app/layout.tsx +27 -0
- frontend/app/page.tsx +146 -0
.dockerignore
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Keep the HF Spaces / Docker build context small. Only backend/ is COPYed into
|
| 2 |
+
# the image, but trimming the context speeds the upload + build.
|
| 3 |
+
.git/
|
| 4 |
+
.claude/
|
| 5 |
+
.vercel/
|
| 6 |
+
|
| 7 |
+
# Frontend (deployed separately on Vercel)
|
| 8 |
+
frontend/node_modules/
|
| 9 |
+
frontend/.next/
|
| 10 |
+
|
| 11 |
+
# Python
|
| 12 |
+
backend/.venv/
|
| 13 |
+
backend/data/
|
| 14 |
+
**/__pycache__/
|
| 15 |
+
**/*.py[cod]
|
| 16 |
+
|
| 17 |
+
# Local model caches (models are fetched fresh at build by prefetch_models.py)
|
| 18 |
+
**/.cache/
|
| 19 |
+
**/.EasyOCR/
|
| 20 |
+
|
| 21 |
+
# Secrets / local env
|
| 22 |
+
**/.env
|
| 23 |
+
**/.env.*
|
| 24 |
+
!**/.env.example
|
| 25 |
+
|
| 26 |
+
# Misc
|
| 27 |
+
.DS_Store
|
| 28 |
+
Thumbs.db
|
.gitignore
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
.venv/
|
| 5 |
+
venv/
|
| 6 |
+
backend/.env
|
| 7 |
+
backend/data/
|
| 8 |
+
|
| 9 |
+
# Node / Next
|
| 10 |
+
node_modules/
|
| 11 |
+
.next/
|
| 12 |
+
frontend/.env.local
|
| 13 |
+
frontend/.env*.local
|
| 14 |
+
npm-debug.log*
|
| 15 |
+
|
| 16 |
+
# Deploy / secrets
|
| 17 |
+
.env
|
| 18 |
+
.env.*
|
| 19 |
+
!.env.example
|
| 20 |
+
!**/.env.example
|
| 21 |
+
.vercel/
|
| 22 |
+
|
| 23 |
+
# Local model caches (baked into the image instead)
|
| 24 |
+
**/.cache/
|
| 25 |
+
**/.EasyOCR/
|
| 26 |
+
|
| 27 |
+
# OS / editor
|
| 28 |
+
.DS_Store
|
| 29 |
+
Thumbs.db
|
| 30 |
+
.vscode/
|
| 31 |
+
.idea/
|
| 32 |
+
|
| 33 |
+
# Local agent settings
|
| 34 |
+
.claude/
|
Dockerfile
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1
|
| 2 |
+
# ---------------------------------------------------------------------------
|
| 3 |
+
# Hugging Face Spaces image for the Document-Agent backend (free CPU tier).
|
| 4 |
+
#
|
| 5 |
+
# HF Spaces (Docker SDK) builds the Dockerfile at the repo ROOT with the repo
|
| 6 |
+
# root as the build context, so source paths are prefixed with backend/. HF
|
| 7 |
+
# runs the container as UID 1000 and routes traffic to the port declared as
|
| 8 |
+
# `app_port:` in README.md (8000 here).
|
| 9 |
+
#
|
| 10 |
+
# Free Spaces have NO persistent disk, so DATA_DIR is an ephemeral, user-owned
|
| 11 |
+
# directory: uploads / SQLite / the vector store reset on restart or rebuild.
|
| 12 |
+
# That is fine for a demo. The ML models are baked in at build time (same as
|
| 13 |
+
# the production image) so cold start is fast and there are no runtime
|
| 14 |
+
# downloads.
|
| 15 |
+
# ---------------------------------------------------------------------------
|
| 16 |
+
FROM python:3.12-slim
|
| 17 |
+
|
| 18 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 19 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 20 |
+
PIP_NO_CACHE_DIR=1 \
|
| 21 |
+
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
| 22 |
+
HF_HUB_DISABLE_SYMLINKS_WARNING=1 \
|
| 23 |
+
# Fixed, shared cache locations so the non-root runtime user finds the
|
| 24 |
+
# models baked in during build.
|
| 25 |
+
HF_HOME=/opt/models/hf \
|
| 26 |
+
EASYOCR_MODULE_PATH=/opt/models/easyocr \
|
| 27 |
+
TORCH_HOME=/opt/models/torch \
|
| 28 |
+
DATA_DIR=/data
|
| 29 |
+
|
| 30 |
+
# Native libs needed by docling / opencv / easyocr.
|
| 31 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 32 |
+
libgl1 \
|
| 33 |
+
libglib2.0-0 \
|
| 34 |
+
libgomp1 \
|
| 35 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 36 |
+
|
| 37 |
+
WORKDIR /app
|
| 38 |
+
|
| 39 |
+
# Install CPU-only torch/torchvision FIRST so docling's torch dependency
|
| 40 |
+
# resolves to the lightweight CPU wheels instead of multi-GB CUDA builds.
|
| 41 |
+
RUN pip install --index-url https://download.pytorch.org/whl/cpu \
|
| 42 |
+
torch torchvision
|
| 43 |
+
|
| 44 |
+
COPY backend/requirements.txt .
|
| 45 |
+
RUN pip install -r requirements.txt
|
| 46 |
+
|
| 47 |
+
# Bake the models into the image.
|
| 48 |
+
COPY backend/scripts/prefetch_models.py scripts/prefetch_models.py
|
| 49 |
+
RUN mkdir -p /opt/models && python scripts/prefetch_models.py
|
| 50 |
+
|
| 51 |
+
# Backend app source (repo root is the build context on HF Spaces).
|
| 52 |
+
COPY backend/ .
|
| 53 |
+
|
| 54 |
+
# HF Spaces runs the container as UID 1000. Make the data dir and the baked
|
| 55 |
+
# model/app trees writable/readable by that user.
|
| 56 |
+
RUN useradd -m -u 1000 user \
|
| 57 |
+
&& mkdir -p /data \
|
| 58 |
+
&& chown -R user:user /data /app /opt/models
|
| 59 |
+
USER user
|
| 60 |
+
ENV HOME=/home/user
|
| 61 |
+
|
| 62 |
+
EXPOSE 8000
|
| 63 |
+
|
| 64 |
+
# Honour $PORT if a host injects it; default 8000 matches app_port in README.
|
| 65 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
| 66 |
+
CMD python -c "import os,urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:%s/api/health' % os.environ.get('PORT','8000')).status==200 else 1)"
|
| 67 |
+
|
| 68 |
+
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
|
README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: DocAgent Backend
|
| 3 |
+
emoji: 📄
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 8000
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# DocAgent — Document Processing AI Agent
|
| 12 |
+
|
| 13 |
+
> **Hugging Face Space note:** this Space runs the **FastAPI backend** (Docker).
|
| 14 |
+
> The UI is deployed separately on Vercel and talks to this Space's URL. Health
|
| 15 |
+
> check: `GET /api/health`. On the free tier there is no persistent disk, so
|
| 16 |
+
> uploads/DB reset on restart. See [`docs/DEPLOY.md`](docs/DEPLOY.md).
|
| 17 |
+
|
| 18 |
+
A production-grade, **chat-based document processing agent** built for Monkhub Innovations.
|
| 19 |
+
Upload any document → it parses, classifies, extracts structured data, summarizes,
|
| 20 |
+
answers grounded questions, flags anomalies, and exports clean data.
|
| 21 |
+
|
| 22 |
+
> **40 / 60 UI:** a persistent streaming chat (40%) beside a functional workspace
|
| 23 |
+
> (60%) — document viewer with bounding-box citation highlights, extracted fields &
|
| 24 |
+
> tables, classification, summary, and export.
|
| 25 |
+
|
| 26 |
+
Built on the findings in [`docs/RESEARCH.md`](docs/RESEARCH.md) — a composed stack:
|
| 27 |
+
**Docling** (local extraction) · **Gemini Flash** (free default LLM) ⇄ **GPT-4o-mini**
|
| 28 |
+
(fallback) · agentic-RAG orchestration · **FastAPI** + **Next.js**.
|
| 29 |
+
|
| 30 |
+
> 📘 **New here? Read [`docs/OVERVIEW.md`](docs/OVERVIEW.md)** — one document covering the
|
| 31 |
+
> research, what we chose and why, the comparison table, the feature/function set, and
|
| 32 |
+
> exactly how the agent works (architecture + both flows + a walkthrough).
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## ✨ Features (the 7 core functions)
|
| 37 |
+
|
| 38 |
+
| Function | What it does |
|
| 39 |
+
|---|---|
|
| 40 |
+
| **Ingest** | PDF, DOCX, PPTX, XLSX, HTML, images — parsed with Docling (layout, tables, OCR) |
|
| 41 |
+
| **Extract** | Schema-driven fields, tables, key-value pairs, entities — each with a confidence score |
|
| 42 |
+
| **Classify** | Zero-shot document type detection with confidence + candidates |
|
| 43 |
+
| **Summarize** | Map-reduce summary (TL;DR + key points) |
|
| 44 |
+
| **Q&A** | Conversational, agentic-RAG answers **grounded with citations** you can click to highlight on the page |
|
| 45 |
+
| **Flag anomalies** | Missing required fields, low-confidence values, arithmetic checks, LLM consistency review |
|
| 46 |
+
| **Export** | JSON · CSV · Excel |
|
| 47 |
+
|
| 48 |
+
---
|
| 49 |
+
|
| 50 |
+
## 🏗️ Architecture
|
| 51 |
+
|
| 52 |
+
See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full diagram and rationale.
|
| 53 |
+
|
| 54 |
+
```
|
| 55 |
+
Next.js (40/60 UI) ──REST + SSE──▶ FastAPI
|
| 56 |
+
├─ agent/ orchestrator (tool-calling loop)
|
| 57 |
+
├─ llm/ Gemini ⇄ OpenAI provider abstraction
|
| 58 |
+
├─ services/ ingestion(Docling) · extraction ·
|
| 59 |
+
│ classify · summary · qa(RAG) ·
|
| 60 |
+
│ anomaly · export · vectorstore · storage
|
| 61 |
+
└─ schemas/ Pydantic contracts + extraction templates
|
| 62 |
+
Docling (local) · ChromaDB (embedded) · SQLite
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
**Design highlights**
|
| 66 |
+
- **Single orchestrator agent** with 5 tools (query, classify, get-extracted, summarize, flag) — the simplest design that covers the brief, per the research.
|
| 67 |
+
- **Provider abstraction**: nothing imports a vendor SDK except `llm/*`. Switch Gemini⇄OpenAI by env, per-request, or in the UI.
|
| 68 |
+
- **Provenance-aware chunks**: every chunk carries page + normalised bbox, so RAG answers and extracted fields highlight their exact source.
|
| 69 |
+
- **Local-first & ~zero marginal cost**: Docling runs locally; Gemini free tier; ChromaDB + SQLite need no external services.
|
| 70 |
+
|
| 71 |
+
---
|
| 72 |
+
|
| 73 |
+
## 🚀 Quick start
|
| 74 |
+
|
| 75 |
+
### Prerequisites
|
| 76 |
+
- **Python 3.10–3.12** (3.13 works for the API; some ML wheels lag — see notes)
|
| 77 |
+
- **Node.js 18+**
|
| 78 |
+
- A **free Gemini API key** → https://aistudio.google.com/apikey
|
| 79 |
+
|
| 80 |
+
### 1. Backend
|
| 81 |
+
|
| 82 |
+
```bash
|
| 83 |
+
cd backend
|
| 84 |
+
python -m venv .venv
|
| 85 |
+
# Windows: .venv\Scripts\activate
|
| 86 |
+
# macOS/Linux: source .venv/bin/activate
|
| 87 |
+
pip install -r requirements.txt
|
| 88 |
+
|
| 89 |
+
cp .env.example .env # then put your GEMINI_API_KEY in .env
|
| 90 |
+
uvicorn app.main:app --reload --port 8000
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
API now at http://localhost:8000 — interactive docs at http://localhost:8000/docs.
|
| 94 |
+
|
| 95 |
+
### 2. Frontend
|
| 96 |
+
|
| 97 |
+
```bash
|
| 98 |
+
cd frontend
|
| 99 |
+
npm install
|
| 100 |
+
cp .env.local.example .env.local # default points at :8000
|
| 101 |
+
npm run dev
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
App now at **http://localhost:3000**.
|
| 105 |
+
|
| 106 |
+
---
|
| 107 |
+
|
| 108 |
+
## 🔧 Configuration (`backend/.env`)
|
| 109 |
+
|
| 110 |
+
| Var | Default | Notes |
|
| 111 |
+
|---|---|---|
|
| 112 |
+
| `GEMINI_API_KEY` | — | **Required.** Free tier. |
|
| 113 |
+
| `OPENAI_API_KEY` | — | Optional fallback (GPT-4o-mini). |
|
| 114 |
+
| `DEFAULT_LLM_PROVIDER` | `gemini` | `gemini` or `openai`. |
|
| 115 |
+
| `GEMINI_MODEL` | `gemini-2.0-flash` | |
|
| 116 |
+
| `OPENAI_MODEL` | `gpt-4o-mini` | |
|
| 117 |
+
| `ENABLE_OCR` | `true` | Docling OCR for scanned docs. |
|
| 118 |
+
|
| 119 |
+
The app runs with **only** `GEMINI_API_KEY`. If both keys are present you can switch
|
| 120 |
+
providers live from the header toggle.
|
| 121 |
+
|
| 122 |
+
---
|
| 123 |
+
|
| 124 |
+
## 🧭 How to use
|
| 125 |
+
|
| 126 |
+
1. Drag a document into the left rail (or click **New document**).
|
| 127 |
+
2. Watch it process — pages render, then classification, fields, summary, and anomaly
|
| 128 |
+
flags stream into the workspace tabs.
|
| 129 |
+
3. **Chat** on the left: *"Summarize this"*, *"What's the total?"*, *"Any missing fields?"*
|
| 130 |
+
Click any **citation chip** to highlight its source on the page.
|
| 131 |
+
4. **Export** from the Export tab (JSON / CSV / Excel).
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
## 📁 Project layout
|
| 136 |
+
|
| 137 |
+
```
|
| 138 |
+
Document-agent/
|
| 139 |
+
├─ docs/
|
| 140 |
+
│ ├─ RESEARCH.md # deep research: 5 systems, scoring, winner, SOTA, UX
|
| 141 |
+
│ └─ ARCHITECTURE.md # architecture decisions + diagram
|
| 142 |
+
├─ backend/
|
| 143 |
+
│ ├─ app/
|
| 144 |
+
│ │ ├─ api/ # documents, chat(SSE), actions, meta
|
| 145 |
+
│ │ ├─ agent/ # orchestrator, tools, prompts
|
| 146 |
+
│ │ ├─ llm/ # base, gemini, openai, registry
|
| 147 |
+
│ │ ├─ services/ # ingestion, extraction, classify, summary, qa, anomaly, export, storage, vectorstore, pipeline
|
| 148 |
+
│ │ ├─ schemas/ # documents + extraction templates
|
| 149 |
+
│ │ └─ core/ # config, logging
|
| 150 |
+
│ └─ requirements.txt
|
| 151 |
+
└─ frontend/
|
| 152 |
+
├─ app/ # layout, page (40/60 shell)
|
| 153 |
+
├─ components/ # Chat, Workspace, Viewer, Fields, Classify, Summary, Export, Sidebar
|
| 154 |
+
└─ lib/ # api client (+SSE), types
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
---
|
| 158 |
+
|
| 159 |
+
## 📝 Notes & next steps
|
| 160 |
+
- **Python 3.13:** the API layer runs fine; if a Docling/Torch wheel isn't yet
|
| 161 |
+
published for 3.13 on your platform, use a 3.11/3.12 venv for the backend.
|
| 162 |
+
- Designed to scale: swap SQLite→Postgres and ChromaDB→pgvector by editing only
|
| 163 |
+
`services/storage.py` and `services/vectorstore.py`.
|
| 164 |
+
- Auth, rate limiting, and multi-tenant workspaces are natural follow-ons.
|
backend/.dockerignore
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Keep the build context small and never ship secrets or local state.
|
| 2 |
+
.venv/
|
| 3 |
+
venv/
|
| 4 |
+
__pycache__/
|
| 5 |
+
*.py[cod]
|
| 6 |
+
*.egg-info/
|
| 7 |
+
.pytest_cache/
|
| 8 |
+
.mypy_cache/
|
| 9 |
+
.ruff_cache/
|
| 10 |
+
|
| 11 |
+
# Local runtime data (uploads, sqlite, rendered pages, vector store).
|
| 12 |
+
data/
|
| 13 |
+
|
| 14 |
+
# Secrets — provided via host env vars in production.
|
| 15 |
+
.env
|
| 16 |
+
.env.*
|
| 17 |
+
|
| 18 |
+
# Misc
|
| 19 |
+
.git/
|
| 20 |
+
.DS_Store
|
| 21 |
+
Thumbs.db
|
| 22 |
+
*.log
|
backend/.env.example
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# Document Processing AI Agent — backend configuration
|
| 3 |
+
# Copy to `.env` and fill in. Only GEMINI_API_KEY is required.
|
| 4 |
+
# ============================================================
|
| 5 |
+
|
| 6 |
+
# --- LLM providers ---
|
| 7 |
+
# Default provider is the free Gemini tier. Get a key at https://aistudio.google.com/apikey
|
| 8 |
+
GEMINI_API_KEY=
|
| 9 |
+
# Optional switchable fallback. Leave blank to disable.
|
| 10 |
+
OPENAI_API_KEY=
|
| 11 |
+
|
| 12 |
+
# Which provider to use by default: "gemini" or "openai"
|
| 13 |
+
DEFAULT_LLM_PROVIDER=gemini
|
| 14 |
+
|
| 15 |
+
# Model names (override only if you know what you want)
|
| 16 |
+
GEMINI_MODEL=gemini-2.0-flash
|
| 17 |
+
OPENAI_MODEL=gpt-4o-mini
|
| 18 |
+
|
| 19 |
+
# Embedding model for RAG (Gemini, free)
|
| 20 |
+
GEMINI_EMBED_MODEL=text-embedding-004
|
| 21 |
+
|
| 22 |
+
# --- Storage ---
|
| 23 |
+
DATA_DIR=./data
|
| 24 |
+
CHROMA_DIR=./data/chroma
|
| 25 |
+
|
| 26 |
+
# --- Server ---
|
| 27 |
+
HOST=0.0.0.0
|
| 28 |
+
PORT=8000
|
| 29 |
+
# Comma-separated allowed origins for CORS (frontend dev server)
|
| 30 |
+
CORS_ORIGINS=http://localhost:3000
|
| 31 |
+
|
| 32 |
+
# --- Extraction ---
|
| 33 |
+
# Max pages to render to images for the viewer / vision fallback
|
| 34 |
+
MAX_RENDER_PAGES=30
|
| 35 |
+
# Run Docling OCR on scanned PDFs/images (slower but handles scans)
|
| 36 |
+
ENABLE_OCR=true
|
backend/Dockerfile
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1
|
| 2 |
+
# ---------------------------------------------------------------------------
|
| 3 |
+
# Production image for the Document-Agent FastAPI backend.
|
| 4 |
+
#
|
| 5 |
+
# It bundles Docling + (CPU) torch + the layout/table/OCR models so the
|
| 6 |
+
# container is self-contained: no model downloads at runtime, fast cold start,
|
| 7 |
+
# works offline. On Linux the HuggingFace symlink cache works normally, so the
|
| 8 |
+
# Windows WinError-1314 issue does not apply here.
|
| 9 |
+
# ---------------------------------------------------------------------------
|
| 10 |
+
FROM python:3.12-slim
|
| 11 |
+
|
| 12 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 13 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 14 |
+
PIP_NO_CACHE_DIR=1 \
|
| 15 |
+
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
| 16 |
+
HF_HUB_DISABLE_SYMLINKS_WARNING=1 \
|
| 17 |
+
# Fixed, shared cache locations so the non-root runtime user finds the
|
| 18 |
+
# models baked in during build.
|
| 19 |
+
HF_HOME=/opt/models/hf \
|
| 20 |
+
EASYOCR_MODULE_PATH=/opt/models/easyocr \
|
| 21 |
+
TORCH_HOME=/opt/models/torch \
|
| 22 |
+
DATA_DIR=/data
|
| 23 |
+
|
| 24 |
+
# Native libs needed by docling / opencv / easyocr.
|
| 25 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 26 |
+
libgl1 \
|
| 27 |
+
libglib2.0-0 \
|
| 28 |
+
libgomp1 \
|
| 29 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 30 |
+
|
| 31 |
+
WORKDIR /app
|
| 32 |
+
|
| 33 |
+
# Install CPU-only torch/torchvision FIRST so docling's torch dependency
|
| 34 |
+
# resolves to the lightweight CPU wheels instead of multi-GB CUDA builds.
|
| 35 |
+
RUN pip install --index-url https://download.pytorch.org/whl/cpu \
|
| 36 |
+
torch torchvision
|
| 37 |
+
|
| 38 |
+
COPY requirements.txt .
|
| 39 |
+
RUN pip install -r requirements.txt
|
| 40 |
+
|
| 41 |
+
# Bake the models into the image.
|
| 42 |
+
COPY scripts/prefetch_models.py scripts/prefetch_models.py
|
| 43 |
+
RUN mkdir -p /opt/models && python scripts/prefetch_models.py
|
| 44 |
+
|
| 45 |
+
# App source.
|
| 46 |
+
COPY . .
|
| 47 |
+
|
| 48 |
+
# Run as a non-root user; /data holds uploads, sqlite db and rendered pages and
|
| 49 |
+
# is expected to be a mounted persistent volume in production.
|
| 50 |
+
RUN useradd --create-home app \
|
| 51 |
+
&& mkdir -p /data \
|
| 52 |
+
&& chown -R app:app /data /app /opt/models
|
| 53 |
+
USER app
|
| 54 |
+
|
| 55 |
+
EXPOSE 8000
|
| 56 |
+
|
| 57 |
+
# Honour $PORT (Render/most PaaS inject it); default 8000 for local/compose.
|
| 58 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
| 59 |
+
CMD python -c "import os,urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:%s/api/health' % os.environ.get('PORT','8000')).status==200 else 1)"
|
| 60 |
+
|
| 61 |
+
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
|
backend/app/__init__.py
ADDED
|
File without changes
|
backend/app/agent/__init__.py
ADDED
|
File without changes
|
backend/app/agent/orchestrator.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The orchestrator agent — a tool-calling loop.
|
| 2 |
+
|
| 3 |
+
Implements the agentic pattern: the LLM decides which tools to call, we run
|
| 4 |
+
them, feed results back, and iterate until the model produces a final answer.
|
| 5 |
+
Then we stream that final answer to the client token-by-token.
|
| 6 |
+
|
| 7 |
+
Keeping a single orchestrator with well-scoped tools (rather than a
|
| 8 |
+
multi-agent swarm) matches the research recommendation: simplest design that
|
| 9 |
+
covers the 7 functions, with room to grow.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from typing import AsyncIterator
|
| 14 |
+
|
| 15 |
+
from app.agent.prompts import ORCHESTRATOR_SYSTEM
|
| 16 |
+
from app.agent.tools import TOOL_FNS, TOOL_SPECS, ToolContext
|
| 17 |
+
from app.core.logging import get_logger
|
| 18 |
+
from app.llm.base import LLMMessage, ToolCall
|
| 19 |
+
from app.llm.registry import get_provider
|
| 20 |
+
from app.schemas.documents import ChatMessage, Citation
|
| 21 |
+
|
| 22 |
+
log = get_logger(__name__)
|
| 23 |
+
|
| 24 |
+
MAX_TOOL_ROUNDS = 4
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _build_messages(history: list[ChatMessage], doc_context: str) -> list[LLMMessage]:
|
| 28 |
+
msgs = [LLMMessage(role="system", content=ORCHESTRATOR_SYSTEM)]
|
| 29 |
+
if doc_context:
|
| 30 |
+
msgs.append(LLMMessage(role="system", content=doc_context))
|
| 31 |
+
for m in history:
|
| 32 |
+
msgs.append(LLMMessage(role=m.role.value, content=m.content))
|
| 33 |
+
return msgs
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
async def _run_tool_rounds(
|
| 37 |
+
llm, msgs: list[LLMMessage], ctx: ToolContext
|
| 38 |
+
) -> list[LLMMessage]:
|
| 39 |
+
"""Let the model call tools until it's ready to answer (bounded)."""
|
| 40 |
+
for _ in range(MAX_TOOL_ROUNDS):
|
| 41 |
+
resp = await llm.complete_tools(msgs, TOOL_SPECS)
|
| 42 |
+
if not resp.tool_calls:
|
| 43 |
+
# model is ready to answer in prose; drop back to streaming
|
| 44 |
+
return msgs
|
| 45 |
+
# record the assistant's tool-call turn
|
| 46 |
+
msgs.append(LLMMessage(role="assistant", content=resp.text,
|
| 47 |
+
tool_calls=resp.tool_calls))
|
| 48 |
+
for call in resp.tool_calls:
|
| 49 |
+
fn = TOOL_FNS.get(call.name)
|
| 50 |
+
if fn is None:
|
| 51 |
+
result = f"Unknown tool: {call.name}"
|
| 52 |
+
else:
|
| 53 |
+
try:
|
| 54 |
+
result = await fn(ctx, call.arguments)
|
| 55 |
+
except Exception as e: # pragma: no cover
|
| 56 |
+
log.exception("tool %s failed", call.name)
|
| 57 |
+
result = f"Tool error: {e}"
|
| 58 |
+
msgs.append(LLMMessage(role="tool", content=result,
|
| 59 |
+
tool_call_id=call.id, name=call.name))
|
| 60 |
+
return msgs
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
async def run_chat(
|
| 64 |
+
history: list[ChatMessage],
|
| 65 |
+
document_id: str | None,
|
| 66 |
+
doc_context: str = "",
|
| 67 |
+
provider: str | None = None,
|
| 68 |
+
) -> tuple[AsyncIterator[str], ToolContext]:
|
| 69 |
+
"""Run the agent and return a streaming token iterator + tool context.
|
| 70 |
+
|
| 71 |
+
The caller streams the tokens to the client and, when done, reads
|
| 72 |
+
ctx.citations to emit grounding metadata.
|
| 73 |
+
"""
|
| 74 |
+
llm = get_provider(provider)
|
| 75 |
+
ctx = ToolContext(document_id=document_id, provider=provider)
|
| 76 |
+
msgs = _build_messages(history, doc_context)
|
| 77 |
+
|
| 78 |
+
# Phase 1: tool-calling (non-streamed reasoning)
|
| 79 |
+
msgs = await _run_tool_rounds(llm, msgs, ctx)
|
| 80 |
+
|
| 81 |
+
# Phase 2: final answer, streamed
|
| 82 |
+
msgs.append(LLMMessage(
|
| 83 |
+
role="system",
|
| 84 |
+
content="Now write the final answer for the user based on the tool "
|
| 85 |
+
"results above. Do not call more tools.",
|
| 86 |
+
))
|
| 87 |
+
|
| 88 |
+
async def stream() -> AsyncIterator[str]:
|
| 89 |
+
async for token in llm.stream(msgs, temperature=0.4):
|
| 90 |
+
yield token
|
| 91 |
+
|
| 92 |
+
return stream(), ctx
|
backend/app/agent/prompts.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""System prompts for the orchestrator agent."""
|
| 2 |
+
|
| 3 |
+
ORCHESTRATOR_SYSTEM = """You are DocAgent, a meticulous document-processing assistant for Monkhub Innovations.
|
| 4 |
+
|
| 5 |
+
You help users work with an uploaded document. You can:
|
| 6 |
+
- answer questions about its contents (always ground answers in the document),
|
| 7 |
+
- classify the document type,
|
| 8 |
+
- extract structured fields, tables, and entities,
|
| 9 |
+
- summarize the document,
|
| 10 |
+
- flag missing fields, inconsistencies, or anomalies,
|
| 11 |
+
- explain extracted data and guide the user to export it.
|
| 12 |
+
|
| 13 |
+
Guidelines:
|
| 14 |
+
- When the user asks something that needs the document's content, call the
|
| 15 |
+
`query_document` tool to retrieve grounded passages before answering.
|
| 16 |
+
- Use `get_extracted_data` to discuss already-extracted fields rather than guessing.
|
| 17 |
+
- Prefer tools over assumptions. If a tool returns citations, reference them.
|
| 18 |
+
- Be concise, professional, and helpful. Use Markdown. When you state a fact
|
| 19 |
+
from the document, make clear it is grounded in the source.
|
| 20 |
+
- If no document is loaded yet, ask the user to upload one.
|
| 21 |
+
"""
|
backend/app/agent/tools.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent tools — the capabilities the orchestrator can call.
|
| 2 |
+
|
| 3 |
+
Each tool is a thin async wrapper over a service, plus a `ToolSpec`
|
| 4 |
+
(JSON-schema description) the LLM sees. Tools are document-scoped: the
|
| 5 |
+
orchestrator passes the active document id in via a closure context.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from typing import Any, Awaitable, Callable
|
| 12 |
+
|
| 13 |
+
from app.core.logging import get_logger
|
| 14 |
+
from app.llm.base import ToolSpec
|
| 15 |
+
from app.services import classification, extraction, qa, storage, summary
|
| 16 |
+
from app.services import anomaly as anomaly_svc
|
| 17 |
+
from app.services import vectorstore
|
| 18 |
+
|
| 19 |
+
log = get_logger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class ToolContext:
|
| 24 |
+
document_id: str | None
|
| 25 |
+
provider: str | None = None
|
| 26 |
+
# collected so the API can surface citations from the last query
|
| 27 |
+
citations: list = None # type: ignore
|
| 28 |
+
|
| 29 |
+
def __post_init__(self):
|
| 30 |
+
if self.citations is None:
|
| 31 |
+
self.citations = []
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
ToolFn = Callable[[ToolContext, dict[str, Any]], Awaitable[str]]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# -------------------------- tool implementations --------------------------
|
| 38 |
+
async def _query_document(ctx: ToolContext, args: dict) -> str:
|
| 39 |
+
if not ctx.document_id:
|
| 40 |
+
return "No document is currently loaded."
|
| 41 |
+
question = args.get("question", "")
|
| 42 |
+
res = await qa.answer(ctx.document_id, question, provider=ctx.provider)
|
| 43 |
+
ctx.citations.extend(res.citations)
|
| 44 |
+
cites = "\n".join(
|
| 45 |
+
f"[{i+1}] (page {c.page+1}) {c.text[:160]}" for i, c in enumerate(res.citations)
|
| 46 |
+
)
|
| 47 |
+
return f"{res.answer}\n\nGrounding passages:\n{cites}"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
async def _classify(ctx: ToolContext, args: dict) -> str:
|
| 51 |
+
detail = storage.get(ctx.document_id) if ctx.document_id else None
|
| 52 |
+
if not detail:
|
| 53 |
+
return "No document loaded."
|
| 54 |
+
if detail.classification:
|
| 55 |
+
c = detail.classification
|
| 56 |
+
return f"Document type: {c.doc_type} (confidence {c.confidence:.0%}). {c.rationale}"
|
| 57 |
+
c = await classification.classify(detail.markdown or "", ctx.provider)
|
| 58 |
+
detail.classification = c
|
| 59 |
+
storage.save(detail)
|
| 60 |
+
return f"Document type: {c.doc_type} (confidence {c.confidence:.0%}). {c.rationale}"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
async def _get_extracted_data(ctx: ToolContext, args: dict) -> str:
|
| 64 |
+
detail = storage.get(ctx.document_id) if ctx.document_id else None
|
| 65 |
+
if not detail or not detail.extraction:
|
| 66 |
+
return "No extracted data yet."
|
| 67 |
+
fields = {f.name: f.value for f in detail.extraction.fields}
|
| 68 |
+
return json.dumps({
|
| 69 |
+
"schema": detail.extraction.schema_name,
|
| 70 |
+
"fields": fields,
|
| 71 |
+
"tables": [t.title or "table" for t in detail.extraction.tables],
|
| 72 |
+
"entities": [{"type": e.type, "value": e.value} for e in detail.extraction.entities],
|
| 73 |
+
}, default=str)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
async def _summarize(ctx: ToolContext, args: dict) -> str:
|
| 77 |
+
detail = storage.get(ctx.document_id) if ctx.document_id else None
|
| 78 |
+
if not detail:
|
| 79 |
+
return "No document loaded."
|
| 80 |
+
if detail.summary:
|
| 81 |
+
return detail.summary
|
| 82 |
+
s = await summary.summarize(detail.markdown or "", ctx.provider)
|
| 83 |
+
detail.summary = s
|
| 84 |
+
storage.save(detail)
|
| 85 |
+
return s
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
async def _flag_anomalies(ctx: ToolContext, args: dict) -> str:
|
| 89 |
+
detail = storage.get(ctx.document_id) if ctx.document_id else None
|
| 90 |
+
if not detail or not detail.extraction:
|
| 91 |
+
return "No extracted data to check."
|
| 92 |
+
doc_type = detail.classification.doc_type if detail.classification else None
|
| 93 |
+
items = await anomaly_svc.detect(detail.markdown or "", detail.extraction,
|
| 94 |
+
doc_type, ctx.provider)
|
| 95 |
+
if not items:
|
| 96 |
+
return "No anomalies detected. The document looks consistent."
|
| 97 |
+
return "\n".join(f"- [{a.severity.value}] {a.field or ''}: {a.message}" for a in items)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# ----------------------------- registry -----------------------------------
|
| 101 |
+
TOOL_SPECS: list[ToolSpec] = [
|
| 102 |
+
ToolSpec(
|
| 103 |
+
name="query_document",
|
| 104 |
+
description="Retrieve grounded passages from the document and answer a question about its contents.",
|
| 105 |
+
parameters={
|
| 106 |
+
"type": "object",
|
| 107 |
+
"properties": {"question": {"type": "string", "description": "The question to answer"}},
|
| 108 |
+
"required": ["question"],
|
| 109 |
+
},
|
| 110 |
+
),
|
| 111 |
+
ToolSpec(
|
| 112 |
+
name="classify_document",
|
| 113 |
+
description="Get the document type/category with confidence.",
|
| 114 |
+
parameters={"type": "object", "properties": {}},
|
| 115 |
+
),
|
| 116 |
+
ToolSpec(
|
| 117 |
+
name="get_extracted_data",
|
| 118 |
+
description="Return the structured fields, tables, and entities already extracted from the document.",
|
| 119 |
+
parameters={"type": "object", "properties": {}},
|
| 120 |
+
),
|
| 121 |
+
ToolSpec(
|
| 122 |
+
name="summarize_document",
|
| 123 |
+
description="Produce or fetch a concise summary of the document.",
|
| 124 |
+
parameters={"type": "object", "properties": {}},
|
| 125 |
+
),
|
| 126 |
+
ToolSpec(
|
| 127 |
+
name="flag_anomalies",
|
| 128 |
+
description="Check for missing required fields, low-confidence values, and inconsistencies.",
|
| 129 |
+
parameters={"type": "object", "properties": {}},
|
| 130 |
+
),
|
| 131 |
+
]
|
| 132 |
+
|
| 133 |
+
TOOL_FNS: dict[str, ToolFn] = {
|
| 134 |
+
"query_document": _query_document,
|
| 135 |
+
"classify_document": _classify,
|
| 136 |
+
"get_extracted_data": _get_extracted_data,
|
| 137 |
+
"summarize_document": _summarize,
|
| 138 |
+
"flag_anomalies": _flag_anomalies,
|
| 139 |
+
}
|
backend/app/api/__init__.py
ADDED
|
File without changes
|
backend/app/api/actions.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""On-demand actions and export endpoints.
|
| 2 |
+
|
| 3 |
+
These let the frontend trigger individual functions (re-extract with a chosen
|
| 4 |
+
schema, re-summarize) and download results — independent of the chat agent.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 9 |
+
from fastapi.responses import Response
|
| 10 |
+
|
| 11 |
+
from app.core.logging import get_logger
|
| 12 |
+
from app.schemas.documents import Classification, ExtractionResult
|
| 13 |
+
from app.schemas.templates import all_template_names
|
| 14 |
+
from app.services import (
|
| 15 |
+
anomaly,
|
| 16 |
+
classification,
|
| 17 |
+
export,
|
| 18 |
+
extraction,
|
| 19 |
+
storage,
|
| 20 |
+
summary,
|
| 21 |
+
vectorstore,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
log = get_logger(__name__)
|
| 25 |
+
router = APIRouter(prefix="/api/documents", tags=["actions"])
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.post("/{doc_id}/extract", response_model=ExtractionResult)
|
| 29 |
+
async def re_extract(doc_id: str, schema: str | None = Query(None),
|
| 30 |
+
provider: str | None = Query(None)):
|
| 31 |
+
detail = storage.get(doc_id)
|
| 32 |
+
if not detail:
|
| 33 |
+
raise HTTPException(404, "Document not found")
|
| 34 |
+
chunks = await vectorstore.query(doc_id, "key fields", k=40)
|
| 35 |
+
doc_type = detail.classification.doc_type if detail.classification else None
|
| 36 |
+
# `schema` is an OPTIONAL user-chosen framing; "auto" (or unset) keeps the
|
| 37 |
+
# extractor fully adaptive.
|
| 38 |
+
hint = schema if (schema and schema.lower() != "auto") else None
|
| 39 |
+
result = await extraction.extract(detail.markdown or "", doc_type, chunks, provider,
|
| 40 |
+
schema_hint=hint)
|
| 41 |
+
detail.extraction = result
|
| 42 |
+
detail.anomalies = await anomaly.detect(detail.markdown or "", result, doc_type, provider)
|
| 43 |
+
storage.save(detail)
|
| 44 |
+
return result
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@router.post("/{doc_id}/classify", response_model=Classification)
|
| 48 |
+
async def re_classify(doc_id: str, provider: str | None = Query(None)):
|
| 49 |
+
detail = storage.get(doc_id)
|
| 50 |
+
if not detail:
|
| 51 |
+
raise HTTPException(404, "Document not found")
|
| 52 |
+
c = await classification.classify(detail.markdown or "", provider)
|
| 53 |
+
detail.classification = c
|
| 54 |
+
storage.save(detail)
|
| 55 |
+
return c
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@router.post("/{doc_id}/summarize")
|
| 59 |
+
async def re_summarize(doc_id: str, provider: str | None = Query(None)):
|
| 60 |
+
detail = storage.get(doc_id)
|
| 61 |
+
if not detail:
|
| 62 |
+
raise HTTPException(404, "Document not found")
|
| 63 |
+
s = await summary.summarize(detail.markdown or "", provider)
|
| 64 |
+
detail.summary = s
|
| 65 |
+
storage.save(detail)
|
| 66 |
+
return {"summary": s}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@router.get("/{doc_id}/export")
|
| 70 |
+
async def export_document(doc_id: str, format: str = Query("json")):
|
| 71 |
+
detail = storage.get(doc_id)
|
| 72 |
+
if not detail:
|
| 73 |
+
raise HTTPException(404, "Document not found")
|
| 74 |
+
fmt = format.lower()
|
| 75 |
+
if fmt not in export.EXPORTERS:
|
| 76 |
+
raise HTTPException(400, f"Unsupported format. Use one of {list(export.EXPORTERS)}")
|
| 77 |
+
fn, media, ext = export.EXPORTERS[fmt]
|
| 78 |
+
data = fn(detail)
|
| 79 |
+
safe = (detail.filename or doc_id).rsplit(".", 1)[0]
|
| 80 |
+
return Response(
|
| 81 |
+
content=data, media_type=media,
|
| 82 |
+
headers={"Content-Disposition": f'attachment; filename="{safe}.{ext}"'},
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@router.get("/-/schemas")
|
| 87 |
+
async def list_schemas():
|
| 88 |
+
return {"schemas": all_template_names()}
|
backend/app/api/chat.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chat endpoint with Server-Sent Events streaming.
|
| 2 |
+
|
| 3 |
+
Streams the agent's answer token-by-token, then a final `citations` event so
|
| 4 |
+
the frontend can highlight grounded passages on the document.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, HTTPException
|
| 11 |
+
from fastapi.responses import StreamingResponse
|
| 12 |
+
|
| 13 |
+
from app.agent.orchestrator import run_chat
|
| 14 |
+
from app.core.logging import get_logger
|
| 15 |
+
from app.schemas.documents import ChatRequest
|
| 16 |
+
from app.services import storage
|
| 17 |
+
|
| 18 |
+
log = get_logger(__name__)
|
| 19 |
+
router = APIRouter(prefix="/api/chat", tags=["chat"])
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _sse(event: str, data) -> str:
|
| 23 |
+
return f"event: {event}\ndata: {json.dumps(data, default=str)}\n\n"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _doc_context(document_id: str | None) -> str:
|
| 27 |
+
if not document_id:
|
| 28 |
+
return ""
|
| 29 |
+
d = storage.get(document_id)
|
| 30 |
+
if not d:
|
| 31 |
+
return ""
|
| 32 |
+
parts = [f"Active document: '{d.filename}' (id={d.id}), status={d.status.value}, "
|
| 33 |
+
f"{d.num_pages} pages."]
|
| 34 |
+
if d.classification:
|
| 35 |
+
parts.append(f"Detected type: {d.classification.doc_type} "
|
| 36 |
+
f"({d.classification.confidence:.0%}).")
|
| 37 |
+
if d.extraction and d.extraction.fields:
|
| 38 |
+
kv = ", ".join(f"{f.name}={f.value}" for f in d.extraction.fields[:12])
|
| 39 |
+
parts.append(f"Already-extracted fields: {kv}")
|
| 40 |
+
return " ".join(parts)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@router.post("")
|
| 44 |
+
async def chat(req: ChatRequest):
|
| 45 |
+
if not req.messages:
|
| 46 |
+
raise HTTPException(400, "messages cannot be empty")
|
| 47 |
+
|
| 48 |
+
async def gen():
|
| 49 |
+
try:
|
| 50 |
+
stream, ctx = await run_chat(
|
| 51 |
+
history=req.messages,
|
| 52 |
+
document_id=req.document_id,
|
| 53 |
+
doc_context=_doc_context(req.document_id),
|
| 54 |
+
provider=req.provider,
|
| 55 |
+
)
|
| 56 |
+
yield _sse("start", {"ok": True})
|
| 57 |
+
async for token in stream:
|
| 58 |
+
yield _sse("token", {"text": token})
|
| 59 |
+
# de-dup citations by chunk_id
|
| 60 |
+
seen, cites = set(), []
|
| 61 |
+
for c in ctx.citations:
|
| 62 |
+
key = c.chunk_id or c.text[:40]
|
| 63 |
+
if key in seen:
|
| 64 |
+
continue
|
| 65 |
+
seen.add(key)
|
| 66 |
+
cites.append(c.model_dump())
|
| 67 |
+
yield _sse("citations", {"citations": cites})
|
| 68 |
+
yield _sse("done", {"ok": True})
|
| 69 |
+
except Exception as e: # pragma: no cover
|
| 70 |
+
log.exception("chat failed")
|
| 71 |
+
yield _sse("error", {"message": str(e)})
|
| 72 |
+
|
| 73 |
+
return StreamingResponse(gen(), media_type="text/event-stream",
|
| 74 |
+
headers={"Cache-Control": "no-cache",
|
| 75 |
+
"X-Accel-Buffering": "no"})
|
backend/app/api/documents.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Document endpoints: upload, list, detail, page images, re-process, delete."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import uuid
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
|
| 7 |
+
from fastapi import APIRouter, BackgroundTasks, File, HTTPException, UploadFile
|
| 8 |
+
from fastapi.responses import FileResponse
|
| 9 |
+
|
| 10 |
+
from app.core.config import settings
|
| 11 |
+
from app.core.logging import get_logger
|
| 12 |
+
from app.schemas.documents import DocStatus, DocumentDetail, DocumentMeta
|
| 13 |
+
from app.services import storage, vectorstore
|
| 14 |
+
from app.services.pipeline import process_document
|
| 15 |
+
|
| 16 |
+
log = get_logger(__name__)
|
| 17 |
+
router = APIRouter(prefix="/api/documents", tags=["documents"])
|
| 18 |
+
|
| 19 |
+
ALLOWED = {
|
| 20 |
+
".pdf", ".docx", ".doc", ".pptx", ".xlsx", ".html", ".md", ".txt",
|
| 21 |
+
".png", ".jpg", ".jpeg", ".tiff", ".bmp",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.post("", response_model=DocumentMeta)
|
| 26 |
+
async def upload(background: BackgroundTasks, file: UploadFile = File(...)):
|
| 27 |
+
ext = "." + (file.filename or "").rsplit(".", 1)[-1].lower() if "." in (file.filename or "") else ""
|
| 28 |
+
if ext not in ALLOWED:
|
| 29 |
+
raise HTTPException(400, f"Unsupported file type '{ext}'. Allowed: {sorted(ALLOWED)}")
|
| 30 |
+
|
| 31 |
+
doc_id = uuid.uuid4().hex[:12]
|
| 32 |
+
dest = settings.uploads_path / f"{doc_id}{ext}"
|
| 33 |
+
data = await file.read()
|
| 34 |
+
dest.write_bytes(data)
|
| 35 |
+
|
| 36 |
+
detail = DocumentDetail(
|
| 37 |
+
id=doc_id,
|
| 38 |
+
filename=file.filename or dest.name,
|
| 39 |
+
content_type=file.content_type or "application/octet-stream",
|
| 40 |
+
size_bytes=len(data),
|
| 41 |
+
status=DocStatus.uploaded,
|
| 42 |
+
created_at=datetime.now(timezone.utc).isoformat(),
|
| 43 |
+
)
|
| 44 |
+
storage.save(detail)
|
| 45 |
+
|
| 46 |
+
# kick off async processing
|
| 47 |
+
background.add_task(process_document, doc_id, dest)
|
| 48 |
+
log.info("Uploaded %s (%d bytes) -> %s", file.filename, len(data), doc_id)
|
| 49 |
+
return DocumentMeta.model_validate(detail.model_dump())
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@router.get("", response_model=list[DocumentMeta])
|
| 53 |
+
async def list_documents():
|
| 54 |
+
return storage.list_all()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@router.get("/{doc_id}", response_model=DocumentDetail)
|
| 58 |
+
async def get_document(doc_id: str):
|
| 59 |
+
detail = storage.get(doc_id)
|
| 60 |
+
if not detail:
|
| 61 |
+
raise HTTPException(404, "Document not found")
|
| 62 |
+
return detail
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.get("/{doc_id}/pages/{page}.png")
|
| 66 |
+
async def get_page_image(doc_id: str, page: int):
|
| 67 |
+
path = settings.renders_path / doc_id / f"page-{page}.png"
|
| 68 |
+
if not path.exists():
|
| 69 |
+
raise HTTPException(404, "Page image not found")
|
| 70 |
+
return FileResponse(path, media_type="image/png")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@router.delete("/{doc_id}")
|
| 74 |
+
async def delete_document(doc_id: str):
|
| 75 |
+
if not storage.get(doc_id):
|
| 76 |
+
raise HTTPException(404, "Document not found")
|
| 77 |
+
vectorstore.delete(doc_id)
|
| 78 |
+
storage.delete(doc_id)
|
| 79 |
+
return {"ok": True}
|
backend/app/api/meta.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Health and metadata endpoints."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from fastapi import APIRouter
|
| 5 |
+
|
| 6 |
+
from app.core.config import settings
|
| 7 |
+
from app.llm.registry import available_providers
|
| 8 |
+
|
| 9 |
+
router = APIRouter(prefix="/api", tags=["meta"])
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.get("/health")
|
| 13 |
+
async def health():
|
| 14 |
+
providers = available_providers()
|
| 15 |
+
return {
|
| 16 |
+
"status": "ok",
|
| 17 |
+
"providers": providers,
|
| 18 |
+
"default_provider": settings.default_llm_provider,
|
| 19 |
+
"llm_configured": bool(providers),
|
| 20 |
+
"ocr_enabled": settings.enable_ocr,
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.get("/config")
|
| 25 |
+
async def config():
|
| 26 |
+
return {
|
| 27 |
+
"providers": available_providers(),
|
| 28 |
+
"default_provider": settings.default_llm_provider,
|
| 29 |
+
"models": {"gemini": settings.gemini_model, "openai": settings.openai_model},
|
| 30 |
+
}
|
backend/app/core/__init__.py
ADDED
|
File without changes
|
backend/app/core/config.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Application configuration, loaded from environment / .env.
|
| 2 |
+
|
| 3 |
+
Single source of truth for runtime settings. Routes and services read
|
| 4 |
+
settings from here only — never from os.environ directly — so behaviour
|
| 5 |
+
is consistent and testable.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from functools import lru_cache
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from pydantic import field_validator
|
| 13 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Settings(BaseSettings):
|
| 17 |
+
model_config = SettingsConfigDict(
|
| 18 |
+
env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
# --- LLM providers ---
|
| 22 |
+
gemini_api_key: str = ""
|
| 23 |
+
openai_api_key: str = ""
|
| 24 |
+
default_llm_provider: str = "gemini" # "gemini" | "openai"
|
| 25 |
+
gemini_model: str = "gemini-2.0-flash"
|
| 26 |
+
openai_model: str = "gpt-4o-mini"
|
| 27 |
+
gemini_embed_model: str = "text-embedding-004"
|
| 28 |
+
|
| 29 |
+
# --- Storage ---
|
| 30 |
+
data_dir: str = "./data"
|
| 31 |
+
chroma_dir: str = "./data/chroma"
|
| 32 |
+
|
| 33 |
+
# --- Server ---
|
| 34 |
+
host: str = "0.0.0.0"
|
| 35 |
+
port: int = 8000
|
| 36 |
+
cors_origins: str = "http://localhost:3000"
|
| 37 |
+
|
| 38 |
+
# --- Extraction ---
|
| 39 |
+
max_render_pages: int = 30
|
| 40 |
+
enable_ocr: bool = True
|
| 41 |
+
|
| 42 |
+
# Secrets pasted into host dashboards (Render/HF/Vercel) very often arrive
|
| 43 |
+
# with a trailing newline or stray whitespace. An API key with a "\n" makes
|
| 44 |
+
# the "Authorization: Bearer <key>\n" header illegal, which httpx rejects
|
| 45 |
+
# locally and the OpenAI SDK re-wraps as a misleading APIConnectionError.
|
| 46 |
+
# Strip them so a sloppy copy-paste can never break auth.
|
| 47 |
+
@field_validator(
|
| 48 |
+
"gemini_api_key", "openai_api_key", "default_llm_provider",
|
| 49 |
+
"gemini_model", "openai_model", "gemini_embed_model",
|
| 50 |
+
mode="after",
|
| 51 |
+
)
|
| 52 |
+
@classmethod
|
| 53 |
+
def _strip(cls, v: str) -> str:
|
| 54 |
+
return v.strip() if isinstance(v, str) else v
|
| 55 |
+
|
| 56 |
+
# ----- derived helpers -----
|
| 57 |
+
@property
|
| 58 |
+
def cors_origin_list(self) -> list[str]:
|
| 59 |
+
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
| 60 |
+
|
| 61 |
+
@property
|
| 62 |
+
def data_path(self) -> Path:
|
| 63 |
+
p = Path(self.data_dir)
|
| 64 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 65 |
+
return p
|
| 66 |
+
|
| 67 |
+
@property
|
| 68 |
+
def uploads_path(self) -> Path:
|
| 69 |
+
p = self.data_path / "uploads"
|
| 70 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 71 |
+
return p
|
| 72 |
+
|
| 73 |
+
@property
|
| 74 |
+
def renders_path(self) -> Path:
|
| 75 |
+
p = self.data_path / "renders"
|
| 76 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 77 |
+
return p
|
| 78 |
+
|
| 79 |
+
@property
|
| 80 |
+
def db_path(self) -> Path:
|
| 81 |
+
return self.data_path / "app.db"
|
| 82 |
+
|
| 83 |
+
@property
|
| 84 |
+
def has_gemini(self) -> bool:
|
| 85 |
+
return bool(self.gemini_api_key)
|
| 86 |
+
|
| 87 |
+
@property
|
| 88 |
+
def has_openai(self) -> bool:
|
| 89 |
+
return bool(self.openai_api_key)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@lru_cache
|
| 93 |
+
def get_settings() -> Settings:
|
| 94 |
+
return Settings()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
settings = get_settings()
|
backend/app/core/logging.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal structured logging setup."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import logging
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
_CONFIGURED = False
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def setup_logging(level: int = logging.INFO) -> None:
|
| 11 |
+
global _CONFIGURED
|
| 12 |
+
if _CONFIGURED:
|
| 13 |
+
return
|
| 14 |
+
handler = logging.StreamHandler(sys.stdout)
|
| 15 |
+
handler.setFormatter(
|
| 16 |
+
logging.Formatter("%(asctime)s | %(levelname)-7s | %(name)s | %(message)s")
|
| 17 |
+
)
|
| 18 |
+
root = logging.getLogger()
|
| 19 |
+
root.setLevel(level)
|
| 20 |
+
root.addHandler(handler)
|
| 21 |
+
_CONFIGURED = True
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def get_logger(name: str) -> logging.Logger:
|
| 25 |
+
setup_logging()
|
| 26 |
+
return logging.getLogger(name)
|
backend/app/llm/__init__.py
ADDED
|
File without changes
|
backend/app/llm/base.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM provider abstraction.
|
| 2 |
+
|
| 3 |
+
All model access in the app goes through the `LLMProvider` interface so that
|
| 4 |
+
the rest of the codebase never imports a vendor SDK directly. This is what
|
| 5 |
+
makes the default(Gemini)/fallback(OpenAI) switch a one-line change and keeps
|
| 6 |
+
the agent provider-agnostic.
|
| 7 |
+
|
| 8 |
+
A provider must support three capabilities the agent relies on:
|
| 9 |
+
* complete() -> plain text (optionally streaming)
|
| 10 |
+
* complete_json() -> JSON object validated against an expected shape
|
| 11 |
+
* complete_tools() -> tool/function calling for the agent loop
|
| 12 |
+
* embed() -> vector embeddings for RAG
|
| 13 |
+
* vision() -> read an image (scanned-page fallback)
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import abc
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
from typing import Any, AsyncIterator, Iterable, Optional
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class ToolSpec:
|
| 24 |
+
"""Vendor-neutral description of a callable tool exposed to the model."""
|
| 25 |
+
|
| 26 |
+
name: str
|
| 27 |
+
description: str
|
| 28 |
+
parameters: dict[str, Any] # JSON Schema for the arguments
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class ToolCall:
|
| 33 |
+
id: str
|
| 34 |
+
name: str
|
| 35 |
+
arguments: dict[str, Any]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class LLMMessage:
|
| 40 |
+
role: str # system | user | assistant | tool
|
| 41 |
+
content: str = ""
|
| 42 |
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
| 43 |
+
tool_call_id: Optional[str] = None # for role == "tool"
|
| 44 |
+
name: Optional[str] = None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class LLMResponse:
|
| 49 |
+
text: str = ""
|
| 50 |
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
| 51 |
+
finish_reason: str = "stop"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
class LLMError(RuntimeError):
|
| 55 |
+
pass
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class LLMProvider(abc.ABC):
|
| 59 |
+
"""Interface every concrete provider implements."""
|
| 60 |
+
|
| 61 |
+
name: str = "base"
|
| 62 |
+
model: str = ""
|
| 63 |
+
|
| 64 |
+
@abc.abstractmethod
|
| 65 |
+
async def complete(
|
| 66 |
+
self,
|
| 67 |
+
messages: list[LLMMessage],
|
| 68 |
+
*,
|
| 69 |
+
temperature: float = 0.2,
|
| 70 |
+
max_tokens: Optional[int] = None,
|
| 71 |
+
) -> str:
|
| 72 |
+
...
|
| 73 |
+
|
| 74 |
+
@abc.abstractmethod
|
| 75 |
+
async def stream(
|
| 76 |
+
self,
|
| 77 |
+
messages: list[LLMMessage],
|
| 78 |
+
*,
|
| 79 |
+
temperature: float = 0.4,
|
| 80 |
+
) -> AsyncIterator[str]:
|
| 81 |
+
...
|
| 82 |
+
yield # pragma: no cover
|
| 83 |
+
|
| 84 |
+
@abc.abstractmethod
|
| 85 |
+
async def complete_json(
|
| 86 |
+
self,
|
| 87 |
+
messages: list[LLMMessage],
|
| 88 |
+
*,
|
| 89 |
+
temperature: float = 0.0,
|
| 90 |
+
) -> Any:
|
| 91 |
+
"""Return parsed JSON. Providers should request JSON output mode."""
|
| 92 |
+
...
|
| 93 |
+
|
| 94 |
+
@abc.abstractmethod
|
| 95 |
+
async def complete_tools(
|
| 96 |
+
self,
|
| 97 |
+
messages: list[LLMMessage],
|
| 98 |
+
tools: list[ToolSpec],
|
| 99 |
+
*,
|
| 100 |
+
temperature: float = 0.2,
|
| 101 |
+
) -> LLMResponse:
|
| 102 |
+
...
|
| 103 |
+
|
| 104 |
+
@abc.abstractmethod
|
| 105 |
+
async def embed(self, texts: Iterable[str]) -> list[list[float]]:
|
| 106 |
+
...
|
| 107 |
+
|
| 108 |
+
async def vision(self, prompt: str, image_paths: list[str]) -> str:
|
| 109 |
+
"""Optional capability; override in providers that support images."""
|
| 110 |
+
raise LLMError(f"{self.name} does not implement vision()")
|
backend/app/llm/gemini_provider.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Google Gemini provider (default, free tier).
|
| 2 |
+
|
| 3 |
+
Uses google-generativeai. Chosen as default because of the generous free
|
| 4 |
+
tier, ~1M token context (great for long documents), and native multimodal
|
| 5 |
+
vision (lets us read scanned pages directly).
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
from typing import Any, AsyncIterator, Iterable, Optional
|
| 11 |
+
|
| 12 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 13 |
+
|
| 14 |
+
from app.core.logging import get_logger
|
| 15 |
+
from app.llm.base import (
|
| 16 |
+
LLMError,
|
| 17 |
+
LLMMessage,
|
| 18 |
+
LLMProvider,
|
| 19 |
+
LLMResponse,
|
| 20 |
+
ToolCall,
|
| 21 |
+
ToolSpec,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
log = get_logger(__name__)
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
import google.generativeai as genai
|
| 28 |
+
except Exception: # pragma: no cover - import guarded for environments w/o dep
|
| 29 |
+
genai = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _to_gemini_history(messages: list[LLMMessage]) -> tuple[Optional[str], list[dict]]:
|
| 33 |
+
"""Convert neutral messages to Gemini's (system_instruction, contents)."""
|
| 34 |
+
system = None
|
| 35 |
+
contents: list[dict] = []
|
| 36 |
+
for m in messages:
|
| 37 |
+
if m.role == "system":
|
| 38 |
+
system = (system + "\n" + m.content) if system else m.content
|
| 39 |
+
continue
|
| 40 |
+
role = "user" if m.role in ("user", "tool") else "model"
|
| 41 |
+
text = m.content
|
| 42 |
+
if m.role == "tool":
|
| 43 |
+
text = f"[tool result for {m.name}]\n{m.content}"
|
| 44 |
+
contents.append({"role": role, "parts": [text]})
|
| 45 |
+
return system, contents
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class GeminiProvider(LLMProvider):
|
| 49 |
+
name = "gemini"
|
| 50 |
+
|
| 51 |
+
def __init__(self, api_key: str, model: str, embed_model: str):
|
| 52 |
+
if genai is None:
|
| 53 |
+
raise LLMError("google-generativeai is not installed")
|
| 54 |
+
if not api_key:
|
| 55 |
+
raise LLMError("GEMINI_API_KEY is not set")
|
| 56 |
+
genai.configure(api_key=api_key)
|
| 57 |
+
self.model = model
|
| 58 |
+
self.embed_model = embed_model
|
| 59 |
+
self._api_key = api_key
|
| 60 |
+
|
| 61 |
+
def _model(self, system: Optional[str] = None, json_mode: bool = False,
|
| 62 |
+
tools: Optional[list] = None):
|
| 63 |
+
cfg: dict[str, Any] = {}
|
| 64 |
+
if json_mode:
|
| 65 |
+
cfg["response_mime_type"] = "application/json"
|
| 66 |
+
return genai.GenerativeModel(
|
| 67 |
+
self.model,
|
| 68 |
+
system_instruction=system,
|
| 69 |
+
generation_config=cfg or None,
|
| 70 |
+
tools=tools,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8))
|
| 74 |
+
async def complete(self, messages, *, temperature=0.2, max_tokens=None) -> str:
|
| 75 |
+
system, contents = _to_gemini_history(messages)
|
| 76 |
+
model = self._model(system)
|
| 77 |
+
resp = await model.generate_content_async(
|
| 78 |
+
contents,
|
| 79 |
+
generation_config={"temperature": temperature,
|
| 80 |
+
**({"max_output_tokens": max_tokens} if max_tokens else {})},
|
| 81 |
+
)
|
| 82 |
+
return (resp.text or "").strip()
|
| 83 |
+
|
| 84 |
+
async def stream(self, messages, *, temperature=0.4) -> AsyncIterator[str]:
|
| 85 |
+
system, contents = _to_gemini_history(messages)
|
| 86 |
+
model = self._model(system)
|
| 87 |
+
resp = await model.generate_content_async(
|
| 88 |
+
contents, generation_config={"temperature": temperature}, stream=True
|
| 89 |
+
)
|
| 90 |
+
async for chunk in resp:
|
| 91 |
+
if getattr(chunk, "text", None):
|
| 92 |
+
yield chunk.text
|
| 93 |
+
|
| 94 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8))
|
| 95 |
+
async def complete_json(self, messages, *, temperature=0.0) -> Any:
|
| 96 |
+
system, contents = _to_gemini_history(messages)
|
| 97 |
+
model = self._model(system, json_mode=True)
|
| 98 |
+
resp = await model.generate_content_async(
|
| 99 |
+
contents, generation_config={"temperature": temperature}
|
| 100 |
+
)
|
| 101 |
+
raw = (resp.text or "").strip()
|
| 102 |
+
try:
|
| 103 |
+
return json.loads(raw)
|
| 104 |
+
except json.JSONDecodeError:
|
| 105 |
+
# last-resort: strip code fences and retry
|
| 106 |
+
cleaned = raw.strip().lstrip("`json").rstrip("`").strip()
|
| 107 |
+
return json.loads(cleaned)
|
| 108 |
+
|
| 109 |
+
async def complete_tools(self, messages, tools, *, temperature=0.2) -> LLMResponse:
|
| 110 |
+
system, contents = _to_gemini_history(messages)
|
| 111 |
+
gem_tools = [{
|
| 112 |
+
"function_declarations": [
|
| 113 |
+
{"name": t.name, "description": t.description, "parameters": t.parameters}
|
| 114 |
+
for t in tools
|
| 115 |
+
]
|
| 116 |
+
}]
|
| 117 |
+
model = self._model(system, tools=gem_tools)
|
| 118 |
+
resp = await model.generate_content_async(
|
| 119 |
+
contents, generation_config={"temperature": temperature}
|
| 120 |
+
)
|
| 121 |
+
calls: list[ToolCall] = []
|
| 122 |
+
text_parts: list[str] = []
|
| 123 |
+
try:
|
| 124 |
+
for part in resp.candidates[0].content.parts:
|
| 125 |
+
fc = getattr(part, "function_call", None)
|
| 126 |
+
if fc and fc.name:
|
| 127 |
+
args = dict(fc.args) if fc.args else {}
|
| 128 |
+
calls.append(ToolCall(id=fc.name, name=fc.name, arguments=args))
|
| 129 |
+
elif getattr(part, "text", None):
|
| 130 |
+
text_parts.append(part.text)
|
| 131 |
+
except (IndexError, AttributeError):
|
| 132 |
+
pass
|
| 133 |
+
return LLMResponse(
|
| 134 |
+
text="".join(text_parts).strip(),
|
| 135 |
+
tool_calls=calls,
|
| 136 |
+
finish_reason="tool_calls" if calls else "stop",
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8))
|
| 140 |
+
async def embed(self, texts: Iterable[str]) -> list[list[float]]:
|
| 141 |
+
out: list[list[float]] = []
|
| 142 |
+
for t in texts:
|
| 143 |
+
r = await genai.embed_content_async(
|
| 144 |
+
model=self.embed_model, content=t, task_type="retrieval_document"
|
| 145 |
+
)
|
| 146 |
+
out.append(r["embedding"])
|
| 147 |
+
return out
|
| 148 |
+
|
| 149 |
+
async def vision(self, prompt: str, image_paths: list[str]) -> str:
|
| 150 |
+
import PIL.Image # docling/pillow already pulls this in
|
| 151 |
+
|
| 152 |
+
model = self._model()
|
| 153 |
+
parts: list[Any] = [prompt]
|
| 154 |
+
for p in image_paths:
|
| 155 |
+
parts.append(PIL.Image.open(p))
|
| 156 |
+
resp = await model.generate_content_async(parts)
|
| 157 |
+
return (resp.text or "").strip()
|
backend/app/llm/openai_provider.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenAI provider (switchable fallback, GPT-4o-mini).
|
| 2 |
+
|
| 3 |
+
Cheap, reliable tool-calling, 128k context. Used when DEFAULT_LLM_PROVIDER
|
| 4 |
+
is "openai" or per-request override, or as automatic fallback if Gemini
|
| 5 |
+
fails and an OpenAI key is configured.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
from typing import Any, AsyncIterator, Iterable, Optional
|
| 11 |
+
|
| 12 |
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
| 13 |
+
|
| 14 |
+
from app.core.logging import get_logger
|
| 15 |
+
from app.llm.base import (
|
| 16 |
+
LLMError,
|
| 17 |
+
LLMMessage,
|
| 18 |
+
LLMProvider,
|
| 19 |
+
LLMResponse,
|
| 20 |
+
ToolCall,
|
| 21 |
+
ToolSpec,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
log = get_logger(__name__)
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
from openai import AsyncOpenAI
|
| 28 |
+
except Exception: # pragma: no cover
|
| 29 |
+
AsyncOpenAI = None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _to_openai_messages(messages: list[LLMMessage]) -> list[dict]:
|
| 33 |
+
out: list[dict] = []
|
| 34 |
+
for m in messages:
|
| 35 |
+
if m.role == "tool":
|
| 36 |
+
out.append({
|
| 37 |
+
"role": "tool",
|
| 38 |
+
"tool_call_id": m.tool_call_id or m.name or "tool",
|
| 39 |
+
"content": m.content,
|
| 40 |
+
})
|
| 41 |
+
elif m.role == "assistant" and m.tool_calls:
|
| 42 |
+
out.append({
|
| 43 |
+
"role": "assistant",
|
| 44 |
+
"content": m.content or None,
|
| 45 |
+
"tool_calls": [
|
| 46 |
+
{
|
| 47 |
+
"id": tc.id,
|
| 48 |
+
"type": "function",
|
| 49 |
+
"function": {"name": tc.name, "arguments": json.dumps(tc.arguments)},
|
| 50 |
+
}
|
| 51 |
+
for tc in m.tool_calls
|
| 52 |
+
],
|
| 53 |
+
})
|
| 54 |
+
else:
|
| 55 |
+
out.append({"role": m.role, "content": m.content})
|
| 56 |
+
return out
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class OpenAIProvider(LLMProvider):
|
| 60 |
+
name = "openai"
|
| 61 |
+
|
| 62 |
+
def __init__(self, api_key: str, model: str, embed_model: str = "text-embedding-3-small"):
|
| 63 |
+
if AsyncOpenAI is None:
|
| 64 |
+
raise LLMError("openai package is not installed")
|
| 65 |
+
if not api_key:
|
| 66 |
+
raise LLMError("OPENAI_API_KEY is not set")
|
| 67 |
+
self.client = AsyncOpenAI(api_key=api_key)
|
| 68 |
+
self.model = model
|
| 69 |
+
self.embed_model = embed_model
|
| 70 |
+
|
| 71 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8))
|
| 72 |
+
async def complete(self, messages, *, temperature=0.2, max_tokens=None) -> str:
|
| 73 |
+
resp = await self.client.chat.completions.create(
|
| 74 |
+
model=self.model,
|
| 75 |
+
messages=_to_openai_messages(messages),
|
| 76 |
+
temperature=temperature,
|
| 77 |
+
max_tokens=max_tokens,
|
| 78 |
+
)
|
| 79 |
+
return (resp.choices[0].message.content or "").strip()
|
| 80 |
+
|
| 81 |
+
async def stream(self, messages, *, temperature=0.4) -> AsyncIterator[str]:
|
| 82 |
+
stream = await self.client.chat.completions.create(
|
| 83 |
+
model=self.model,
|
| 84 |
+
messages=_to_openai_messages(messages),
|
| 85 |
+
temperature=temperature,
|
| 86 |
+
stream=True,
|
| 87 |
+
)
|
| 88 |
+
async for chunk in stream:
|
| 89 |
+
delta = chunk.choices[0].delta.content
|
| 90 |
+
if delta:
|
| 91 |
+
yield delta
|
| 92 |
+
|
| 93 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8))
|
| 94 |
+
async def complete_json(self, messages, *, temperature=0.0) -> Any:
|
| 95 |
+
resp = await self.client.chat.completions.create(
|
| 96 |
+
model=self.model,
|
| 97 |
+
messages=_to_openai_messages(messages),
|
| 98 |
+
temperature=temperature,
|
| 99 |
+
response_format={"type": "json_object"},
|
| 100 |
+
)
|
| 101 |
+
return json.loads(resp.choices[0].message.content or "{}")
|
| 102 |
+
|
| 103 |
+
async def complete_tools(self, messages, tools, *, temperature=0.2) -> LLMResponse:
|
| 104 |
+
oa_tools = [
|
| 105 |
+
{
|
| 106 |
+
"type": "function",
|
| 107 |
+
"function": {
|
| 108 |
+
"name": t.name,
|
| 109 |
+
"description": t.description,
|
| 110 |
+
"parameters": t.parameters,
|
| 111 |
+
},
|
| 112 |
+
}
|
| 113 |
+
for t in tools
|
| 114 |
+
]
|
| 115 |
+
resp = await self.client.chat.completions.create(
|
| 116 |
+
model=self.model,
|
| 117 |
+
messages=_to_openai_messages(messages),
|
| 118 |
+
tools=oa_tools,
|
| 119 |
+
temperature=temperature,
|
| 120 |
+
)
|
| 121 |
+
msg = resp.choices[0].message
|
| 122 |
+
calls: list[ToolCall] = []
|
| 123 |
+
for tc in (msg.tool_calls or []):
|
| 124 |
+
try:
|
| 125 |
+
args = json.loads(tc.function.arguments or "{}")
|
| 126 |
+
except json.JSONDecodeError:
|
| 127 |
+
args = {}
|
| 128 |
+
calls.append(ToolCall(id=tc.id, name=tc.function.name, arguments=args))
|
| 129 |
+
return LLMResponse(
|
| 130 |
+
text=(msg.content or "").strip(),
|
| 131 |
+
tool_calls=calls,
|
| 132 |
+
finish_reason="tool_calls" if calls else "stop",
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=8))
|
| 136 |
+
async def embed(self, texts: Iterable[str]) -> list[list[float]]:
|
| 137 |
+
resp = await self.client.embeddings.create(
|
| 138 |
+
model=self.embed_model, input=list(texts)
|
| 139 |
+
)
|
| 140 |
+
return [d.embedding for d in resp.data]
|
backend/app/llm/registry.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Provider registry — resolves which LLM to use and handles fallback.
|
| 2 |
+
|
| 3 |
+
This is the single place that knows about concrete providers. The agent and
|
| 4 |
+
services ask for a provider by name (or the default) and get back something
|
| 5 |
+
implementing `LLMProvider`. If the requested provider isn't configured, we
|
| 6 |
+
fall back gracefully so the app still runs with whatever key is present.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from functools import lru_cache
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
from app.core.config import settings
|
| 14 |
+
from app.core.logging import get_logger
|
| 15 |
+
from app.llm.base import LLMError, LLMProvider
|
| 16 |
+
|
| 17 |
+
log = get_logger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@lru_cache
|
| 21 |
+
def _gemini() -> Optional[LLMProvider]:
|
| 22 |
+
if not settings.has_gemini:
|
| 23 |
+
return None
|
| 24 |
+
from app.llm.gemini_provider import GeminiProvider
|
| 25 |
+
|
| 26 |
+
return GeminiProvider(
|
| 27 |
+
api_key=settings.gemini_api_key,
|
| 28 |
+
model=settings.gemini_model,
|
| 29 |
+
embed_model=settings.gemini_embed_model,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@lru_cache
|
| 34 |
+
def _openai() -> Optional[LLMProvider]:
|
| 35 |
+
if not settings.has_openai:
|
| 36 |
+
return None
|
| 37 |
+
from app.llm.openai_provider import OpenAIProvider
|
| 38 |
+
|
| 39 |
+
return OpenAIProvider(api_key=settings.openai_api_key, model=settings.openai_model)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def available_providers() -> list[str]:
|
| 43 |
+
out = []
|
| 44 |
+
if settings.has_gemini:
|
| 45 |
+
out.append("gemini")
|
| 46 |
+
if settings.has_openai:
|
| 47 |
+
out.append("openai")
|
| 48 |
+
return out
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def get_provider(name: Optional[str] = None) -> LLMProvider:
|
| 52 |
+
"""Resolve a provider by name with sensible fallback.
|
| 53 |
+
|
| 54 |
+
Order of preference:
|
| 55 |
+
1. explicitly requested name (if configured)
|
| 56 |
+
2. configured DEFAULT_LLM_PROVIDER
|
| 57 |
+
3. any other configured provider
|
| 58 |
+
"""
|
| 59 |
+
requested = (name or settings.default_llm_provider or "gemini").lower()
|
| 60 |
+
|
| 61 |
+
chain = [requested]
|
| 62 |
+
# then the configured default, then the other one
|
| 63 |
+
for cand in (settings.default_llm_provider, "gemini", "openai"):
|
| 64 |
+
if cand not in chain:
|
| 65 |
+
chain.append(cand)
|
| 66 |
+
|
| 67 |
+
for cand in chain:
|
| 68 |
+
prov = _gemini() if cand == "gemini" else _openai() if cand == "openai" else None
|
| 69 |
+
if prov is not None:
|
| 70 |
+
if cand != requested:
|
| 71 |
+
log.warning("Provider '%s' unavailable; using '%s'", requested, cand)
|
| 72 |
+
return prov
|
| 73 |
+
|
| 74 |
+
raise LLMError(
|
| 75 |
+
"No LLM provider configured. Set GEMINI_API_KEY (recommended) or OPENAI_API_KEY."
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def get_embedder() -> LLMProvider:
|
| 80 |
+
"""Embeddings can come from any provider; prefer the default."""
|
| 81 |
+
return get_provider()
|
backend/app/main.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application entrypoint.
|
| 2 |
+
|
| 3 |
+
Wires routers, CORS, logging, and DB init. Run with:
|
| 4 |
+
uvicorn app.main:app --reload --port 8000
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from contextlib import asynccontextmanager
|
| 9 |
+
|
| 10 |
+
from fastapi import FastAPI
|
| 11 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 12 |
+
|
| 13 |
+
from app.api import actions, chat, documents, meta
|
| 14 |
+
from app.core.config import settings
|
| 15 |
+
from app.core.logging import get_logger, setup_logging
|
| 16 |
+
from app.services import storage
|
| 17 |
+
|
| 18 |
+
log = get_logger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@asynccontextmanager
|
| 22 |
+
async def lifespan(app: FastAPI):
|
| 23 |
+
setup_logging()
|
| 24 |
+
storage.init_db()
|
| 25 |
+
providers = []
|
| 26 |
+
if settings.has_gemini:
|
| 27 |
+
providers.append("gemini")
|
| 28 |
+
if settings.has_openai:
|
| 29 |
+
providers.append("openai")
|
| 30 |
+
if not providers:
|
| 31 |
+
log.warning("No LLM provider configured! Set GEMINI_API_KEY in backend/.env")
|
| 32 |
+
else:
|
| 33 |
+
log.info("LLM providers available: %s (default=%s)",
|
| 34 |
+
providers, settings.default_llm_provider)
|
| 35 |
+
yield
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
app = FastAPI(
|
| 39 |
+
title="Document Processing AI Agent",
|
| 40 |
+
description="Monkhub Innovations — chat-based document processing agent",
|
| 41 |
+
version="1.0.0",
|
| 42 |
+
lifespan=lifespan,
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
app.add_middleware(
|
| 46 |
+
CORSMiddleware,
|
| 47 |
+
allow_origins=settings.cors_origin_list,
|
| 48 |
+
allow_credentials=True,
|
| 49 |
+
allow_methods=["*"],
|
| 50 |
+
allow_headers=["*"],
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
app.include_router(meta.router)
|
| 54 |
+
app.include_router(documents.router)
|
| 55 |
+
app.include_router(actions.router)
|
| 56 |
+
app.include_router(chat.router)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@app.get("/")
|
| 60 |
+
async def root():
|
| 61 |
+
return {"name": "Document Processing AI Agent", "docs": "/docs", "health": "/api/health"}
|
backend/app/schemas/__init__.py
ADDED
|
File without changes
|
backend/app/schemas/documents.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared data contracts for documents, extraction, and chat.
|
| 2 |
+
|
| 3 |
+
These Pydantic models are the boundary between backend and frontend and
|
| 4 |
+
between the agent and its tools. Keep them stable and well-typed.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from enum import Enum
|
| 9 |
+
from typing import Any, Optional
|
| 10 |
+
|
| 11 |
+
from pydantic import BaseModel, Field
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
# --------------------------------------------------------------------------
|
| 15 |
+
# Geometry / provenance
|
| 16 |
+
# --------------------------------------------------------------------------
|
| 17 |
+
class BBox(BaseModel):
|
| 18 |
+
"""Normalised bounding box (0..1) plus the page it lives on.
|
| 19 |
+
|
| 20 |
+
Normalised coordinates let the frontend overlay highlights regardless of
|
| 21 |
+
the rendered image size. This powers the citation-highlight UX.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
page: int = Field(..., description="0-based page index")
|
| 25 |
+
x0: float
|
| 26 |
+
y0: float
|
| 27 |
+
x1: float
|
| 28 |
+
y1: float
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class Citation(BaseModel):
|
| 32 |
+
"""A grounded reference back to the source document."""
|
| 33 |
+
|
| 34 |
+
text: str = Field(..., description="The supporting snippet")
|
| 35 |
+
page: int
|
| 36 |
+
bbox: Optional[BBox] = None
|
| 37 |
+
chunk_id: Optional[str] = None
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
# --------------------------------------------------------------------------
|
| 41 |
+
# Documents
|
| 42 |
+
# --------------------------------------------------------------------------
|
| 43 |
+
class DocStatus(str, Enum):
|
| 44 |
+
uploaded = "uploaded"
|
| 45 |
+
processing = "processing"
|
| 46 |
+
ready = "ready"
|
| 47 |
+
failed = "failed"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class PageInfo(BaseModel):
|
| 51 |
+
page: int
|
| 52 |
+
width: int
|
| 53 |
+
height: int
|
| 54 |
+
image_url: str
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class DocumentMeta(BaseModel):
|
| 58 |
+
id: str
|
| 59 |
+
filename: str
|
| 60 |
+
content_type: str
|
| 61 |
+
size_bytes: int
|
| 62 |
+
status: DocStatus = DocStatus.uploaded
|
| 63 |
+
num_pages: int = 0
|
| 64 |
+
created_at: str
|
| 65 |
+
error: Optional[str] = None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class DocumentDetail(DocumentMeta):
|
| 69 |
+
pages: list[PageInfo] = Field(default_factory=list)
|
| 70 |
+
markdown: Optional[str] = None
|
| 71 |
+
classification: Optional["Classification"] = None
|
| 72 |
+
summary: Optional[str] = None
|
| 73 |
+
extraction: Optional["ExtractionResult"] = None
|
| 74 |
+
anomalies: list["Anomaly"] = Field(default_factory=list)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# --------------------------------------------------------------------------
|
| 78 |
+
# Classification
|
| 79 |
+
# --------------------------------------------------------------------------
|
| 80 |
+
class Classification(BaseModel):
|
| 81 |
+
doc_type: str = Field(..., description="e.g. invoice, contract, form, resume, report, receipt, other")
|
| 82 |
+
confidence: float = Field(..., ge=0, le=1)
|
| 83 |
+
rationale: str = ""
|
| 84 |
+
candidates: list[dict[str, Any]] = Field(default_factory=list)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# --------------------------------------------------------------------------
|
| 88 |
+
# Extraction
|
| 89 |
+
# --------------------------------------------------------------------------
|
| 90 |
+
class ExtractedField(BaseModel):
|
| 91 |
+
name: str
|
| 92 |
+
value: Any = None
|
| 93 |
+
confidence: float = Field(0.0, ge=0, le=1)
|
| 94 |
+
citation: Optional[Citation] = None
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class ExtractedTable(BaseModel):
|
| 98 |
+
title: Optional[str] = None
|
| 99 |
+
columns: list[str] = Field(default_factory=list)
|
| 100 |
+
rows: list[list[Any]] = Field(default_factory=list)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class Entity(BaseModel):
|
| 104 |
+
type: str # person, org, date, money, email, address, ...
|
| 105 |
+
value: str
|
| 106 |
+
confidence: float = Field(0.0, ge=0, le=1)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class ExtractedRecord(BaseModel):
|
| 110 |
+
"""One repeated entity in a multi-record document (e.g. one employee in a
|
| 111 |
+
roster, one transaction in a statement). Each cell keeps its own confidence
|
| 112 |
+
and citation so records are as grounded as flat fields."""
|
| 113 |
+
|
| 114 |
+
fields: list[ExtractedField] = Field(default_factory=list)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class ExtractionLayout(str, Enum):
|
| 118 |
+
single_record = "single_record" # the whole doc describes one entity
|
| 119 |
+
multi_record = "multi_record" # the doc lists many similar entities
|
| 120 |
+
mixed = "mixed" # a header record plus a repeated sub-list
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class ExtractionResult(BaseModel):
|
| 124 |
+
schema_name: str = "generic"
|
| 125 |
+
layout: ExtractionLayout = ExtractionLayout.single_record
|
| 126 |
+
record_type: Optional[str] = None # singular name of a record, e.g. "employee"
|
| 127 |
+
# Document-level / header fields (always used for single_record docs).
|
| 128 |
+
fields: list[ExtractedField] = Field(default_factory=list)
|
| 129 |
+
# Repeated records (multi_record / mixed) + the canonical column order.
|
| 130 |
+
records: list[ExtractedRecord] = Field(default_factory=list)
|
| 131 |
+
record_keys: list[str] = Field(default_factory=list)
|
| 132 |
+
tables: list[ExtractedTable] = Field(default_factory=list)
|
| 133 |
+
entities: list[Entity] = Field(default_factory=list)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# --------------------------------------------------------------------------
|
| 137 |
+
# Anomalies
|
| 138 |
+
# --------------------------------------------------------------------------
|
| 139 |
+
class AnomalySeverity(str, Enum):
|
| 140 |
+
info = "info"
|
| 141 |
+
warning = "warning"
|
| 142 |
+
error = "error"
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class Anomaly(BaseModel):
|
| 146 |
+
severity: AnomalySeverity
|
| 147 |
+
field: Optional[str] = None
|
| 148 |
+
message: str
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# --------------------------------------------------------------------------
|
| 152 |
+
# Chat
|
| 153 |
+
# --------------------------------------------------------------------------
|
| 154 |
+
class ChatRole(str, Enum):
|
| 155 |
+
user = "user"
|
| 156 |
+
assistant = "assistant"
|
| 157 |
+
system = "system"
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class ChatMessage(BaseModel):
|
| 161 |
+
role: ChatRole
|
| 162 |
+
content: str
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
class ChatRequest(BaseModel):
|
| 166 |
+
document_id: Optional[str] = None
|
| 167 |
+
messages: list[ChatMessage]
|
| 168 |
+
provider: Optional[str] = Field(None, description="override LLM provider: gemini|openai")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# Resolve forward references
|
| 172 |
+
DocumentDetail.model_rebuild()
|
backend/app/schemas/templates.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Optional, user-selectable extraction hints.
|
| 2 |
+
|
| 3 |
+
IMPORTANT: these are NOT required for the agent to work. By default the
|
| 4 |
+
extractor is fully adaptive — it induces the right schema from the document's
|
| 5 |
+
own content (see services/extraction.py), so it handles any document type,
|
| 6 |
+
including ones never seen before, with no template at all.
|
| 7 |
+
|
| 8 |
+
This module only exists so a user can *optionally* force a particular framing
|
| 9 |
+
via the re-extract API (`?schema=invoice`). Nothing here is applied
|
| 10 |
+
automatically, and the default pipeline never consults it. Keep it tiny — do
|
| 11 |
+
NOT grow a per-domain format zoo here; that's the anti-pattern the adaptive
|
| 12 |
+
extractor is designed to avoid.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
# A short, illustrative menu surfaced by the /-/schemas endpoint. "auto" is the
|
| 17 |
+
# default and means "let the agent decide" — the others are just optional framings.
|
| 18 |
+
OPTIONAL_HINTS: list[str] = ["auto", "invoice", "contract", "receipt", "resume"]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def all_template_names() -> list[str]:
|
| 22 |
+
return list(OPTIONAL_HINTS)
|
backend/app/services/__init__.py
ADDED
|
File without changes
|
backend/app/services/anomaly.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Anomaly / consistency checks.
|
| 2 |
+
|
| 3 |
+
Two layers:
|
| 4 |
+
1. Deterministic rules — missing required fields (per schema template),
|
| 5 |
+
low-confidence extractions, and arithmetic checks (e.g. invoice
|
| 6 |
+
subtotal + tax == total).
|
| 7 |
+
2. LLM review — a second pass that flags semantic inconsistencies the
|
| 8 |
+
rules can't catch.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import re
|
| 13 |
+
from datetime import date
|
| 14 |
+
|
| 15 |
+
from dateutil import parser as date_parser
|
| 16 |
+
|
| 17 |
+
from app.core.logging import get_logger
|
| 18 |
+
from app.llm.base import LLMMessage
|
| 19 |
+
from app.llm.registry import get_provider
|
| 20 |
+
from app.schemas.documents import Anomaly, AnomalySeverity, ExtractionLayout, ExtractionResult
|
| 21 |
+
|
| 22 |
+
log = get_logger(__name__)
|
| 23 |
+
|
| 24 |
+
_LOW_CONF = 0.45
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _to_number(v) -> float | None:
|
| 28 |
+
if v is None:
|
| 29 |
+
return None
|
| 30 |
+
if isinstance(v, (int, float)):
|
| 31 |
+
return float(v)
|
| 32 |
+
m = re.search(r"-?\d[\d,]*\.?\d*", str(v))
|
| 33 |
+
if not m:
|
| 34 |
+
return None
|
| 35 |
+
try:
|
| 36 |
+
return float(m.group(0).replace(",", ""))
|
| 37 |
+
except ValueError:
|
| 38 |
+
return None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# A string only counts as a date if it carries an unambiguous date shape AND a
|
| 42 |
+
# 4-digit year, so money/IDs (e.g. "19,50,000") never get mis-parsed as dates.
|
| 43 |
+
_DATE_SHAPE = re.compile(
|
| 44 |
+
r"\d{4}-\d{1,2}-\d{1,2}" # ISO 2024-01-15
|
| 45 |
+
r"|\d{1,2}[/.]\d{1,2}[/.]\d{2,4}" # 15/01/2024, 1.1.24
|
| 46 |
+
r"|(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{4}" # March 2021
|
| 47 |
+
r"|\d{1,2}\s+(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{4}", # 15 Jan 2024
|
| 48 |
+
re.IGNORECASE,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _as_future_date(value) -> date | None:
|
| 53 |
+
"""Return the parsed date iff `value` is clearly a date AFTER today.
|
| 54 |
+
|
| 55 |
+
Date sanity is done deterministically here (not by the LLM) because models
|
| 56 |
+
routinely get past/future comparisons wrong — e.g. calling 2024-01-15 a
|
| 57 |
+
'future' date when today is 2026. Template-free: works on any field whose
|
| 58 |
+
value looks like a date, regardless of document type or field name.
|
| 59 |
+
"""
|
| 60 |
+
s = str(value).strip()
|
| 61 |
+
if not _DATE_SHAPE.search(s):
|
| 62 |
+
return None
|
| 63 |
+
try:
|
| 64 |
+
dt = date_parser.parse(s, fuzzy=True, dayfirst=True).date()
|
| 65 |
+
except (ValueError, OverflowError, TypeError):
|
| 66 |
+
return None
|
| 67 |
+
return dt if dt > date.today() else None
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _future_date_checks(extraction: ExtractionResult) -> list[Anomaly]:
|
| 71 |
+
today = date.today().isoformat()
|
| 72 |
+
out: list[Anomaly] = []
|
| 73 |
+
|
| 74 |
+
def check(name: str, value, where: str = "") -> None:
|
| 75 |
+
if value in (None, "", []):
|
| 76 |
+
return
|
| 77 |
+
fut = _as_future_date(value)
|
| 78 |
+
if fut:
|
| 79 |
+
out.append(Anomaly(
|
| 80 |
+
severity=AnomalySeverity.warning, field=name,
|
| 81 |
+
message=f"'{name}'{where} is a future date ({value}), "
|
| 82 |
+
f"after today ({today}). Please verify.",
|
| 83 |
+
))
|
| 84 |
+
|
| 85 |
+
for f in extraction.fields:
|
| 86 |
+
check(f.name, f.value)
|
| 87 |
+
for i, rec in enumerate(extraction.records, 1):
|
| 88 |
+
for f in rec.fields:
|
| 89 |
+
check(f.name, f.value, where=f" (record {i})")
|
| 90 |
+
return out
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _find(present: dict, *substrings: str):
|
| 94 |
+
"""Return the first extracted field whose name contains any substring."""
|
| 95 |
+
for name, f in present.items():
|
| 96 |
+
low = name.lower()
|
| 97 |
+
if any(s in low for s in substrings):
|
| 98 |
+
return f
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _generic_arithmetic(present: dict) -> list[Anomaly]:
|
| 103 |
+
"""Type-agnostic money check: wherever a document exposes component amounts
|
| 104 |
+
and a total (invoice, receipt, payslip, statement, quote, ...), verify they
|
| 105 |
+
reconcile. Driven by field-name semantics, not by any document template."""
|
| 106 |
+
total_f = _find(present, "grand_total", "amount_due", "balance_due", "total", "net_payable")
|
| 107 |
+
sub_f = _find(present, "subtotal", "sub_total", "gross", "basic")
|
| 108 |
+
tax_f = _find(present, "tax", "gst", "vat")
|
| 109 |
+
disc_f = _find(present, "discount", "rebate")
|
| 110 |
+
if not total_f or not sub_f:
|
| 111 |
+
return []
|
| 112 |
+
sub = _to_number(sub_f.value)
|
| 113 |
+
total = _to_number(total_f.value)
|
| 114 |
+
if sub is None or total is None:
|
| 115 |
+
return []
|
| 116 |
+
tax = _to_number(tax_f.value) if tax_f else 0.0
|
| 117 |
+
disc = _to_number(disc_f.value) if disc_f else 0.0
|
| 118 |
+
expected = sub + (tax or 0.0) - (disc or 0.0)
|
| 119 |
+
if abs(expected - total) > max(0.02 * abs(total), 0.5):
|
| 120 |
+
bits = [f"{sub_f.name} ({sub})"]
|
| 121 |
+
if tax_f and tax:
|
| 122 |
+
bits.append(f"+ {tax_f.name} ({tax})")
|
| 123 |
+
if disc_f and disc:
|
| 124 |
+
bits.append(f"- {disc_f.name} ({disc})")
|
| 125 |
+
return [Anomaly(
|
| 126 |
+
severity=AnomalySeverity.warning, field=total_f.name,
|
| 127 |
+
message=f"Amounts may not reconcile: {' '.join(bits)} = {expected:g}, "
|
| 128 |
+
f"but {total_f.name} is {total:g}.",
|
| 129 |
+
)]
|
| 130 |
+
return []
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def rule_checks(extraction: ExtractionResult, doc_type: str | None) -> list[Anomaly]:
|
| 134 |
+
out: list[Anomaly] = []
|
| 135 |
+
present = {f.name: f for f in extraction.fields}
|
| 136 |
+
is_multi = extraction.layout in (ExtractionLayout.multi_record, ExtractionLayout.mixed)
|
| 137 |
+
|
| 138 |
+
# Multi-record sanity: flag records that are entirely empty or missing an
|
| 139 |
+
# identifier — works for any kind of repeated record, no schema assumed.
|
| 140 |
+
if extraction.records:
|
| 141 |
+
id_key = extraction.record_keys[0] if extraction.record_keys else None
|
| 142 |
+
for i, rec in enumerate(extraction.records, 1):
|
| 143 |
+
vals = {f.name: f.value for f in rec.fields}
|
| 144 |
+
if all(v in (None, "", []) for v in vals.values()):
|
| 145 |
+
out.append(Anomaly(
|
| 146 |
+
severity=AnomalySeverity.warning, field=None,
|
| 147 |
+
message=f"Record {i} is empty.",
|
| 148 |
+
))
|
| 149 |
+
elif id_key and vals.get(id_key) in (None, "", []):
|
| 150 |
+
out.append(Anomaly(
|
| 151 |
+
severity=AnomalySeverity.info, field=id_key,
|
| 152 |
+
message=f"Record {i} is missing its identifier ('{id_key}').",
|
| 153 |
+
))
|
| 154 |
+
|
| 155 |
+
# Low confidence (header / doc-level fields).
|
| 156 |
+
for f in extraction.fields:
|
| 157 |
+
if f.value not in (None, "", []) and f.confidence < _LOW_CONF:
|
| 158 |
+
out.append(Anomaly(
|
| 159 |
+
severity=AnomalySeverity.warning, field=f.name,
|
| 160 |
+
message=f"Low-confidence extraction for '{f.name}' "
|
| 161 |
+
f"({f.confidence:.0%}). Please verify.",
|
| 162 |
+
))
|
| 163 |
+
|
| 164 |
+
# Generic financial reconciliation (only fires when the right fields exist).
|
| 165 |
+
if not is_multi:
|
| 166 |
+
out += _generic_arithmetic(present)
|
| 167 |
+
|
| 168 |
+
# Deterministic temporal sanity (past/future) — reliable where the LLM isn't.
|
| 169 |
+
out += _future_date_checks(extraction)
|
| 170 |
+
return out
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _summarise_extraction(extraction: ExtractionResult) -> str:
|
| 174 |
+
parts = []
|
| 175 |
+
if extraction.fields:
|
| 176 |
+
parts.append("Document-level fields:\n" +
|
| 177 |
+
"\n".join(f"- {f.name}: {f.value}" for f in extraction.fields))
|
| 178 |
+
if extraction.records:
|
| 179 |
+
parts.append(f"\n{len(extraction.records)} records "
|
| 180 |
+
f"(type: {extraction.record_type or 'record'}, "
|
| 181 |
+
f"keys: {', '.join(extraction.record_keys)}):")
|
| 182 |
+
for i, rec in enumerate(extraction.records[:25], 1):
|
| 183 |
+
kv = ", ".join(f"{f.name}={f.value}" for f in rec.fields if f.value not in (None, ""))
|
| 184 |
+
parts.append(f" {i}. {kv}")
|
| 185 |
+
return "\n".join(parts) or "(no fields extracted)"
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
async def llm_review(markdown: str, extraction: ExtractionResult,
|
| 189 |
+
provider: str | None = None) -> list[Anomaly]:
|
| 190 |
+
if not markdown.strip():
|
| 191 |
+
return []
|
| 192 |
+
llm = get_provider(provider)
|
| 193 |
+
extraction_str = _summarise_extraction(extraction)
|
| 194 |
+
sys = (
|
| 195 |
+
"Do NOT perform any date or temporal reasoning: never flag a value for "
|
| 196 |
+
"being a 'future' or 'past' date, nor compare dates to today — those "
|
| 197 |
+
"checks are handled separately and reliably elsewhere; ignore them.\n"
|
| 198 |
+
"You review extracted document data against the source for GENUINE problems "
|
| 199 |
+
"only: internal contradictions, values that conflict with the source text, "
|
| 200 |
+
"arithmetic that doesn't add up, impossible/suspicious values, or important "
|
| 201 |
+
"data that the source clearly states but is missing or wrong in the extraction.\n"
|
| 202 |
+
"DO NOT flag: formatting or normalisation differences (e.g. a date rewritten "
|
| 203 |
+
"as ISO 'YYYY-MM-DD', currency symbols, casing, whitespace), values that are "
|
| 204 |
+
"legitimately null because the source says they are missing/masked/unknown, or "
|
| 205 |
+
"stylistic choices. Those are correct, not anomalies.\n"
|
| 206 |
+
"Prefer 'info' for notes worth a human's attention, 'warning' for likely "
|
| 207 |
+
"issues, 'error' for clear errors. Respond ONLY as JSON: "
|
| 208 |
+
'{"anomalies": [{"severity": "info|warning|error", "field": str|null, '
|
| 209 |
+
'"message": str}]}. Return an empty array if everything is consistent.'
|
| 210 |
+
)
|
| 211 |
+
msgs = [
|
| 212 |
+
LLMMessage(role="system", content=sys),
|
| 213 |
+
LLMMessage(role="user",
|
| 214 |
+
content=f"Extracted data:\n{extraction_str}\n\n"
|
| 215 |
+
f"Source (excerpt):\n{markdown[:7000]}"),
|
| 216 |
+
]
|
| 217 |
+
try:
|
| 218 |
+
data = await llm.complete_json(msgs)
|
| 219 |
+
except Exception as e:
|
| 220 |
+
log.warning("llm anomaly review failed: %s", e)
|
| 221 |
+
return []
|
| 222 |
+
|
| 223 |
+
out = []
|
| 224 |
+
for a in data.get("anomalies", []) or []:
|
| 225 |
+
try:
|
| 226 |
+
out.append(Anomaly(
|
| 227 |
+
severity=AnomalySeverity(a.get("severity", "info")),
|
| 228 |
+
field=a.get("field"),
|
| 229 |
+
message=str(a.get("message", "")),
|
| 230 |
+
))
|
| 231 |
+
except Exception:
|
| 232 |
+
continue
|
| 233 |
+
return out
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
async def detect(markdown: str, extraction: ExtractionResult, doc_type: str | None,
|
| 237 |
+
provider: str | None = None, deep: bool = True) -> list[Anomaly]:
|
| 238 |
+
anomalies = rule_checks(extraction, doc_type)
|
| 239 |
+
if deep:
|
| 240 |
+
anomalies += await llm_review(markdown, extraction, provider)
|
| 241 |
+
return anomalies
|
backend/app/services/classification.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Document classification — zero-shot via LLM.
|
| 2 |
+
|
| 3 |
+
Returns a type + confidence + rationale, plus ranked candidates. Uses a
|
| 4 |
+
truncated view of the document so it stays cheap and fast.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from app.core.logging import get_logger
|
| 9 |
+
from app.llm.base import LLMMessage
|
| 10 |
+
from app.llm.registry import get_provider
|
| 11 |
+
from app.schemas.documents import Classification
|
| 12 |
+
|
| 13 |
+
log = get_logger(__name__)
|
| 14 |
+
|
| 15 |
+
KNOWN_TYPES = [
|
| 16 |
+
"invoice", "receipt", "contract", "purchase_order", "form",
|
| 17 |
+
"resume", "employee_record", "report", "letter", "email",
|
| 18 |
+
"bank_statement", "id_document", "spreadsheet", "other",
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
_SYS = (
|
| 22 |
+
"You are a document classification expert. Classify the document into the single "
|
| 23 |
+
f"best-fitting type from this list: {', '.join(KNOWN_TYPES)}. "
|
| 24 |
+
"Pick 'employee_record' for HR records / employee rosters (even when several "
|
| 25 |
+
"people are listed), 'spreadsheet' for tabular data dumps, and 'form' for "
|
| 26 |
+
"fillable forms. Use 'other' only when nothing fits. "
|
| 27 |
+
"Respond ONLY as JSON with keys: doc_type (string from the list), "
|
| 28 |
+
"confidence (0..1), rationale (short string), "
|
| 29 |
+
"candidates (array of {type, confidence} for the top 3)."
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def classify(markdown: str, provider: str | None = None) -> Classification:
|
| 34 |
+
llm = get_provider(provider)
|
| 35 |
+
excerpt = markdown[:6000] if markdown else "(empty document)"
|
| 36 |
+
msgs = [
|
| 37 |
+
LLMMessage(role="system", content=_SYS),
|
| 38 |
+
LLMMessage(role="user", content=f"Document content:\n\n{excerpt}"),
|
| 39 |
+
]
|
| 40 |
+
try:
|
| 41 |
+
data = await llm.complete_json(msgs)
|
| 42 |
+
except Exception as e:
|
| 43 |
+
log.warning("classification failed: %s", e)
|
| 44 |
+
return Classification(doc_type="other", confidence=0.0, rationale="classification error")
|
| 45 |
+
|
| 46 |
+
return Classification(
|
| 47 |
+
doc_type=str(data.get("doc_type", "other")).lower(),
|
| 48 |
+
confidence=float(data.get("confidence", 0.5) or 0.5),
|
| 49 |
+
rationale=str(data.get("rationale", "")),
|
| 50 |
+
candidates=data.get("candidates", []) or [],
|
| 51 |
+
)
|
backend/app/services/export.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Export extracted data to structured formats: JSON, CSV, Excel."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import csv
|
| 5 |
+
import io
|
| 6 |
+
import json
|
| 7 |
+
|
| 8 |
+
from app.schemas.documents import DocumentDetail
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def to_json(detail: DocumentDetail) -> bytes:
|
| 12 |
+
payload = {
|
| 13 |
+
"document": {
|
| 14 |
+
"id": detail.id,
|
| 15 |
+
"filename": detail.filename,
|
| 16 |
+
"doc_type": detail.classification.doc_type if detail.classification else None,
|
| 17 |
+
"num_pages": detail.num_pages,
|
| 18 |
+
},
|
| 19 |
+
"classification": detail.classification.model_dump() if detail.classification else None,
|
| 20 |
+
"summary": detail.summary,
|
| 21 |
+
"extraction": detail.extraction.model_dump() if detail.extraction else None,
|
| 22 |
+
"anomalies": [a.model_dump() for a in detail.anomalies],
|
| 23 |
+
}
|
| 24 |
+
return json.dumps(payload, indent=2, default=str).encode("utf-8")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _record_rows(extraction) -> tuple[list[str], list[list]]:
|
| 28 |
+
"""Flatten records into (header, rows) using the canonical key order."""
|
| 29 |
+
keys = list(extraction.record_keys)
|
| 30 |
+
if not keys: # derive from the records themselves, preserving first-seen order
|
| 31 |
+
seen = set()
|
| 32 |
+
for rec in extraction.records:
|
| 33 |
+
for f in rec.fields:
|
| 34 |
+
if f.name not in seen:
|
| 35 |
+
keys.append(f.name)
|
| 36 |
+
seen.add(f.name)
|
| 37 |
+
rows = []
|
| 38 |
+
for rec in extraction.records:
|
| 39 |
+
m = {f.name: f.value for f in rec.fields}
|
| 40 |
+
rows.append([m.get(k) for k in keys])
|
| 41 |
+
return keys, rows
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def to_csv(detail: DocumentDetail) -> bytes:
|
| 45 |
+
buf = io.StringIO()
|
| 46 |
+
w = csv.writer(buf)
|
| 47 |
+
ex = detail.extraction
|
| 48 |
+
# Multi-record docs export as a proper record table; otherwise field/value.
|
| 49 |
+
if ex and ex.records:
|
| 50 |
+
keys, rows = _record_rows(ex)
|
| 51 |
+
w.writerow(keys)
|
| 52 |
+
for row in rows:
|
| 53 |
+
w.writerow(["" if c is None else c for c in row])
|
| 54 |
+
else:
|
| 55 |
+
w.writerow(["field", "value", "confidence"])
|
| 56 |
+
if ex:
|
| 57 |
+
for f in ex.fields:
|
| 58 |
+
w.writerow([f.name, f.value, f"{f.confidence:.2f}"])
|
| 59 |
+
return buf.getvalue().encode("utf-8")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def to_xlsx(detail: DocumentDetail) -> bytes:
|
| 63 |
+
from openpyxl import Workbook
|
| 64 |
+
|
| 65 |
+
wb = Workbook()
|
| 66 |
+
ws = wb.active
|
| 67 |
+
ws.title = "Fields"
|
| 68 |
+
ws.append(["Field", "Value", "Confidence"])
|
| 69 |
+
ex = detail.extraction
|
| 70 |
+
if ex:
|
| 71 |
+
for f in ex.fields:
|
| 72 |
+
ws.append([f.name, str(f.value) if f.value is not None else "", round(f.confidence, 2)])
|
| 73 |
+
|
| 74 |
+
# Repeated records get their own sheet, one row per record.
|
| 75 |
+
if ex.records:
|
| 76 |
+
keys, rows = _record_rows(ex)
|
| 77 |
+
rec = wb.create_sheet(title=(ex.record_type or "Records")[:31])
|
| 78 |
+
rec.append(keys)
|
| 79 |
+
for row in rows:
|
| 80 |
+
rec.append([str(c) if c is not None else "" for c in row])
|
| 81 |
+
|
| 82 |
+
for ti, table in enumerate(ex.tables):
|
| 83 |
+
tab = wb.create_sheet(title=(table.title or f"Table {ti+1}")[:31])
|
| 84 |
+
if table.columns:
|
| 85 |
+
tab.append(table.columns)
|
| 86 |
+
for row in table.rows:
|
| 87 |
+
tab.append([str(c) if c is not None else "" for c in row])
|
| 88 |
+
|
| 89 |
+
out = io.BytesIO()
|
| 90 |
+
wb.save(out)
|
| 91 |
+
return out.getvalue()
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
EXPORTERS = {
|
| 95 |
+
"json": (to_json, "application/json", "json"),
|
| 96 |
+
"csv": (to_csv, "text/csv", "csv"),
|
| 97 |
+
"xlsx": (to_xlsx,
|
| 98 |
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"),
|
| 99 |
+
}
|
backend/app/services/extraction.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adaptive structured-data extraction.
|
| 2 |
+
|
| 3 |
+
The goal: given ANY document — any layout, any field-label convention, known
|
| 4 |
+
type or not — produce clean, consistently-keyed structured data in the shapes
|
| 5 |
+
the rest of the app understands (fields / records / tables / entities), each
|
| 6 |
+
grounded with a confidence score and a best-effort citation.
|
| 7 |
+
|
| 8 |
+
How it adapts (vs. the old fixed-template approach):
|
| 9 |
+
|
| 10 |
+
* Layout detection — the model decides whether the document describes ONE
|
| 11 |
+
entity (`single_record`, e.g. an invoice), MANY similar entities
|
| 12 |
+
(`multi_record`, e.g. an employee roster or a transaction list), or a
|
| 13 |
+
header plus a repeated sub-list (`mixed`, e.g. invoice + line items).
|
| 14 |
+
* Schema induction — for a known type a template *suggests* fields, but the
|
| 15 |
+
model is free to add/drop fields to match the actual document. For unknown
|
| 16 |
+
types it derives the schema from the content.
|
| 17 |
+
* Canonical keys — inconsistent source labels ("Staff Number" / "Personnel
|
| 18 |
+
ID" / "ID") are normalised to ONE snake_case key, consistent across every
|
| 19 |
+
record, so downstream consumers get a stable shape.
|
| 20 |
+
* Grounding — values come only from the document body (never file metadata),
|
| 21 |
+
dates/amounts are normalised, and citations are attached by matching values
|
| 22 |
+
back to provenance-carrying chunks.
|
| 23 |
+
"""
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
from app.core.logging import get_logger
|
| 27 |
+
from app.llm.base import LLMMessage
|
| 28 |
+
from app.llm.registry import get_provider
|
| 29 |
+
from app.schemas.documents import (
|
| 30 |
+
BBox,
|
| 31 |
+
Citation,
|
| 32 |
+
Entity,
|
| 33 |
+
ExtractedField,
|
| 34 |
+
ExtractedRecord,
|
| 35 |
+
ExtractedTable,
|
| 36 |
+
ExtractionLayout,
|
| 37 |
+
ExtractionResult,
|
| 38 |
+
)
|
| 39 |
+
from app.services.vectorstore import Chunk
|
| 40 |
+
|
| 41 |
+
log = get_logger(__name__)
|
| 42 |
+
|
| 43 |
+
_MAX_CHARS = 16000 # how much of the document we feed the model
|
| 44 |
+
_MAX_RECORDS = 200 # safety cap on returned records
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
_SYSTEM = """You are an expert structured-data extraction engine. You convert ANY \
|
| 48 |
+
document — regardless of its layout, formatting, or field-label conventions — into \
|
| 49 |
+
clean, consistent structured data. You handle invoices, contracts, resumes, forms, \
|
| 50 |
+
reports, rosters, statements, emails, scanned pages and anything else.
|
| 51 |
+
|
| 52 |
+
STEP 1 — Determine the document LAYOUT:
|
| 53 |
+
- "single_record": the whole document describes ONE entity (one invoice, one
|
| 54 |
+
contract, one resume, one letter). Put everything in `fields`.
|
| 55 |
+
- "multi_record": the document lists MANY similar entities (an employee roster,
|
| 56 |
+
a list of transactions, a table of people/items). Put EACH entity as one object
|
| 57 |
+
in `records`; put any shared header info in `fields`.
|
| 58 |
+
- "mixed": one primary record plus a repeated sub-list (e.g. an invoice header
|
| 59 |
+
with line items). Use `fields` for the header and `records` for the repeated list.
|
| 60 |
+
|
| 61 |
+
STEP 2 — Extract, following these RULES exactly:
|
| 62 |
+
1. CANONICAL KEYS: normalise inconsistent labels to ONE consistent snake_case key.
|
| 63 |
+
Across records the SAME concept MUST use the SAME key even when the source
|
| 64 |
+
labels differ. e.g. "Staff Number"/"Personnel ID"/"ID" -> "staff_id";
|
| 65 |
+
"Date Joined"/"Joining Date"/"DOJ" -> "date_of_joining";
|
| 66 |
+
"Job Title"/"Position"/"Role" -> "job_title".
|
| 67 |
+
2. KEEP DISTINCT CONCEPTS SEPARATE: if the source distinguishes e.g. BASIC salary
|
| 68 |
+
from total CTC, keep BOTH keys (e.g. basic_salary AND ctc). Never merge
|
| 69 |
+
different things into one key.
|
| 70 |
+
3. CONSISTENT COLUMNS: every record object should use the same set of keys. If a
|
| 71 |
+
record genuinely lacks a value, set that key to null — do NOT omit the key.
|
| 72 |
+
4. NORMALISE VALUES: dates -> ISO "YYYY-MM-DD" when the full date is known; use
|
| 73 |
+
"YYYY-MM" if only month+year is known, "YYYY" if only the year. Keep currency
|
| 74 |
+
symbols and amounts as written (e.g. "₹24,00,000"). Strip surrounding labels.
|
| 75 |
+
5. GROUNDING: extract ONLY values present in the document body. NEVER use file
|
| 76 |
+
metadata, document properties, the author of the file, or outside knowledge.
|
| 77 |
+
If a value is absent, missing, masked or illegible, use null. Never invent.
|
| 78 |
+
6. record_keys: list the canonical record keys in a sensible order (identifier first).
|
| 79 |
+
7. Also capture genuine TABLES verbatim and the most salient ENTITIES
|
| 80 |
+
(types: person, org, date, money, email, phone, address, id).
|
| 81 |
+
8. confidence (0..1) reflects how certain you are about a value.
|
| 82 |
+
|
| 83 |
+
Return ONLY a JSON object with EXACTLY this shape:
|
| 84 |
+
{
|
| 85 |
+
"layout": "single_record" | "multi_record" | "mixed",
|
| 86 |
+
"record_type": string | null, // singular noun, e.g. "employee", "line_item"
|
| 87 |
+
"fields": [{"name": str, "value": any, "confidence": number}],
|
| 88 |
+
"record_keys": [str], // canonical column order for records
|
| 89 |
+
"records": [ { "<key>": any, ... } ], // one object per repeated entity
|
| 90 |
+
"tables": [{"title": str, "columns": [str], "rows": [[any]]}],
|
| 91 |
+
"entities": [{"type": str, "value": str, "confidence": number}]
|
| 92 |
+
}"""
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
_KNOWN_HINTS = {"invoice", "contract", "receipt", "resume"}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _context(doc_type: str | None, schema_hint: str | None) -> str:
|
| 99 |
+
"""A neutral one-line context note. Deliberately carries NO field schema —
|
| 100 |
+
the model must derive the schema from the document's own content so the
|
| 101 |
+
agent works on any type, including unseen ones."""
|
| 102 |
+
# An explicit, user-chosen framing (optional). Never auto-applied.
|
| 103 |
+
if schema_hint and schema_hint.lower() in _KNOWN_HINTS:
|
| 104 |
+
return (
|
| 105 |
+
f"The user asked you to extract this as a '{schema_hint.lower()}'. Use that "
|
| 106 |
+
"as a lens, but still derive the actual fields/records from the content."
|
| 107 |
+
)
|
| 108 |
+
if doc_type and doc_type.lower() not in ("other", "unknown", ""):
|
| 109 |
+
return (
|
| 110 |
+
f"A classifier loosely tagged this as '{doc_type.lower()}'. Treat that as a "
|
| 111 |
+
"weak hint only — do NOT assume any fixed schema; derive the fields and "
|
| 112 |
+
"records from the actual content of THIS document."
|
| 113 |
+
)
|
| 114 |
+
return (
|
| 115 |
+
"Derive the most useful fields and/or records directly from the document's "
|
| 116 |
+
"own content. Do not assume any fixed schema."
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _attach_citation(value, chunks: list[Chunk]) -> Citation | None:
|
| 121 |
+
"""Best-effort: find the chunk whose text contains the extracted value.
|
| 122 |
+
|
| 123 |
+
Only returns a citation when the value text genuinely appears in the source,
|
| 124 |
+
so normalised/metadata-derived values are never given a misleading citation.
|
| 125 |
+
"""
|
| 126 |
+
if value is None:
|
| 127 |
+
return None
|
| 128 |
+
needle = str(value).strip().lower()
|
| 129 |
+
if len(needle) < 3:
|
| 130 |
+
return None
|
| 131 |
+
for c in chunks:
|
| 132 |
+
if needle in c.text.lower():
|
| 133 |
+
bbox = BBox(page=c.page, **c.bbox) if c.bbox else None
|
| 134 |
+
return Citation(text=c.text[:200], page=c.page, bbox=bbox, chunk_id=c.id)
|
| 135 |
+
return None
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _field(name, value, confidence, chunks) -> ExtractedField:
|
| 139 |
+
return ExtractedField(
|
| 140 |
+
name=str(name),
|
| 141 |
+
value=value,
|
| 142 |
+
confidence=max(0.0, min(1.0, float(confidence or 0.0))),
|
| 143 |
+
citation=_attach_citation(value, chunks),
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _parse_fields(raw, chunks) -> list[ExtractedField]:
|
| 148 |
+
out: list[ExtractedField] = []
|
| 149 |
+
for f in raw or []:
|
| 150 |
+
if not isinstance(f, dict):
|
| 151 |
+
continue
|
| 152 |
+
out.append(_field(f.get("name", ""), f.get("value"),
|
| 153 |
+
f.get("confidence", 0.6), chunks))
|
| 154 |
+
return out
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _parse_records(raw_records, record_keys, chunks) -> tuple[list[ExtractedRecord], list[str]]:
|
| 158 |
+
records: list[ExtractedRecord] = []
|
| 159 |
+
keys: list[str] = [str(k) for k in (record_keys or [])]
|
| 160 |
+
seen = set(keys)
|
| 161 |
+
|
| 162 |
+
for rec in (raw_records or [])[:_MAX_RECORDS]:
|
| 163 |
+
if not isinstance(rec, dict):
|
| 164 |
+
continue
|
| 165 |
+
# Discover any keys the model used but didn't list, preserving order.
|
| 166 |
+
for k in rec.keys():
|
| 167 |
+
if k not in seen:
|
| 168 |
+
keys.append(str(k))
|
| 169 |
+
seen.add(k)
|
| 170 |
+
# Per-record confidence, if the model provided one; else a sensible default.
|
| 171 |
+
rec_conf = rec.get("_confidence")
|
| 172 |
+
fields = [
|
| 173 |
+
_field(k, rec.get(k),
|
| 174 |
+
rec_conf if rec_conf is not None else (0.85 if rec.get(k) not in (None, "") else 0.0),
|
| 175 |
+
chunks)
|
| 176 |
+
for k in keys
|
| 177 |
+
if k != "_confidence"
|
| 178 |
+
]
|
| 179 |
+
records.append(ExtractedRecord(fields=fields))
|
| 180 |
+
|
| 181 |
+
keys = [k for k in keys if k != "_confidence"]
|
| 182 |
+
return records, keys
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _parse_tables(raw) -> list[ExtractedTable]:
|
| 186 |
+
tables: list[ExtractedTable] = []
|
| 187 |
+
for t in raw or []:
|
| 188 |
+
if not isinstance(t, dict):
|
| 189 |
+
continue
|
| 190 |
+
tables.append(ExtractedTable(
|
| 191 |
+
title=t.get("title"),
|
| 192 |
+
columns=[str(c) for c in (t.get("columns") or [])],
|
| 193 |
+
rows=[[c for c in row] for row in (t.get("rows") or []) if isinstance(row, list)],
|
| 194 |
+
))
|
| 195 |
+
return tables
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _parse_entities(raw) -> list[Entity]:
|
| 199 |
+
out: list[Entity] = []
|
| 200 |
+
for e in raw or []:
|
| 201 |
+
if not isinstance(e, dict):
|
| 202 |
+
continue
|
| 203 |
+
val = e.get("value")
|
| 204 |
+
if val in (None, ""):
|
| 205 |
+
continue
|
| 206 |
+
out.append(Entity(
|
| 207 |
+
type=str(e.get("type", "")),
|
| 208 |
+
value=str(val),
|
| 209 |
+
confidence=max(0.0, min(1.0, float(e.get("confidence", 0.6) or 0.6))),
|
| 210 |
+
))
|
| 211 |
+
return out
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def _coerce_layout(raw) -> ExtractionLayout:
|
| 215 |
+
try:
|
| 216 |
+
return ExtractionLayout(str(raw).strip().lower())
|
| 217 |
+
except (ValueError, AttributeError):
|
| 218 |
+
return ExtractionLayout.single_record
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
async def extract(
|
| 222 |
+
markdown: str,
|
| 223 |
+
doc_type: str | None,
|
| 224 |
+
chunks: list[Chunk],
|
| 225 |
+
provider: str | None = None,
|
| 226 |
+
schema_hint: str | None = None,
|
| 227 |
+
) -> ExtractionResult:
|
| 228 |
+
llm = get_provider(provider)
|
| 229 |
+
excerpt = markdown[:_MAX_CHARS] if markdown else "(empty)"
|
| 230 |
+
|
| 231 |
+
msgs = [
|
| 232 |
+
LLMMessage(role="system", content=_SYSTEM),
|
| 233 |
+
LLMMessage(role="user",
|
| 234 |
+
content=f"{_context(doc_type, schema_hint)}\n\nDocument content:\n\n{excerpt}"),
|
| 235 |
+
]
|
| 236 |
+
try:
|
| 237 |
+
data = await llm.complete_json(msgs)
|
| 238 |
+
except Exception as e:
|
| 239 |
+
log.warning("extraction failed: %s", e)
|
| 240 |
+
return ExtractionResult(schema_name="generic")
|
| 241 |
+
|
| 242 |
+
if not isinstance(data, dict):
|
| 243 |
+
log.warning("extraction returned non-object: %r", type(data))
|
| 244 |
+
return ExtractionResult(schema_name="generic")
|
| 245 |
+
|
| 246 |
+
layout = _coerce_layout(data.get("layout"))
|
| 247 |
+
fields = _parse_fields(data.get("fields"), chunks)
|
| 248 |
+
records, record_keys = _parse_records(data.get("records"), data.get("record_keys"), chunks)
|
| 249 |
+
tables = _parse_tables(data.get("tables"))
|
| 250 |
+
entities = _parse_entities(data.get("entities"))
|
| 251 |
+
|
| 252 |
+
# If the model returned records but mislabelled layout, correct it.
|
| 253 |
+
if records and layout == ExtractionLayout.single_record:
|
| 254 |
+
layout = ExtractionLayout.multi_record
|
| 255 |
+
|
| 256 |
+
# The schema name is the model's own description of what it found — not a
|
| 257 |
+
# template we imposed. Falls back to the classifier label, then "generic".
|
| 258 |
+
record_type = data.get("record_type")
|
| 259 |
+
schema_name = (
|
| 260 |
+
str(record_type) if record_type
|
| 261 |
+
else (doc_type.lower() if doc_type and doc_type.lower() not in ("other", "unknown", "") else "generic")
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
return ExtractionResult(
|
| 265 |
+
schema_name=schema_name,
|
| 266 |
+
layout=layout,
|
| 267 |
+
record_type=str(record_type) if record_type else None,
|
| 268 |
+
fields=fields,
|
| 269 |
+
records=records,
|
| 270 |
+
record_keys=record_keys,
|
| 271 |
+
tables=tables,
|
| 272 |
+
entities=entities,
|
| 273 |
+
)
|
backend/app/services/ingestion.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Document ingestion — the extraction backbone (Docling).
|
| 2 |
+
|
| 3 |
+
Responsibilities:
|
| 4 |
+
* Convert an uploaded file into a DoclingDocument (layout, tables, OCR).
|
| 5 |
+
* Export Markdown (used for summarization / classification / display).
|
| 6 |
+
* Render page images (for the viewer + vision fallback).
|
| 7 |
+
* Produce text chunks that carry page + normalised bbox provenance, so
|
| 8 |
+
RAG answers and extracted fields can be highlighted on the page.
|
| 9 |
+
|
| 10 |
+
Everything Docling-specific lives here. The rest of the app consumes the
|
| 11 |
+
neutral results (markdown, pages, chunks).
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
import uuid
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Optional
|
| 20 |
+
|
| 21 |
+
# Windows: normal user accounts can't create symlinks (needs Developer Mode or
|
| 22 |
+
# admin), so HuggingFace's symlink-based model cache fails with WinError 1314.
|
| 23 |
+
# This huggingface_hub version's symlink-support probe is unreliable here, so it
|
| 24 |
+
# still calls os.symlink and hard-crashes instead of falling back to a file copy.
|
| 25 |
+
# We force the copy path by making are_symlinks_supported() return False, and
|
| 26 |
+
# silence the now-irrelevant warning. Must run before any `docling` import below.
|
| 27 |
+
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
|
| 28 |
+
if os.name == "nt":
|
| 29 |
+
try:
|
| 30 |
+
import huggingface_hub.file_download as _hf_dl
|
| 31 |
+
|
| 32 |
+
_hf_dl.are_symlinks_supported = lambda *a, **k: False # copy, never symlink
|
| 33 |
+
except Exception: # pragma: no cover - HF not importable yet is non-fatal
|
| 34 |
+
pass
|
| 35 |
+
|
| 36 |
+
from app.core.config import settings
|
| 37 |
+
from app.core.logging import get_logger
|
| 38 |
+
from app.schemas.documents import PageInfo
|
| 39 |
+
from app.services.vectorstore import Chunk
|
| 40 |
+
|
| 41 |
+
log = get_logger(__name__)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class IngestResult:
|
| 46 |
+
markdown: str
|
| 47 |
+
pages: list[PageInfo]
|
| 48 |
+
chunks: list[Chunk]
|
| 49 |
+
num_pages: int
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# --------------------------------------------------------------------------
|
| 53 |
+
# Docling conversion
|
| 54 |
+
# --------------------------------------------------------------------------
|
| 55 |
+
def _converter():
|
| 56 |
+
from docling.document_converter import DocumentConverter, PdfFormatOption
|
| 57 |
+
from docling.datamodel.base_models import InputFormat
|
| 58 |
+
from docling.datamodel.pipeline_options import PdfPipelineOptions
|
| 59 |
+
|
| 60 |
+
opts = PdfPipelineOptions()
|
| 61 |
+
opts.do_ocr = settings.enable_ocr
|
| 62 |
+
opts.do_table_structure = True
|
| 63 |
+
opts.generate_page_images = True
|
| 64 |
+
opts.images_scale = 2.0
|
| 65 |
+
return DocumentConverter(
|
| 66 |
+
format_options={InputFormat.PDF: PdfFormatOption(pipeline_options=opts)}
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _normalise_bbox(bbox, page_w: float, page_h: float) -> Optional[dict]:
|
| 71 |
+
"""Convert a Docling bbox to normalised top-left-origin coords (0..1)."""
|
| 72 |
+
if bbox is None or not page_w or not page_h:
|
| 73 |
+
return None
|
| 74 |
+
try:
|
| 75 |
+
l, t, r, b = bbox.l, bbox.t, bbox.r, bbox.b
|
| 76 |
+
# Docling PDF coords are bottom-left origin; flip to top-left.
|
| 77 |
+
origin = getattr(getattr(bbox, "coord_origin", None), "value", "")
|
| 78 |
+
if str(origin).upper().startswith("BOTTOM"):
|
| 79 |
+
t, b = page_h - t, page_h - b
|
| 80 |
+
y0, y1 = sorted((t, b))
|
| 81 |
+
x0, x1 = sorted((l, r))
|
| 82 |
+
return {
|
| 83 |
+
"x0": max(0.0, x0 / page_w),
|
| 84 |
+
"y0": max(0.0, y0 / page_h),
|
| 85 |
+
"x1": min(1.0, x1 / page_w),
|
| 86 |
+
"y1": min(1.0, y1 / page_h),
|
| 87 |
+
}
|
| 88 |
+
except Exception:
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _extract_chunks(doc, doc_id: str) -> list[Chunk]:
|
| 93 |
+
"""Build provenance-aware chunks from DoclingDocument text items.
|
| 94 |
+
|
| 95 |
+
We group consecutive text items per page into reasonably sized chunks so
|
| 96 |
+
embeddings have enough context, while keeping the bbox of the first item
|
| 97 |
+
as the citation anchor.
|
| 98 |
+
"""
|
| 99 |
+
chunks: list[Chunk] = []
|
| 100 |
+
page_sizes: dict[int, tuple[float, float]] = {}
|
| 101 |
+
try:
|
| 102 |
+
for pno, page in doc.pages.items():
|
| 103 |
+
sz = getattr(page, "size", None)
|
| 104 |
+
if sz:
|
| 105 |
+
page_sizes[pno] = (sz.width, sz.height)
|
| 106 |
+
except Exception:
|
| 107 |
+
pass
|
| 108 |
+
|
| 109 |
+
buf_text: list[str] = []
|
| 110 |
+
buf_page = 0
|
| 111 |
+
buf_bbox = None
|
| 112 |
+
|
| 113 |
+
def flush():
|
| 114 |
+
nonlocal buf_text, buf_bbox, buf_page
|
| 115 |
+
if buf_text:
|
| 116 |
+
text = " ".join(buf_text).strip()
|
| 117 |
+
if text:
|
| 118 |
+
chunks.append(Chunk(
|
| 119 |
+
id=f"{doc_id}-{len(chunks)}",
|
| 120 |
+
text=text, page=buf_page, doc_id=doc_id, bbox=buf_bbox,
|
| 121 |
+
))
|
| 122 |
+
buf_text, buf_bbox = [], None
|
| 123 |
+
|
| 124 |
+
try:
|
| 125 |
+
for item in getattr(doc, "texts", []):
|
| 126 |
+
text = getattr(item, "text", "") or ""
|
| 127 |
+
if not text.strip():
|
| 128 |
+
continue
|
| 129 |
+
prov = getattr(item, "prov", None)
|
| 130 |
+
page_no = 0
|
| 131 |
+
bbox = None
|
| 132 |
+
if prov:
|
| 133 |
+
page_no = getattr(prov[0], "page_no", 1) - 1
|
| 134 |
+
pw, ph = page_sizes.get(page_no + 1, (0, 0))
|
| 135 |
+
bbox = _normalise_bbox(getattr(prov[0], "bbox", None), pw, ph)
|
| 136 |
+
if buf_text and (page_no != buf_page or len(" ".join(buf_text)) > 900):
|
| 137 |
+
flush()
|
| 138 |
+
if not buf_text:
|
| 139 |
+
buf_page, buf_bbox = page_no, bbox
|
| 140 |
+
buf_text.append(text)
|
| 141 |
+
flush()
|
| 142 |
+
except Exception as e: # pragma: no cover
|
| 143 |
+
log.warning("chunk extraction fell back: %s", e)
|
| 144 |
+
|
| 145 |
+
return chunks
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _fallback_chunks(markdown: str, doc_id: str) -> list[Chunk]:
|
| 149 |
+
"""Split markdown into ~800-char chunks when provenance isn't available."""
|
| 150 |
+
words = markdown.split()
|
| 151 |
+
chunks, buf = [], []
|
| 152 |
+
for w in words:
|
| 153 |
+
buf.append(w)
|
| 154 |
+
if len(" ".join(buf)) > 800:
|
| 155 |
+
chunks.append(Chunk(id=f"{doc_id}-{len(chunks)}",
|
| 156 |
+
text=" ".join(buf), page=0, doc_id=doc_id))
|
| 157 |
+
buf = []
|
| 158 |
+
if buf:
|
| 159 |
+
chunks.append(Chunk(id=f"{doc_id}-{len(chunks)}",
|
| 160 |
+
text=" ".join(buf), page=0, doc_id=doc_id))
|
| 161 |
+
return chunks
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# --------------------------------------------------------------------------
|
| 165 |
+
# Page rendering (for the viewer)
|
| 166 |
+
# --------------------------------------------------------------------------
|
| 167 |
+
def _render_pages(doc, src: Path, doc_id: str) -> list[PageInfo]:
|
| 168 |
+
out_dir = settings.renders_path / doc_id
|
| 169 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 170 |
+
pages: list[PageInfo] = []
|
| 171 |
+
|
| 172 |
+
# Preferred: Docling-generated page images
|
| 173 |
+
try:
|
| 174 |
+
for pno, page in sorted(doc.pages.items()):
|
| 175 |
+
img = getattr(page, "image", None)
|
| 176 |
+
pil = getattr(img, "pil_image", None) if img else None
|
| 177 |
+
if pil is None:
|
| 178 |
+
continue
|
| 179 |
+
fn = out_dir / f"page-{pno-1}.png"
|
| 180 |
+
pil.save(fn)
|
| 181 |
+
pages.append(PageInfo(
|
| 182 |
+
page=pno - 1, width=pil.width, height=pil.height,
|
| 183 |
+
image_url=f"/api/documents/{doc_id}/pages/{pno-1}.png",
|
| 184 |
+
))
|
| 185 |
+
if pages:
|
| 186 |
+
return pages
|
| 187 |
+
except Exception as e:
|
| 188 |
+
log.warning("docling page render failed (%s); trying pdfium", e)
|
| 189 |
+
|
| 190 |
+
# Fallback: render the PDF with pypdfium2 (a Docling dependency)
|
| 191 |
+
try:
|
| 192 |
+
import pypdfium2 as pdfium
|
| 193 |
+
|
| 194 |
+
pdf = pdfium.PdfDocument(str(src))
|
| 195 |
+
for i in range(len(pdf)):
|
| 196 |
+
bitmap = pdf[i].render(scale=2.0)
|
| 197 |
+
pil = bitmap.to_pil()
|
| 198 |
+
fn = out_dir / f"page-{i}.png"
|
| 199 |
+
pil.save(fn)
|
| 200 |
+
pages.append(PageInfo(
|
| 201 |
+
page=i, width=pil.width, height=pil.height,
|
| 202 |
+
image_url=f"/api/documents/{doc_id}/pages/{i}.png",
|
| 203 |
+
))
|
| 204 |
+
except Exception as e:
|
| 205 |
+
log.warning("pdfium render failed (%s); trying raw image", e)
|
| 206 |
+
|
| 207 |
+
# Last resort: the upload is itself an image
|
| 208 |
+
if not pages and src.suffix.lower() in {".png", ".jpg", ".jpeg", ".tiff", ".bmp"}:
|
| 209 |
+
try:
|
| 210 |
+
from PIL import Image
|
| 211 |
+
|
| 212 |
+
pil = Image.open(src).convert("RGB")
|
| 213 |
+
fn = out_dir / "page-0.png"
|
| 214 |
+
pil.save(fn)
|
| 215 |
+
pages.append(PageInfo(page=0, width=pil.width, height=pil.height,
|
| 216 |
+
image_url=f"/api/documents/{doc_id}/pages/0.png"))
|
| 217 |
+
except Exception:
|
| 218 |
+
pass
|
| 219 |
+
|
| 220 |
+
return pages
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
# --------------------------------------------------------------------------
|
| 224 |
+
# Public entry point
|
| 225 |
+
# --------------------------------------------------------------------------
|
| 226 |
+
def ingest(path: Path, doc_id: str) -> IngestResult:
|
| 227 |
+
"""Synchronous (CPU-bound) ingestion; call via run_in_threadpool."""
|
| 228 |
+
conv = _converter()
|
| 229 |
+
result = conv.convert(str(path))
|
| 230 |
+
doc = result.document
|
| 231 |
+
|
| 232 |
+
try:
|
| 233 |
+
markdown = doc.export_to_markdown()
|
| 234 |
+
except Exception:
|
| 235 |
+
markdown = ""
|
| 236 |
+
|
| 237 |
+
pages = _render_pages(doc, path, doc_id)
|
| 238 |
+
chunks = _extract_chunks(doc, doc_id)
|
| 239 |
+
if not chunks:
|
| 240 |
+
chunks = _fallback_chunks(markdown, doc_id)
|
| 241 |
+
|
| 242 |
+
num_pages = len(pages) or (max((c.page for c in chunks), default=0) + 1)
|
| 243 |
+
return IngestResult(markdown=markdown, pages=pages, chunks=chunks, num_pages=num_pages)
|
backend/app/services/pipeline.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""End-to-end processing pipeline for a freshly uploaded document.
|
| 2 |
+
|
| 3 |
+
Runs asynchronously after upload:
|
| 4 |
+
ingest (Docling) → index chunks (RAG) → classify → extract → summarize
|
| 5 |
+
→ detect anomalies → persist.
|
| 6 |
+
|
| 7 |
+
State is written back to storage at each milestone so the frontend can poll
|
| 8 |
+
status and progressively render results.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
from fastapi.concurrency import run_in_threadpool
|
| 15 |
+
|
| 16 |
+
from app.core.logging import get_logger
|
| 17 |
+
from app.schemas.documents import DocStatus, DocumentDetail
|
| 18 |
+
from app.services import (
|
| 19 |
+
anomaly,
|
| 20 |
+
classification,
|
| 21 |
+
extraction,
|
| 22 |
+
ingestion,
|
| 23 |
+
storage,
|
| 24 |
+
summary,
|
| 25 |
+
vectorstore,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
log = get_logger(__name__)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
async def process_document(doc_id: str, path: Path, provider: str | None = None) -> None:
|
| 32 |
+
detail = storage.get(doc_id)
|
| 33 |
+
if detail is None:
|
| 34 |
+
log.error("process_document: %s not found", doc_id)
|
| 35 |
+
return
|
| 36 |
+
|
| 37 |
+
# --- Stage 1: ingestion is the only stage that can hard-fail the doc.
|
| 38 |
+
try:
|
| 39 |
+
detail.status = DocStatus.processing
|
| 40 |
+
storage.save(detail)
|
| 41 |
+
|
| 42 |
+
result = await run_in_threadpool(ingestion.ingest, path, doc_id)
|
| 43 |
+
detail.markdown = result.markdown
|
| 44 |
+
detail.pages = result.pages
|
| 45 |
+
detail.num_pages = result.num_pages
|
| 46 |
+
storage.save(detail)
|
| 47 |
+
except Exception as e: # pragma: no cover
|
| 48 |
+
log.exception("Ingestion failed for %s", doc_id)
|
| 49 |
+
detail = storage.get(doc_id) or detail
|
| 50 |
+
detail.status = DocStatus.failed
|
| 51 |
+
detail.error = f"Document parsing failed: {e}"
|
| 52 |
+
storage.save(detail)
|
| 53 |
+
return
|
| 54 |
+
|
| 55 |
+
# --- Stages 2-6: best-effort. A failure here (e.g. no LLM key) degrades
|
| 56 |
+
# gracefully — the document still becomes 'ready' with the viewer working.
|
| 57 |
+
notes: list[str] = []
|
| 58 |
+
|
| 59 |
+
async def step(name: str, coro):
|
| 60 |
+
try:
|
| 61 |
+
return await coro
|
| 62 |
+
except Exception as e:
|
| 63 |
+
log.warning("Stage '%s' skipped for %s: %s", name, doc_id, e)
|
| 64 |
+
notes.append(f"{name}: {e}")
|
| 65 |
+
return None
|
| 66 |
+
|
| 67 |
+
await step("index", vectorstore.index_chunks(doc_id, result.chunks))
|
| 68 |
+
|
| 69 |
+
detail.classification = await step(
|
| 70 |
+
"classify", classification.classify(result.markdown, provider)
|
| 71 |
+
)
|
| 72 |
+
storage.save(detail)
|
| 73 |
+
|
| 74 |
+
doc_type = detail.classification.doc_type if detail.classification else None
|
| 75 |
+
detail.extraction = await step(
|
| 76 |
+
"extract", extraction.extract(result.markdown, doc_type, result.chunks, provider)
|
| 77 |
+
)
|
| 78 |
+
storage.save(detail)
|
| 79 |
+
|
| 80 |
+
detail.summary = await step("summarize", summary.summarize(result.markdown, provider))
|
| 81 |
+
storage.save(detail)
|
| 82 |
+
|
| 83 |
+
if detail.extraction:
|
| 84 |
+
detail.anomalies = (
|
| 85 |
+
await step("anomalies",
|
| 86 |
+
anomaly.detect(result.markdown, detail.extraction, doc_type, provider))
|
| 87 |
+
or []
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
detail.status = DocStatus.ready
|
| 91 |
+
detail.error = (
|
| 92 |
+
"Some AI steps were skipped (is GEMINI_API_KEY set?): " + "; ".join(notes)
|
| 93 |
+
if notes else None
|
| 94 |
+
)
|
| 95 |
+
storage.save(detail)
|
| 96 |
+
log.info("Document %s processed (%s, %d pages)%s", doc_id, doc_type,
|
| 97 |
+
detail.num_pages, " [with skipped steps]" if notes else "")
|
backend/app/services/qa.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Conversational Q&A over a document — agentic RAG with citations.
|
| 2 |
+
|
| 3 |
+
Retrieves the most relevant provenance-carrying chunks, then asks the LLM to
|
| 4 |
+
answer grounded in them, returning both the answer text and the citations the
|
| 5 |
+
UI can highlight on the page.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
|
| 11 |
+
from app.core.logging import get_logger
|
| 12 |
+
from app.llm.base import LLMMessage
|
| 13 |
+
from app.llm.registry import get_provider
|
| 14 |
+
from app.schemas.documents import BBox, Citation
|
| 15 |
+
from app.services import vectorstore
|
| 16 |
+
|
| 17 |
+
log = get_logger(__name__)
|
| 18 |
+
|
| 19 |
+
_SYS = (
|
| 20 |
+
"You answer questions about a specific document using ONLY the provided "
|
| 21 |
+
"context passages. Cite evidence by referring to passage numbers like [1], "
|
| 22 |
+
"[2]. If the answer is not in the context, say so honestly. Be concise."
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class QAResult:
|
| 28 |
+
answer: str
|
| 29 |
+
citations: list[Citation]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _context_block(chunks) -> str:
|
| 33 |
+
return "\n\n".join(
|
| 34 |
+
f"[{i+1}] (page {c.page + 1}) {c.text}" for i, c in enumerate(chunks)
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
async def answer(
|
| 39 |
+
doc_id: str, question: str, k: int = 5, provider: str | None = None
|
| 40 |
+
) -> QAResult:
|
| 41 |
+
chunks = await vectorstore.query(doc_id, question, k=k)
|
| 42 |
+
if not chunks:
|
| 43 |
+
return QAResult(
|
| 44 |
+
answer="I couldn't find anything relevant in this document to answer that.",
|
| 45 |
+
citations=[],
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
llm = get_provider(provider)
|
| 49 |
+
msgs = [
|
| 50 |
+
LLMMessage(role="system", content=_SYS),
|
| 51 |
+
LLMMessage(role="user",
|
| 52 |
+
content=f"Context:\n{_context_block(chunks)}\n\nQuestion: {question}"),
|
| 53 |
+
]
|
| 54 |
+
text = await llm.complete(msgs, temperature=0.2)
|
| 55 |
+
|
| 56 |
+
citations = [
|
| 57 |
+
Citation(
|
| 58 |
+
text=c.text[:240], page=c.page, chunk_id=c.id,
|
| 59 |
+
bbox=BBox(page=c.page, **c.bbox) if c.bbox else None,
|
| 60 |
+
)
|
| 61 |
+
for c in chunks
|
| 62 |
+
]
|
| 63 |
+
return QAResult(answer=text, citations=citations)
|
backend/app/services/storage.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Document persistence.
|
| 2 |
+
|
| 3 |
+
A small SQLite-backed repository that stores each document's full
|
| 4 |
+
`DocumentDetail` as JSON. Deliberately simple (no ORM) so the project runs
|
| 5 |
+
with zero external services; the access is funnelled through this module so
|
| 6 |
+
swapping to Postgres later touches only this file.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import sqlite3
|
| 12 |
+
import threading
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
from app.core.config import settings
|
| 16 |
+
from app.core.logging import get_logger
|
| 17 |
+
from app.schemas.documents import DocumentDetail, DocumentMeta
|
| 18 |
+
|
| 19 |
+
log = get_logger(__name__)
|
| 20 |
+
|
| 21 |
+
_lock = threading.Lock()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _conn() -> sqlite3.Connection:
|
| 25 |
+
conn = sqlite3.connect(settings.db_path, check_same_thread=False)
|
| 26 |
+
conn.row_factory = sqlite3.Row
|
| 27 |
+
return conn
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def init_db() -> None:
|
| 31 |
+
with _lock, _conn() as conn:
|
| 32 |
+
conn.execute(
|
| 33 |
+
"""
|
| 34 |
+
CREATE TABLE IF NOT EXISTS documents (
|
| 35 |
+
id TEXT PRIMARY KEY,
|
| 36 |
+
filename TEXT,
|
| 37 |
+
status TEXT,
|
| 38 |
+
created_at TEXT,
|
| 39 |
+
data TEXT
|
| 40 |
+
)
|
| 41 |
+
"""
|
| 42 |
+
)
|
| 43 |
+
log.info("SQLite initialised at %s", settings.db_path)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def save(detail: DocumentDetail) -> None:
|
| 47 |
+
with _lock, _conn() as conn:
|
| 48 |
+
conn.execute(
|
| 49 |
+
"REPLACE INTO documents (id, filename, status, created_at, data) "
|
| 50 |
+
"VALUES (?, ?, ?, ?, ?)",
|
| 51 |
+
(
|
| 52 |
+
detail.id,
|
| 53 |
+
detail.filename,
|
| 54 |
+
detail.status.value,
|
| 55 |
+
detail.created_at,
|
| 56 |
+
detail.model_dump_json(),
|
| 57 |
+
),
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def get(doc_id: str) -> Optional[DocumentDetail]:
|
| 62 |
+
with _lock, _conn() as conn:
|
| 63 |
+
row = conn.execute(
|
| 64 |
+
"SELECT data FROM documents WHERE id = ?", (doc_id,)
|
| 65 |
+
).fetchone()
|
| 66 |
+
if not row:
|
| 67 |
+
return None
|
| 68 |
+
return DocumentDetail.model_validate_json(row["data"])
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def list_all() -> list[DocumentMeta]:
|
| 72 |
+
with _lock, _conn() as conn:
|
| 73 |
+
rows = conn.execute(
|
| 74 |
+
"SELECT data FROM documents ORDER BY created_at DESC"
|
| 75 |
+
).fetchall()
|
| 76 |
+
out: list[DocumentMeta] = []
|
| 77 |
+
for r in rows:
|
| 78 |
+
d = json.loads(r["data"])
|
| 79 |
+
out.append(DocumentMeta.model_validate(d))
|
| 80 |
+
return out
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def delete(doc_id: str) -> bool:
|
| 84 |
+
with _lock, _conn() as conn:
|
| 85 |
+
cur = conn.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
|
| 86 |
+
return cur.rowcount > 0
|
backend/app/services/summary.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Summarization — map-reduce over the document.
|
| 2 |
+
|
| 3 |
+
For short docs we summarize directly; for long ones we summarize chunks then
|
| 4 |
+
reduce, keeping each LLM call within context limits.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from app.core.logging import get_logger
|
| 9 |
+
from app.llm.base import LLMMessage
|
| 10 |
+
from app.llm.registry import get_provider
|
| 11 |
+
|
| 12 |
+
log = get_logger(__name__)
|
| 13 |
+
|
| 14 |
+
_MAP_SYS = "Summarize the following document section in 2-3 concise sentences."
|
| 15 |
+
_REDUCE_SYS = (
|
| 16 |
+
"You are a precise summarizer. Produce a clear, well-structured summary of the "
|
| 17 |
+
"document: a one-line TL;DR, then 3-6 key bullet points covering the most "
|
| 18 |
+
"important facts, figures, dates, parties, and actions. Use Markdown."
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _split(markdown: str, size: int = 9000) -> list[str]:
|
| 23 |
+
if len(markdown) <= size:
|
| 24 |
+
return [markdown]
|
| 25 |
+
words, parts, buf = markdown.split(), [], []
|
| 26 |
+
for w in words:
|
| 27 |
+
buf.append(w)
|
| 28 |
+
if len(" ".join(buf)) > size:
|
| 29 |
+
parts.append(" ".join(buf))
|
| 30 |
+
buf = []
|
| 31 |
+
if buf:
|
| 32 |
+
parts.append(" ".join(buf))
|
| 33 |
+
return parts
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
async def summarize(markdown: str, provider: str | None = None) -> str:
|
| 37 |
+
if not markdown.strip():
|
| 38 |
+
return "_The document appears to be empty._"
|
| 39 |
+
llm = get_provider(provider)
|
| 40 |
+
sections = _split(markdown)
|
| 41 |
+
|
| 42 |
+
if len(sections) == 1:
|
| 43 |
+
msgs = [
|
| 44 |
+
LLMMessage(role="system", content=_REDUCE_SYS),
|
| 45 |
+
LLMMessage(role="user", content=sections[0]),
|
| 46 |
+
]
|
| 47 |
+
return await llm.complete(msgs, temperature=0.3)
|
| 48 |
+
|
| 49 |
+
partials = []
|
| 50 |
+
for s in sections:
|
| 51 |
+
msgs = [LLMMessage(role="system", content=_MAP_SYS),
|
| 52 |
+
LLMMessage(role="user", content=s)]
|
| 53 |
+
partials.append(await llm.complete(msgs, temperature=0.2))
|
| 54 |
+
|
| 55 |
+
combined = "\n\n".join(partials)
|
| 56 |
+
msgs = [LLMMessage(role="system", content=_REDUCE_SYS),
|
| 57 |
+
LLMMessage(role="user", content=combined)]
|
| 58 |
+
return await llm.complete(msgs, temperature=0.3)
|
backend/app/services/vectorstore.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vector store for RAG — pure-Python, numpy cosine similarity.
|
| 2 |
+
|
| 3 |
+
Deliberately dependency-light: no native build (chroma-hnswlib needs a C++
|
| 4 |
+
compiler) and no external service. Each document's chunks + embeddings are
|
| 5 |
+
persisted to a single .npz/.json pair on disk and cached in memory.
|
| 6 |
+
|
| 7 |
+
Chunks carry page/bbox provenance so retrieval returns citations the UI can
|
| 8 |
+
highlight. To scale up, swap this module's body for pgvector or Chroma — the
|
| 9 |
+
public interface (index_chunks / query / delete / Chunk) stays the same.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
from dataclasses import dataclass, asdict
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Optional
|
| 17 |
+
|
| 18 |
+
from app.core.config import settings
|
| 19 |
+
from app.core.logging import get_logger
|
| 20 |
+
|
| 21 |
+
log = get_logger(__name__)
|
| 22 |
+
|
| 23 |
+
try:
|
| 24 |
+
import numpy as np
|
| 25 |
+
except Exception: # pragma: no cover
|
| 26 |
+
np = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class Chunk:
|
| 31 |
+
id: str
|
| 32 |
+
text: str
|
| 33 |
+
page: int
|
| 34 |
+
doc_id: str
|
| 35 |
+
bbox: Optional[dict] = None
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# in-memory cache: doc_id -> (matrix[N,d], list[Chunk])
|
| 39 |
+
_cache: dict[str, tuple] = {}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _store_dir() -> Path:
|
| 43 |
+
p = Path(settings.chroma_dir)
|
| 44 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 45 |
+
return p
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _paths(doc_id: str) -> tuple[Path, Path]:
|
| 49 |
+
d = _store_dir()
|
| 50 |
+
return d / f"{doc_id}.npy", d / f"{doc_id}.json"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _load(doc_id: str):
|
| 54 |
+
if doc_id in _cache:
|
| 55 |
+
return _cache[doc_id]
|
| 56 |
+
vec_path, meta_path = _paths(doc_id)
|
| 57 |
+
if not vec_path.exists() or not meta_path.exists():
|
| 58 |
+
return None
|
| 59 |
+
try:
|
| 60 |
+
mat = np.load(vec_path)
|
| 61 |
+
chunks = [Chunk(**c) for c in json.loads(meta_path.read_text("utf-8"))]
|
| 62 |
+
_cache[doc_id] = (mat, chunks)
|
| 63 |
+
return _cache[doc_id]
|
| 64 |
+
except Exception as e: # pragma: no cover
|
| 65 |
+
log.warning("vectorstore load failed for %s: %s", doc_id, e)
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _normalize(mat):
|
| 70 |
+
norms = np.linalg.norm(mat, axis=1, keepdims=True)
|
| 71 |
+
norms[norms == 0] = 1.0
|
| 72 |
+
return mat / norms
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def index_chunks(doc_id: str, chunks: list[Chunk]) -> None:
|
| 76 |
+
if not chunks:
|
| 77 |
+
return
|
| 78 |
+
if np is None:
|
| 79 |
+
raise RuntimeError("numpy is not installed")
|
| 80 |
+
from app.llm.registry import get_embedder
|
| 81 |
+
|
| 82 |
+
embedder = get_embedder()
|
| 83 |
+
vectors = await embedder.embed([c.text for c in chunks])
|
| 84 |
+
mat = np.asarray(vectors, dtype="float32")
|
| 85 |
+
|
| 86 |
+
vec_path, meta_path = _paths(doc_id)
|
| 87 |
+
np.save(vec_path, mat)
|
| 88 |
+
meta_path.write_text(
|
| 89 |
+
json.dumps([asdict(c) for c in chunks], ensure_ascii=False), "utf-8"
|
| 90 |
+
)
|
| 91 |
+
_cache[doc_id] = (mat, chunks)
|
| 92 |
+
log.info("Indexed %d chunks for doc %s (dim=%d)", len(chunks), doc_id,
|
| 93 |
+
mat.shape[1] if mat.ndim == 2 else 0)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
async def query(doc_id: str, question: str, k: int = 5) -> list[Chunk]:
|
| 97 |
+
loaded = _load(doc_id)
|
| 98 |
+
if not loaded:
|
| 99 |
+
return []
|
| 100 |
+
mat, chunks = loaded
|
| 101 |
+
if mat.size == 0 or not chunks:
|
| 102 |
+
return []
|
| 103 |
+
|
| 104 |
+
from app.llm.registry import get_embedder
|
| 105 |
+
|
| 106 |
+
embedder = get_embedder()
|
| 107 |
+
qvec = np.asarray((await embedder.embed([question]))[0], dtype="float32")
|
| 108 |
+
|
| 109 |
+
sims = _normalize(mat) @ (qvec / (np.linalg.norm(qvec) or 1.0))
|
| 110 |
+
top = np.argsort(-sims)[: min(k, len(chunks))]
|
| 111 |
+
return [chunks[int(i)] for i in top]
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def delete(doc_id: str) -> None:
|
| 115 |
+
_cache.pop(doc_id, None)
|
| 116 |
+
for p in _paths(doc_id):
|
| 117 |
+
try:
|
| 118 |
+
p.unlink(missing_ok=True)
|
| 119 |
+
except Exception:
|
| 120 |
+
pass
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Web framework ---
|
| 2 |
+
fastapi==0.115.6
|
| 3 |
+
uvicorn[standard]==0.34.0
|
| 4 |
+
python-multipart==0.0.20
|
| 5 |
+
pydantic==2.10.4
|
| 6 |
+
pydantic-settings==2.7.1
|
| 7 |
+
|
| 8 |
+
# --- LLM providers ---
|
| 9 |
+
google-generativeai==0.8.3
|
| 10 |
+
openai==1.59.6
|
| 11 |
+
|
| 12 |
+
# --- Document extraction ---
|
| 13 |
+
# Docling is the local, MIT-licensed extraction backbone (layout, tables, OCR).
|
| 14 |
+
docling==2.15.1
|
| 15 |
+
|
| 16 |
+
# --- RAG / vector store ---
|
| 17 |
+
# Pure-Python (numpy) cosine-similarity store — no native build, no external
|
| 18 |
+
# service. Swap for pgvector/Chroma in services/vectorstore.py to scale up.
|
| 19 |
+
numpy>=1.26,<3
|
| 20 |
+
|
| 21 |
+
# --- Export ---
|
| 22 |
+
openpyxl==3.1.5
|
| 23 |
+
|
| 24 |
+
# --- Utilities ---
|
| 25 |
+
httpx==0.28.1
|
| 26 |
+
tenacity==9.0.0
|
| 27 |
+
python-dotenv==1.0.1
|
| 28 |
+
# Deterministic date parsing for temporal anomaly checks (also a docling dep).
|
| 29 |
+
python-dateutil==2.9.0.post0
|
backend/scripts/prefetch_models.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Download the ML models at image-build time so the container is fully
|
| 2 |
+
self-contained: no first-request download, fast cold starts, works offline.
|
| 3 |
+
|
| 4 |
+
Run during `docker build`. Honours HF_HOME / EASYOCR_MODULE_PATH / TORCH_HOME
|
| 5 |
+
so the cache lands in a fixed, world-readable location the runtime user shares.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
|
| 11 |
+
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def fetch_docling() -> None:
|
| 15 |
+
# Layout + TableFormer models (ds4sd/docling-models).
|
| 16 |
+
from docling.pipeline.standard_pdf_pipeline import StandardPdfPipeline
|
| 17 |
+
|
| 18 |
+
path = StandardPdfPipeline.download_models_hf()
|
| 19 |
+
print(f"[prefetch] docling models -> {path}", flush=True)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def fetch_easyocr() -> None:
|
| 23 |
+
# EasyOCR detection + recognition models (used for scanned PDFs / images).
|
| 24 |
+
try:
|
| 25 |
+
import easyocr # noqa: F401
|
| 26 |
+
|
| 27 |
+
easyocr.Reader(["en"], gpu=False)
|
| 28 |
+
print("[prefetch] easyocr (en) models ready", flush=True)
|
| 29 |
+
except Exception as e: # OCR is optional; never fail the build over it.
|
| 30 |
+
print(f"[prefetch] easyocr skipped: {e}", flush=True)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
if __name__ == "__main__":
|
| 34 |
+
fetch_docling()
|
| 35 |
+
fetch_easyocr()
|
| 36 |
+
print("[prefetch] done", flush=True)
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Local production-like run of the backend (the frontend deploys to Vercel).
|
| 2 |
+
# Usage:
|
| 3 |
+
# docker compose up --build
|
| 4 |
+
# # backend on http://localhost:8000 ; data persists in the named volume.
|
| 5 |
+
services:
|
| 6 |
+
backend:
|
| 7 |
+
build:
|
| 8 |
+
context: ./backend
|
| 9 |
+
image: document-agent-backend
|
| 10 |
+
env_file:
|
| 11 |
+
- ./backend/.env # OPENAI_API_KEY / GEMINI_API_KEY etc.
|
| 12 |
+
environment:
|
| 13 |
+
DATA_DIR: /data
|
| 14 |
+
# Allow the local Next dev server (and the proxy) through CORS.
|
| 15 |
+
CORS_ORIGINS: "http://localhost:3000,http://localhost:3002"
|
| 16 |
+
ENABLE_OCR: "true"
|
| 17 |
+
ports:
|
| 18 |
+
- "8000:8000"
|
| 19 |
+
volumes:
|
| 20 |
+
- backend_data:/data
|
| 21 |
+
restart: unless-stopped
|
| 22 |
+
|
| 23 |
+
volumes:
|
| 24 |
+
backend_data:
|
docs/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Architecture — Document Processing AI Agent
|
| 2 |
+
|
| 3 |
+
**Decision record from research (see `RESEARCH.md`). Stack chosen: custom composed stack.**
|
| 4 |
+
|
| 5 |
+
## High-level
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
┌──────────────────────────────────────────────────────────────┐
|
| 9 |
+
│ FRONTEND (Next.js + React + TypeScript + Tailwind) │
|
| 10 |
+
│ 40% Chat │ 60% Workspace │
|
| 11 |
+
│ ──────── │ ── Tabs: Viewer · Fields · Classify · Summary · │
|
| 12 |
+
│ streaming │ Export ── │
|
| 13 |
+
│ agent │ Document viewer w/ bounding-box highlights │
|
| 14 |
+
└─────┬────────────────────────────────────────────────────────┘
|
| 15 |
+
│ REST + SSE (streaming)
|
| 16 |
+
┌─────▼────────────────────────────────────────────────────────┐
|
| 17 |
+
│ BACKEND (FastAPI, async) │
|
| 18 |
+
│ │
|
| 19 |
+
│ api/ routes: documents, chat(SSE), extract, │
|
| 20 |
+
│ classify, summary, export, health │
|
| 21 |
+
│ agent/ orchestrator (tool-calling loop) + prompts │
|
| 22 |
+
│ llm/ provider abstraction: Gemini(default)⇄OpenAI │
|
| 23 |
+
│ services/ ingestion(Docling) · extraction · classification│
|
| 24 |
+
│ · summary · qa(RAG) · anomaly · export · │
|
| 25 |
+
│ vectorstore · storage │
|
| 26 |
+
│ schemas/ Pydantic models (incl. invoice/contract) │
|
| 27 |
+
│ core/ config · logging · deps │
|
| 28 |
+
└─────┬───────────────────────────┬────────────────────────────┘
|
| 29 |
+
│ │
|
| 30 |
+
┌─────▼─────┐ ┌─────────▼──────────┐
|
| 31 |
+
│ Docling │ │ Storage │
|
| 32 |
+
│ (local │ │ • files (disk/S3) │
|
| 33 |
+
│ extract) │ │ • SQLite/Postgres │
|
| 34 |
+
└───────────┘ │ • vectors (chroma/│
|
| 35 |
+
│ pgvector) │
|
| 36 |
+
└────────────────────┘
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
## The 7 core functions → implementation
|
| 40 |
+
|
| 41 |
+
| Function | How | Tool exposed to agent |
|
| 42 |
+
|---|---|---|
|
| 43 |
+
| 1. Upload & ingest | `POST /documents` → Docling parse → DoclingDocument JSON + page images | `ingest_document` |
|
| 44 |
+
| 2. Parse & extract structured data | Docling structure + LLM structured-output against Pydantic schema (fields, tables, KV, entities) + confidence | `extract_fields` |
|
| 45 |
+
| 3. Classify | LLM zero-shot classification → type + confidence | `classify_document` |
|
| 46 |
+
| 4. Summarize | Map-reduce summarization over chunks | `summarize_document` |
|
| 47 |
+
| 5. Conversational Q&A | Agentic RAG: retrieve chunks → answer **with citations** (page + bbox) | `query_document` |
|
| 48 |
+
| 6. Flag anomalies | Rule + LLM checks: missing required fields, inconsistencies, low-confidence | `flag_anomalies` |
|
| 49 |
+
| 7. Export | Serialize extracted data → JSON / CSV / Excel | `export_data` |
|
| 50 |
+
|
| 51 |
+
## Agent design
|
| 52 |
+
Single **orchestrator agent** with the tools above (tool-calling loop), agentic-RAG retrieval, structured outputs (Pydantic), human-in-the-loop on low-confidence fields. Pattern mirrors LlamaIndex ADW: *parse → maintain state → retrieve → reason → surface for validation*.
|
| 53 |
+
|
| 54 |
+
## LLM strategy
|
| 55 |
+
- **Default (free):** Gemini Flash (1M context, native vision for scans).
|
| 56 |
+
- **Fallback (switchable):** GPT-4o-mini.
|
| 57 |
+
- `llm/base.py` defines a `LLMProvider` interface; runtime switch via config/env or per-request header. Cheap model for classify, stronger for reasoning.
|
| 58 |
+
|
| 59 |
+
## Tech choices (pragmatic defaults for a runnable, impressive build)
|
| 60 |
+
- **Backend:** FastAPI, Pydantic v2, Docling, `google-generativeai`, `openai`, ChromaDB (embedded vector store — no external service), SQLite (dev) with a path to Postgres/pgvector.
|
| 61 |
+
- **Frontend:** Next.js (App Router) + React + TypeScript + Tailwind + a PDF/image viewer with overlay layer for bounding boxes; SSE for streaming chat.
|
| 62 |
+
- **Local-first:** runs with only a free Gemini API key. No paid services required.
|
| 63 |
+
|
| 64 |
+
## Separation of concerns
|
| 65 |
+
Routes are thin → call services. Agent orchestrates services as tools. LLM access only via `llm/`. Storage only via `services/storage` + `services/vectorstore`. Schemas shared. This is the Dify-grade discipline we borrowed.
|
docs/DEPLOY.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deployment guide
|
| 2 |
+
|
| 3 |
+
Architecture: **frontend → Vercel**, **backend → a Docker container host** (Render
|
| 4 |
+
blueprint included). The two talk over HTTPS; the frontend proxies `/api/*` to the
|
| 5 |
+
backend via a Next.js rewrite, so the browser only ever sees the Vercel origin.
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
Browser ──> Vercel (Next.js) ──/api/* rewrite──> Backend container (FastAPI + Docling)
|
| 9 |
+
└── persistent disk /data
|
| 10 |
+
└── models baked into image
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
Why the backend can't be serverless: it bundles Docling + (CPU) torch + ~850 MB of
|
| 14 |
+
layout/table/OCR models and needs real RAM/disk. Plan for **2 GB minimum, 4 GB
|
| 15 |
+
recommended**.
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## 1. Backend → Render (recommended)
|
| 20 |
+
|
| 21 |
+
The image bakes the models in, runs as non-root, exposes a health check, and binds
|
| 22 |
+
`$PORT`. Everything is described in [`render.yaml`](../render.yaml).
|
| 23 |
+
|
| 24 |
+
1. **Push this repo to GitHub** (Render deploys from Git):
|
| 25 |
+
```bash
|
| 26 |
+
git remote add origin https://github.com/<you>/document-agent.git
|
| 27 |
+
git push -u origin main
|
| 28 |
+
```
|
| 29 |
+
2. In the **Render dashboard → New → Blueprint**, select the repo. Render reads
|
| 30 |
+
`render.yaml` and provisions a Docker web service + a 10 GB disk at `/data`.
|
| 31 |
+
3. When prompted, set the secret env vars:
|
| 32 |
+
- `OPENAI_API_KEY` — your key
|
| 33 |
+
- `GEMINI_API_KEY` — optional (leave blank to disable Gemini)
|
| 34 |
+
4. After the first deploy, note the URL (e.g. `https://document-agent-backend.onrender.com`)
|
| 35 |
+
and verify: `GET <url>/api/health` → `{"status":"ok", ...}`.
|
| 36 |
+
5. Set `CORS_ORIGINS` to your Vercel domain (see step 2 below) and redeploy.
|
| 37 |
+
|
| 38 |
+
First build is slow (downloads torch + models). Subsequent deploys reuse layers.
|
| 39 |
+
|
| 40 |
+
### Free option — Hugging Face Spaces (no card required)
|
| 41 |
+
|
| 42 |
+
Render's free plan is 512 MB RAM with no disk and can't run this image. **HF Spaces
|
| 43 |
+
free CPU tier gives 16 GB RAM** and runs Docker, so it fits — the only loss is
|
| 44 |
+
persistence (free Spaces have no disk, so uploads/DB reset on restart/rebuild).
|
| 45 |
+
|
| 46 |
+
This repo ships an HF-ready **root `Dockerfile`** (builds the backend with the repo
|
| 47 |
+
root as context, runs as UID 1000) and the HF config in the **`README.md`
|
| 48 |
+
frontmatter** (`sdk: docker`, `app_port: 8000`).
|
| 49 |
+
|
| 50 |
+
1. Create a free account at https://huggingface.co, then **New → Space**:
|
| 51 |
+
- **SDK: Docker** (blank template), name e.g. `document-agent`, visibility your choice.
|
| 52 |
+
2. Push this repo to the Space's git remote:
|
| 53 |
+
```bash
|
| 54 |
+
git remote add space https://huggingface.co/spaces/<hf-user>/document-agent
|
| 55 |
+
git push space main # auth: HF username + an HF access token as password
|
| 56 |
+
```
|
| 57 |
+
(Create a write token at https://huggingface.co/settings/tokens.)
|
| 58 |
+
3. In the Space: **Settings → Variables and secrets**, add as **secrets**:
|
| 59 |
+
- `OPENAI_API_KEY` (freshly rotated) and/or `GEMINI_API_KEY`
|
| 60 |
+
- `DEFAULT_LLM_PROVIDER` (`openai` or `gemini`), `ENABLE_OCR` (`true`/`false`)
|
| 61 |
+
4. The Space builds (slow first time: torch + ~850 MB models). When live, the URL is
|
| 62 |
+
`https://<hf-user>-document-agent.hf.space`; verify `GET <url>/api/health`.
|
| 63 |
+
5. Use that URL as `NEXT_PUBLIC_API_BASE` on Vercel (step 2 below).
|
| 64 |
+
|
| 65 |
+
> Tip: if free RAM is tight on large scans, set `ENABLE_OCR=false` to cut memory.
|
| 66 |
+
|
| 67 |
+
### Other alternatives
|
| 68 |
+
- **Fly.io**: `fly launch` from `./backend` (it detects the Dockerfile), add a volume
|
| 69 |
+
(`fly volumes create data`) mounted at `/data`, set secrets with `fly secrets set`.
|
| 70 |
+
- **Railway**: new service from the Dockerfile, add a volume at `/data`, set the env vars.
|
| 71 |
+
|
| 72 |
+
---
|
| 73 |
+
|
| 74 |
+
## 2. Frontend → Vercel
|
| 75 |
+
|
| 76 |
+
The frontend is already env-driven — it needs exactly one variable pointing at the
|
| 77 |
+
backend.
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
cd frontend
|
| 81 |
+
vercel # first run: link/create the project
|
| 82 |
+
# set the backend URL for all environments:
|
| 83 |
+
vercel env add NEXT_PUBLIC_API_BASE
|
| 84 |
+
# value: https://document-agent-backend.onrender.com (no trailing slash)
|
| 85 |
+
vercel --prod # deploy
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
`NEXT_PUBLIC_API_BASE` drives the `/api/:path*` rewrite in
|
| 89 |
+
[`next.config.mjs`](../frontend/next.config.mjs), so uploads, page images, exports and
|
| 90 |
+
the chat stream all flow through the same origin — no CORS needed in the common case.
|
| 91 |
+
|
| 92 |
+
> Streaming note: chat uses SSE through the Vercel rewrite. If a host buffers the
|
| 93 |
+
> stream, switch to direct calls by setting `BASE = process.env.NEXT_PUBLIC_API_BASE`
|
| 94 |
+
> in `frontend/lib/api.ts` and make sure `CORS_ORIGINS` on the backend lists the
|
| 95 |
+
> Vercel domain.
|
| 96 |
+
|
| 97 |
+
---
|
| 98 |
+
|
| 99 |
+
## 3. Verify locally first (optional but recommended)
|
| 100 |
+
|
| 101 |
+
Build and run the real production image before shipping:
|
| 102 |
+
|
| 103 |
+
```bash
|
| 104 |
+
docker compose up --build
|
| 105 |
+
# backend: http://localhost:8000/api/health
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
`docker-compose.yml` mounts a named volume at `/data` and reads `backend/.env`, so it
|
| 109 |
+
mirrors production (persistent uploads + sqlite + rendered pages).
|
| 110 |
+
|
| 111 |
+
---
|
| 112 |
+
|
| 113 |
+
## Operational notes
|
| 114 |
+
|
| 115 |
+
- **Persistence**: `/data` holds uploads, the SQLite DB, the numpy vector store and
|
| 116 |
+
rendered page images. Keep it on a mounted disk/volume — without one, data resets on
|
| 117 |
+
every deploy.
|
| 118 |
+
- **Models**: baked into the image (not in `/data`), so they survive redeploys with no
|
| 119 |
+
re-download.
|
| 120 |
+
- **Secrets**: only ever via host env vars. `backend/.env` is git-ignored.
|
| 121 |
+
- **Scaling**: state lives on local disk + SQLite, so run a **single instance**. To
|
| 122 |
+
scale horizontally, move the DB to Postgres and the vector store to pgvector
|
| 123 |
+
(`services/vectorstore.py` is a drop-in seam) and uploads to object storage.
|
| 124 |
+
- **Cost guard**: the backend holds your LLM key behind a public URL. Consider adding
|
| 125 |
+
auth or a rate limit before sharing widely.
|
docs/OVERVIEW.md
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DocAgent — Project Overview, Research, Design & How It Works
|
| 2 |
+
|
| 3 |
+
**Project:** Document Processing AI Agent (chat-based, 40/60 UI)
|
| 4 |
+
**Org:** Monkhub Innovations
|
| 5 |
+
**One-line:** Upload any document → it parses, classifies, extracts structured
|
| 6 |
+
data, summarizes, answers grounded questions, flags anomalies, and exports clean data.
|
| 7 |
+
|
| 8 |
+
This single document brings together **what we researched, what we chose and why,
|
| 9 |
+
the comparison, the feature set, and exactly how the agent works end-to-end.**
|
| 10 |
+
For the full deep-dive research see [`RESEARCH.md`](RESEARCH.md); for the layered
|
| 11 |
+
architecture record see [`ARCHITECTURE.md`](ARCHITECTURE.md); to deploy see
|
| 12 |
+
[`DEPLOY.md`](DEPLOY.md).
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## 1. Research — what we evaluated
|
| 17 |
+
|
| 18 |
+
We needed a foundation for a **custom, chat-based, production document-processing
|
| 19 |
+
agent** with a 40/60 split UI and a **free-LLM-first** model strategy. We evaluated
|
| 20 |
+
the five leading systems, one per relevant archetype:
|
| 21 |
+
|
| 22 |
+
| Archetype | System | Why it represents the category |
|
| 23 |
+
|---|---|---|
|
| 24 |
+
| Full open-source app | **RAGFlow** | A complete chat-over-documents product with deep understanding, citations, and an agent layer — the closest existing thing to our target. |
|
| 25 |
+
| LLM app platform | **Dify** | Reference for clean platform architecture, visual orchestration, observability, polished UI/UX. |
|
| 26 |
+
| Extraction toolkit | **Docling** (IBM / LF AI & Data) | Best-in-class open document parsing — layout, tables, OCR, VLM. The extraction *backbone*. |
|
| 27 |
+
| Workflow automation | **n8n** | The "glue" archetype: trigger → OCR → LLM extract → validate → route, assembled visually. |
|
| 28 |
+
| Agentic framework | **LlamaIndex ADW** | Reference for stateful, multi-step *document agents* and schema-based structured extraction. |
|
| 29 |
+
|
| 30 |
+
**Method:** sequential, single-researcher web research; each system and source read
|
| 31 |
+
in full. Full notes and sources in [`RESEARCH.md`](RESEARCH.md).
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## 2. Comparison table & scoring
|
| 36 |
+
|
| 37 |
+
**Rubric (1–5; 5 = best)** — scored for *our* specific goal, not in the abstract:
|
| 38 |
+
|
| 39 |
+
| Code | Parameter |
|
| 40 |
+
|---|---|
|
| 41 |
+
| A | Feature completeness (OCR, layout, tables, KV, entities, classify, summarize, Q&A, export) |
|
| 42 |
+
| B | Architecture quality & extensibility |
|
| 43 |
+
| C | Ease of customisation to our UI/flows |
|
| 44 |
+
| D | Production-readiness (auth, scaling, observability, deploy) |
|
| 45 |
+
| E | UI/UX quality for document work |
|
| 46 |
+
| F | Cost / open-source friendliness (license, no per-page cost, self-host) |
|
| 47 |
+
| G | Community & documentation |
|
| 48 |
+
|
| 49 |
+
| System | A | B | C | D | E | F | G | **Total /35** |
|
| 50 |
+
|---|---|---|---|---|---|---|---|---|
|
| 51 |
+
| **Dify** | 3 | 5 | 5 | 5 | 5 | 4 | 5 | **32** |
|
| 52 |
+
| **RAGFlow** | 5 | 4 | 3 | 4 | 4 | 5 | 5 | **30** |
|
| 53 |
+
| **Docling** | 5 | 5 | 4 | 4 | 1 | 5 | 5 | **29** |
|
| 54 |
+
| **LlamaIndex ADW** | 4 | 5 | 4 | 4 | 2 | 3 | 5 | **27** |
|
| 55 |
+
| **n8n workflows** | 3 | 3 | 5 | 4 | 2 | 3 | 5 | **25** |
|
| 56 |
+
|
| 57 |
+
**How to read it.** Dify wins on raw total, but its lead comes from *platform polish*,
|
| 58 |
+
not *document intelligence* (its weakest axis, A=3). RAGFlow and Docling lead on the
|
| 59 |
+
axis this project lives or dies on — document depth (A=5). The honest conclusion is
|
| 60 |
+
that **no single system is simultaneously the deepest extractor, the cleanest platform,
|
| 61 |
+
and a ready-made 40/60 chat UI.** So we composed.
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
+
## 3. What we chose & why
|
| 66 |
+
|
| 67 |
+
> **Decision: a composed custom stack — not a fork of any one system.**
|
| 68 |
+
|
| 69 |
+
| Borrowed from | What we took |
|
| 70 |
+
|---|---|
|
| 71 |
+
| **RAGFlow** | The end-to-end **pipeline blueprint** (deep-understanding-at-ingestion → grounded, citable answers) and citation UX. |
|
| 72 |
+
| **Docling** | The **actual extraction engine** — MIT, local, layout + TableFormer tables + OCR, zero per-page cost. |
|
| 73 |
+
| **LlamaIndex ADW** | The **agent loop pattern**: *parse → maintain state → retrieve → reason → surface for validation.* |
|
| 74 |
+
| **Dify** | **Separation-of-concerns discipline** — thin routes, services, provider abstraction. |
|
| 75 |
+
| **Our own** | **FastAPI** backend + **Next.js/React** 40/60 UI tailored to the brief. |
|
| 76 |
+
|
| 77 |
+
**Why composed instead of forking RAGFlow** (the top *product*): its infra (MinIO +
|
| 78 |
+
Elasticsearch + MySQL + Redis) and opinionated UI are too heavy to host our bespoke
|
| 79 |
+
40/60 experience, and the footprint (≥4 cores / ≥16 GB / ≥50 GB) is unjustified at our
|
| 80 |
+
stage. Composing gives us RAGFlow's proven flow, Docling's best-in-class *and free/local*
|
| 81 |
+
extraction, LlamaIndex's stateful agent patterns, and Dify's architectural cleanliness —
|
| 82 |
+
with none of the licensing, cost, or lock-in downsides.
|
| 83 |
+
|
| 84 |
+
### Chosen stack at a glance
|
| 85 |
+
|
| 86 |
+
| Layer | Choice | Why |
|
| 87 |
+
|---|---|---|
|
| 88 |
+
| **Extraction** | **Docling** (layout, TableFormer, EasyOCR) | Best open accuracy; MIT; local; $0 per page; offline-capable. |
|
| 89 |
+
| **LLM (default)** | **Gemini Flash** | Generous free tier; ~1M-token context for long docs; native vision for scans. |
|
| 90 |
+
| **LLM (fallback)** | **GPT-4o-mini** | Cheap, reliable tool-calling; switchable per-request behind a provider interface. |
|
| 91 |
+
| **Backend** | **FastAPI** (async, Pydantic v2) | Clean route→service layering; SSE streaming; production-ready. |
|
| 92 |
+
| **Frontend** | **Next.js (App Router) + React + TS + Tailwind** | 40/60 layout, document viewer with bounding-box overlays, streaming chat. |
|
| 93 |
+
| **Vector store** | **Pure-Python numpy cosine** (`.npz`/`.json` on disk) | Dependency-light, no native build/service; chunks carry page+bbox for citations. Swappable for pgvector/Chroma. |
|
| 94 |
+
| **Metadata store** | **SQLite** (JSON blobs) | Zero external services; access funnelled through one module → easy Postgres swap. |
|
| 95 |
+
|
| 96 |
+
**Local-first & ~zero marginal cost:** the whole app runs with only a free Gemini key.
|
| 97 |
+
Nothing imports a vendor SDK except `llm/*`, so providers swap by env, per request, or
|
| 98 |
+
from the UI.
|
| 99 |
+
|
| 100 |
+
---
|
| 101 |
+
|
| 102 |
+
## 4. Features — the 7 core functions
|
| 103 |
+
|
| 104 |
+
| # | Function | What it does | Agent tool |
|
| 105 |
+
|---|---|---|---|
|
| 106 |
+
| 1 | **Ingest** | Parse PDF, DOCX, PPTX, XLSX, HTML, images with Docling (layout, tables, OCR) → unified Markdown + page images + provenance-carrying chunks. | *(pipeline)* |
|
| 107 |
+
| 2 | **Extract** | **Adaptive, template-free** structured data: fields / records / tables / entities, each with a confidence score and best-effort citation. | `get_extracted_data` |
|
| 108 |
+
| 3 | **Classify** | Zero-shot document-type detection with confidence + rationale. | `classify_document` |
|
| 109 |
+
| 4 | **Summarize** | Map-reduce summary (TL;DR + key points) over chunks. | `summarize_document` |
|
| 110 |
+
| 5 | **Q&A** | Conversational, **agentic-RAG** answers **grounded with citations** you can click to highlight on the page. | `query_document` |
|
| 111 |
+
| 6 | **Flag anomalies** | Missing fields, low-confidence values, generic arithmetic reconciliation, multi-record sanity, LLM consistency review. | `flag_anomalies` |
|
| 112 |
+
| 7 | **Export** | Serialize extracted data to **JSON / CSV / Excel** (records become a table). | *(API)* |
|
| 113 |
+
|
| 114 |
+
### The headline feature: adaptive, domain-agnostic extraction
|
| 115 |
+
|
| 116 |
+
Extraction does **not** rely on per-domain templates. For *any* document — any layout,
|
| 117 |
+
any label convention, known type or not — it produces a stable, consistently-keyed shape by:
|
| 118 |
+
|
| 119 |
+
- **Layout detection** — the model decides whether the doc is one entity
|
| 120 |
+
(`single_record`, e.g. an invoice), many similar entities (`multi_record`, e.g. an
|
| 121 |
+
employee roster or transaction list), or a header + repeated sub-list
|
| 122 |
+
(`mixed`, e.g. invoice + line items).
|
| 123 |
+
- **Schema induction** — fields are derived from the actual content (a known type only
|
| 124 |
+
*suggests* fields; the model may add/drop to fit reality).
|
| 125 |
+
- **Canonical keys** — inconsistent source labels ("Staff Number" / "Personnel ID" / "ID")
|
| 126 |
+
are normalised to one `snake_case` key, identical across every record.
|
| 127 |
+
- **Grounding** — values come only from the document body (never file metadata); dates and
|
| 128 |
+
amounts are normalised (ISO dates, currency); citations are attached by matching values
|
| 129 |
+
back to provenance-carrying chunks.
|
| 130 |
+
|
| 131 |
+
---
|
| 132 |
+
|
| 133 |
+
## 5. How it works — architecture
|
| 134 |
+
|
| 135 |
+
```
|
| 136 |
+
┌──────────────────────────────────────────────────────────────┐
|
| 137 |
+
│ FRONTEND (Next.js + React + TypeScript + Tailwind) │
|
| 138 |
+
│ 40% Chat (streaming agent) │ 60% Workspace │
|
| 139 |
+
│ │ Tabs: Viewer · Fields · │
|
| 140 |
+
│ │ Classify · Summary · Export │
|
| 141 |
+
│ │ Viewer w/ bounding-box cites │
|
| 142 |
+
└─────┬────────────────────────────────────────────────────────┘
|
| 143 |
+
│ REST + SSE (token streaming)
|
| 144 |
+
┌─────▼────────────────────────────────────────────────────────┐
|
| 145 |
+
│ BACKEND (FastAPI, async) │
|
| 146 |
+
│ api/ documents · chat(SSE) · actions · meta(health) │
|
| 147 |
+
│ agent/ orchestrator (tool-calling loop) + prompts │
|
| 148 |
+
│ llm/ provider abstraction: Gemini(default) ⇄ OpenAI │
|
| 149 |
+
│ services/ ingestion(Docling) · extraction · classification │
|
| 150 |
+
│ · summary · qa(RAG) · anomaly · export · │
|
| 151 |
+
│ vectorstore · storage · pipeline │
|
| 152 |
+
│ schemas/ Pydantic contracts (documents, optional hints) │
|
| 153 |
+
└─────┬───────────────────────────┬────────────────────────────┘
|
| 154 |
+
│ │
|
| 155 |
+
┌─────▼─────┐ ┌─────────▼──────────┐
|
| 156 |
+
│ Docling │ │ Storage (/data) │
|
| 157 |
+
│ (local │ │ • files on disk │
|
| 158 |
+
│ extract) │ │ • SQLite metadata │
|
| 159 |
+
│ models │ │ • numpy vectors │
|
| 160 |
+
│ baked in │ │ (.npz/.json) │
|
| 161 |
+
└───────────┘ │ • rendered pages │
|
| 162 |
+
└────────────────────┘
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
**Discipline (borrowed from Dify):** routes are thin → call services; the agent
|
| 166 |
+
orchestrates services as tools; LLM access only through `llm/`; storage only through
|
| 167 |
+
`services/storage` + `services/vectorstore`; schemas are shared.
|
| 168 |
+
|
| 169 |
+
---
|
| 170 |
+
|
| 171 |
+
## 6. Working — the two flows
|
| 172 |
+
|
| 173 |
+
### Flow A — Ingestion pipeline (runs once per upload, in the background)
|
| 174 |
+
|
| 175 |
+
Implemented in `services/pipeline.py`. State is written back at every milestone so the
|
| 176 |
+
UI can poll status and render results progressively.
|
| 177 |
+
|
| 178 |
+
```
|
| 179 |
+
upload → [Stage 1: INGEST]──fail?──► status=failed (hard fail; only this stage can)
|
| 180 |
+
│ Docling parse → Markdown + page images + chunks(page,bbox)
|
| 181 |
+
▼
|
| 182 |
+
[index] embed chunks → numpy vector store ┐
|
| 183 |
+
[classify] LLM zero-shot → type + confidence │ Stages 2–6 are
|
| 184 |
+
[extract] adaptive induction → fields/records/... │ best-effort: a
|
| 185 |
+
[summarize] map-reduce → TL;DR + key points │ failure (e.g. no
|
| 186 |
+
[anomalies] rule + LLM checks (needs extraction) │ LLM key) degrades
|
| 187 |
+
▼ ┘ gracefully
|
| 188 |
+
status=ready (viewer always works, even if AI steps were skipped)
|
| 189 |
+
```
|
| 190 |
+
|
| 191 |
+
Key robustness property: **only ingestion can hard-fail a document.** Everything else is
|
| 192 |
+
wrapped so a missing API key or a flaky step still yields a `ready` document with a
|
| 193 |
+
working viewer and a note about what was skipped.
|
| 194 |
+
|
| 195 |
+
### Flow B — Chat agent (per user message)
|
| 196 |
+
|
| 197 |
+
Implemented in `agent/orchestrator.py` — a bounded **tool-calling loop**, then a
|
| 198 |
+
**streamed** final answer.
|
| 199 |
+
|
| 200 |
+
```
|
| 201 |
+
user message + history + doc context
|
| 202 |
+
│
|
| 203 |
+
▼
|
| 204 |
+
Phase 1: TOOL ROUNDS (up to MAX_TOOL_ROUNDS = 4, non-streamed reasoning)
|
| 205 |
+
llm.complete_tools(messages, TOOL_SPECS)
|
| 206 |
+
│
|
| 207 |
+
├── model returns tool_calls? ──yes──► run each tool ─► append results ─► loop
|
| 208 |
+
│ tools: query_document · classify_document ·
|
| 209 |
+
│ get_extracted_data · summarize_document · flag_anomalies
|
| 210 |
+
│
|
| 211 |
+
└── no tool_calls (ready to answer) ─► exit loop
|
| 212 |
+
▼
|
| 213 |
+
Phase 2: FINAL ANSWER (streamed token-by-token over SSE, temperature 0.4)
|
| 214 |
+
│
|
| 215 |
+
▼
|
| 216 |
+
client renders answer + citation chips → click a chip → highlight the
|
| 217 |
+
page region (numpy store returned page+bbox with each retrieved chunk)
|
| 218 |
+
```
|
| 219 |
+
|
| 220 |
+
`query_document` runs **agentic RAG**: retrieve the most similar chunks, answer from
|
| 221 |
+
them, and return grounding passages whose page+bbox the UI turns into clickable
|
| 222 |
+
highlights. The orchestrator collects citations in its `ToolContext` so the API can
|
| 223 |
+
emit them alongside the streamed answer.
|
| 224 |
+
|
| 225 |
+
---
|
| 226 |
+
|
| 227 |
+
## 7. End-to-end walkthrough (a user's view)
|
| 228 |
+
|
| 229 |
+
1. **Drag a document** into the left rail (or click *New document*).
|
| 230 |
+
2. The pipeline runs: pages render in the viewer first, then **classification, fields,
|
| 231 |
+
summary, and anomaly flags** stream into the workspace tabs as each stage finishes.
|
| 232 |
+
3. **Chat** on the left — e.g. *"Summarize this"*, *"What's the total?"*, *"Any missing
|
| 233 |
+
fields?"* The agent calls the right tools, then streams a grounded answer. Click any
|
| 234 |
+
**citation chip** to highlight its exact source on the page.
|
| 235 |
+
4. **Review & edit** low-confidence fields inline (human-in-the-loop) before exporting.
|
| 236 |
+
5. **Export** from the Export tab as **JSON / CSV / Excel**.
|
| 237 |
+
|
| 238 |
+
---
|
| 239 |
+
|
| 240 |
+
## 8. Why this design holds up
|
| 241 |
+
|
| 242 |
+
- **Document depth where it matters** — Docling gives best-in-class layout/table/OCR
|
| 243 |
+
locally and for free, the axis on which generic platforms (Dify, n8n) are weakest.
|
| 244 |
+
- **Truly document-agnostic** — extraction induces its own schema, so a new document
|
| 245 |
+
type needs no code or template changes.
|
| 246 |
+
- **Grounded & trustworthy** — every chunk carries page + bbox, so answers and fields
|
| 247 |
+
point at their exact source; the single most trust-building UX feature.
|
| 248 |
+
- **Cheap and portable** — runs on a free Gemini key with no external services; SQLite
|
| 249 |
+
and the numpy store are single-file and swappable for Postgres/pgvector at scale via
|
| 250 |
+
one module each.
|
| 251 |
+
- **Graceful degradation** — the app stays useful even when AI steps fail.
|
| 252 |
+
- **Clean seams for growth** — provider abstraction, storage/vectorstore interfaces,
|
| 253 |
+
and a single-orchestrator agent that can grow more tools without a rewrite.
|
docs/RESEARCH.md
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Document Processing AI Agent — Deep Research Report
|
| 2 |
+
|
| 3 |
+
**Project:** Production-ready, chat-based Document Processing AI Agent
|
| 4 |
+
**Org:** Monkhub Innovations
|
| 5 |
+
**Date:** 2026-06-24
|
| 6 |
+
**Method:** Sequential, single-researcher web research (no sub-agents). Each system and source read in full, one at a time.
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
## 0. Executive Summary
|
| 11 |
+
|
| 12 |
+
We evaluated five leading systems for document-processing automation, spanning the four relevant archetypes:
|
| 13 |
+
|
| 14 |
+
| Archetype | System | Why it represents the category |
|
| 15 |
+
|---|---|---|
|
| 16 |
+
| Full open-source app | **RAGFlow** (infiniflow) | A complete chat-over-documents product with deep document understanding, citations, and an agent layer — the closest existing thing to what we are building. |
|
| 17 |
+
| LLM app platform | **Dify** (langgenius) | The reference for clean platform architecture, visual orchestration, observability, and polished UI/UX. |
|
| 18 |
+
| Extraction toolkit | **Docling** (IBM / LF AI & Data) | Best-in-class open document parsing (layout, tables, OCR, VLM) — the extraction *backbone*. |
|
| 19 |
+
| Workflow automation | **n8n** document workflows | The "glue" archetype: trigger → OCR → LLM extract → validate → route, assembled visually. |
|
| 20 |
+
| Agentic framework | **LlamaIndex** (Agentic Document Workflows + LlamaParse/Extract) | The reference for stateful, multi-step *document agents* and schema-based structured extraction. |
|
| 21 |
+
|
| 22 |
+
**Winner / primary reference foundation: RAGFlow**, with the explicit recommendation that we **do not fork it wholesale**. For our build we adopt **RAGFlow's pipeline design as the reference architecture**, use **Docling as the actual extraction engine** (MIT, local, zero per-page cost), borrow **Dify's clean separation-of-concerns and observability**, and structure the agent loop using **LlamaIndex-style agentic-workflow patterns**. Details in §3–§6.
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## 1. The Five Systems — Deep Analysis
|
| 27 |
+
|
| 28 |
+
### 1.1 RAGFlow (infiniflow) — *Apache 2.0, ~83.5k★*
|
| 29 |
+
|
| 30 |
+
**What it does / core purpose.** Open-source RAG engine fused with agent capabilities that turns unstructured documents into a production "context layer" for LLMs. Philosophy: *"quality in, quality out"* — invest heavily in deep document understanding at ingestion so retrieval and answers are grounded and citable.
|
| 31 |
+
|
| 32 |
+
**End-to-end pipeline.**
|
| 33 |
+
1. **Ingest** — Word, PPT, Excel, PDF, images, scans, structured data, web pages.
|
| 34 |
+
2. **Deep document understanding** — the **DeepDoc** module performs layout analysis and structure extraction; can also delegate to MinerU or Docling parsers.
|
| 35 |
+
3. **Template-based chunking** — intelligent, configurable, *visualized* chunking with human review/intervention.
|
| 36 |
+
4. **Embed + index** — into Elasticsearch (default) or Infinity (their own engine).
|
| 37 |
+
5. **Multi-recall retrieval + fused re-ranking.**
|
| 38 |
+
6. **Grounded answer with citations** — traceable references to reduce hallucination.
|
| 39 |
+
|
| 40 |
+
**Architecture.** Frontend (web) → REST API → backend services → DeepDoc / embedding / LLM interface, over a storage tier of **MinIO** (objects), **Elasticsearch or Infinity** (vector + full-text), **MySQL** (metadata), **Redis** (cache). Agent framework adds workflow automation, MCP support, code executor (Python/JS, gVisor sandbox), and agent memory.
|
| 41 |
+
|
| 42 |
+
**Tech stack / models.** Python + Go backend, TypeScript frontend. Pluggable LLM and embedding providers (OpenAI, DeepSeek, Gemini, local). Docker / Docker Compose / Kubernetes (Helm).
|
| 43 |
+
|
| 44 |
+
**Key features.** Explainable template chunking; grounded citations with visualization; broad format support; agent templates + MCP; memory; many chat-channel and data-source connectors (Confluence, S3, Notion, Google Drive, Discord, etc.); multi-language UI.
|
| 45 |
+
|
| 46 |
+
**Strengths.** Deepest end-to-end document pipeline of the five; citation grounding; fully permissive license; mature, very active, well-documented; the most *directly comparable* product to our target.
|
| 47 |
+
|
| 48 |
+
**Weaknesses.** Heavy footprint (≥4 cores, ≥16 GB RAM, ≥50 GB disk; x86-first); operational learning curve; opinionated stack that is hard to bend into a *custom* 40/60 UI without significant surgery.
|
| 49 |
+
|
| 50 |
+
**Relevance.** ★★★★★ as a **reference** for the pipeline, citation UX, and chunking visualization. Lower as a thing-to-fork because its UI and infra are opinionated.
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
### 1.2 Dify (langgenius) — *Apache-2.0-based (with extra conditions), ~146k★*
|
| 55 |
+
|
| 56 |
+
**What it does / core purpose.** Open-source **LLM app development platform** — bridges prototype to production with a low-code visual canvas plus developer APIs (Backend-as-a-Service).
|
| 57 |
+
|
| 58 |
+
**End-to-end flow.** Design on a visual canvas → pick model (50+ providers) → ingest documents into a managed RAG pipeline → define agents (function-calling or ReAct) with 50+ built-in tools → test in a prompt IDE → monitor (logs/metrics) → deploy via API or hosted UI.
|
| 59 |
+
|
| 60 |
+
**Architecture.** Python backend (BaaS) + Next.js/React/TypeScript frontend; **PostgreSQL**; a canvas **workflow engine**; a unified **model-abstraction layer**; out-of-the-box **RAG pipeline**; first-class **observability** (Langfuse, Opik, Arize Phoenix).
|
| 61 |
+
|
| 62 |
+
**Tech stack / models.** TS (≈53%) + Python (≈43%). 50+ LLM providers incl. OpenAI, Anthropic, Google, Mistral, local. Docker Compose / K8s / Terraform / CDK. Min 2 cores / 4 GB.
|
| 63 |
+
|
| 64 |
+
**Key features.** Visual workflow builder; agent framework with tools; managed RAG; prompt IDE with side-by-side model comparison; production observability; complete API surface.
|
| 65 |
+
|
| 66 |
+
**Strengths.** Cleanest, most extensible *platform* architecture; very polished UI/UX; superb model-provider abstraction (directly relevant to our "free Gemini + GPT-4o-mini fallback" requirement); strong production/observability story; huge community.
|
| 67 |
+
|
| 68 |
+
**Weaknesses.** Document processing is **generic** — basic ingest/chunk, *no* specialized OCR / table-structure / layout intelligence. License has extra conditions beyond pure Apache 2.0. As a platform it can feel heavy if you only need an app.
|
| 69 |
+
|
| 70 |
+
**Relevance.** ★★★★☆ — the best **architectural and UI/UX reference**, and a candidate **model-routing layer**, but its document intelligence is too shallow to be our extraction engine.
|
| 71 |
+
|
| 72 |
+
---
|
| 73 |
+
|
| 74 |
+
### 1.3 Docling (IBM Research → LF AI & Data) — *MIT, ~62k★*
|
| 75 |
+
|
| 76 |
+
**What it does / core purpose.** Open document-processing library that parses diverse formats (with advanced PDF understanding) into a unified, AI-ready representation. It is the **extraction backbone**, not an agent.
|
| 77 |
+
|
| 78 |
+
**Pipeline.** Ingest (path/URL) → format detect → **layout analysis** (structure + reading order) → content extraction (text/tables/images) → **model inference** for hard elements → unified **DoclingDocument** → export (Markdown / JSON / HTML / DocTags).
|
| 79 |
+
|
| 80 |
+
**Architecture / models.** Layout analysis; **TableFormer** for table structure; OCR engines for scans; pluggable **VLMs** (built-in **GraniteDocling 258M**); ASR for audio; chart-understanding module. Python, Pydantic v2.
|
| 81 |
+
|
| 82 |
+
**Formats.** **In:** PDF, DOCX, PPTX, XLSX, HTML, EPUB, images (PNG/TIFF/JPEG), audio, email, LaTeX, Markdown, ODF, XBRL, more. **Out:** Markdown, HTML, lossless JSON, DocTags, domain XML.
|
| 83 |
+
|
| 84 |
+
**Key features.** Best-in-class layout + table extraction; semantic **chunking**; native adapters for **LangChain, LlamaIndex, CrewAI, Haystack, MCP**; **local / air-gapped** execution (no per-page API cost); CLI + Python.
|
| 85 |
+
|
| 86 |
+
**Strengths.** Best open extraction accuracy; widest format coverage; permissive MIT; local-first (privacy + zero marginal cost); OpenSSF badge; peer-reviewed; trivially embeddable into any backend.
|
| 87 |
+
|
| 88 |
+
**Weaknesses.** Not an application — no UI, chat, classification, or Q&A on its own (by design). VLM inference adds latency/compute; scaling docs sparse; some domains need fine-tuning.
|
| 89 |
+
|
| 90 |
+
**Relevance.** ★★★★★ as the **component we should actually use** for ingest/extraction. Not a competitor to the whole app — a building block.
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
+
### 1.4 n8n Document Workflows — *Sustainable Use License (fair-code), source-available*
|
| 95 |
+
|
| 96 |
+
**What it does / core purpose.** Visual workflow-automation platform; "document processing" emerges from chaining nodes. Hundreds of templates exist (e.g. *Mistral OCR + GPT-4o-mini invoice processing*, *Gemini OCR + Google Sheets*).
|
| 97 |
+
|
| 98 |
+
**Typical pipeline (3 layers).** **Ingestion** (webhook / email / Drive / upload trigger → download file) → **Intelligence** (OCR node — Mistral / Gemini / Google Vision — then an **AI Agent / LLM node** with a **structured-output parser** to emit JSON; classification + field extraction) → **Orchestration** (validation against business rules / POs, enrichment, routing to Sheets / SQL / CRM / ERP / Telegram / Gmail). Core design principle: **decouple extraction from delivery** via modular, reusable sub-workflows.
|
| 99 |
+
|
| 100 |
+
**Strengths.** Fastest way to prototype an extract→validate→route pipeline; 400+ integrations; visual + maintainable; built-in retries/logging; self-hostable.
|
| 101 |
+
|
| 102 |
+
**Weaknesses.** Not an end-user product — **no document-viewer or chat UI** for our use case (you'd build that separately). Logic-in-canvas sprawls at scale; n8n can become an orchestration bottleneck; **fair-code** license (not OSI-open) carries commercial-use restrictions.
|
| 103 |
+
|
| 104 |
+
**Relevance.** ★★★☆☆ — excellent **reference for the orchestration pattern** (and a possible internal automation tool), but not the foundation for a polished chat app.
|
| 105 |
+
|
| 106 |
+
---
|
| 107 |
+
|
| 108 |
+
### 1.5 LlamaIndex — Agentic Document Workflows (ADW) + LlamaParse/Extract — *Core MIT; Parse/Extract/Cloud are SaaS with free tier*
|
| 109 |
+
|
| 110 |
+
**What it does / core purpose.** A leading framework for **document agents**. ADW goes a step beyond both IDP and RAG: a **document agent** that parses, **maintains state across multi-step processes**, retrieves reference material, applies business logic, and produces recommendations (human-in-the-loop by design).
|
| 111 |
+
|
| 112 |
+
**Architecture / pattern.** Document agent orchestrates: **LlamaParse** (VLM-powered parsing) → **LlamaExtract** (Pydantic-schema structured extraction *with confidence scores*) → indexes/RAG for reference retrieval → **Workflow engine** (event-driven; fan-out/fan-in, self-reflection, human-in-the-loop). Inverts classic RAG: *parse → maintain state → retrieve contextually → reason → surface for validation*.
|
| 113 |
+
|
| 114 |
+
**Strengths.** Best **agent-orchestration** abstractions; schema-first extraction with confidence; excellent for conversational, stateful Q&A over documents; huge community and docs; MIT core.
|
| 115 |
+
|
| 116 |
+
**Weaknesses.** The strongest parsing/extraction (LlamaParse/Extract/Cloud) is **paid SaaS** (free tier limited); framework is code-first (no end-user UI provided); you assemble the product yourself.
|
| 117 |
+
|
| 118 |
+
**Relevance.** ★★★★☆ — the best **mental model and orchestration reference** for our agent loop; we can mirror ADW patterns using our own free-LLM + Docling stack instead of the paid services.
|
| 119 |
+
|
| 120 |
+
---
|
| 121 |
+
|
| 122 |
+
## 2. Comparison Table & Scoring
|
| 123 |
+
|
| 124 |
+
**Rubric (1–5; 5 = best).** Scored for the specific goal: *foundation/reference for a custom, chat-based, production document-processing agent with a 40/60 UI and free-LLM-first model strategy.*
|
| 125 |
+
|
| 126 |
+
| # | Parameter | What a 5 looks like |
|
| 127 |
+
|---|---|---|
|
| 128 |
+
| A | **Feature completeness (doc processing)** | Native OCR, layout, tables, KV, entities, classification, summarization, Q&A, export |
|
| 129 |
+
| B | **Architecture quality & extensibility** | Clean layers, pluggable models/stores, easy to add tools/formats |
|
| 130 |
+
| C | **Ease of customisation** | Fast to bend to our UI/flows without fighting the framework |
|
| 131 |
+
| D | **Production-readiness** | Auth, scaling, observability, error handling, deploy story |
|
| 132 |
+
| E | **UI/UX quality** | Polished, relevant end-user experience for document work |
|
| 133 |
+
| F | **Cost / open-source friendliness** | Permissive license, no forced per-page/API cost, self-hostable |
|
| 134 |
+
| G | **Community & documentation** | Stars, activity, docs depth |
|
| 135 |
+
|
| 136 |
+
| System | A | B | C | D | E | F | G | **Total /35** |
|
| 137 |
+
|---|---|---|---|---|---|---|---|---|
|
| 138 |
+
| **RAGFlow** | 5 | 4 | 3 | 4 | 4 | 5 | 5 | **30** |
|
| 139 |
+
| **Dify** | 3 | 5 | 5 | 5 | 5 | 4 | 5 | **32** |
|
| 140 |
+
| **Docling** | 5 | 5 | 4 | 4 | 1 | 5 | 5 | **29** |
|
| 141 |
+
| **n8n workflows** | 3 | 3 | 5 | 4 | 2 | 3 | 5 | **25** |
|
| 142 |
+
| **LlamaIndex ADW** | 4 | 5 | 4 | 4 | 2 | 3 | 5 | **27** |
|
| 143 |
+
|
| 144 |
+
**Reading the table.** By raw total, **Dify (32)** edges **RAGFlow (30)** — but Dify's lead comes from platform polish, *not* document intelligence (its weakest axis, A=3). When we weight by what this project actually needs — **document-processing depth (A), architecture (B), and customisation (C)** — the picture is:
|
| 145 |
+
|
| 146 |
+
- **Document depth (A):** RAGFlow & Docling lead (5).
|
| 147 |
+
- **Architecture (B):** Dify, Docling, LlamaIndex lead (5).
|
| 148 |
+
- **The honest conclusion is a *combination*, not a single fork.** No one system is simultaneously the deepest extractor *and* the cleanest platform *and* a ready-made 40/60 chat UI.
|
| 149 |
+
|
| 150 |
+
---
|
| 151 |
+
|
| 152 |
+
## 3. Winner & Why
|
| 153 |
+
|
| 154 |
+
**Primary reference foundation: RAGFlow.** It is the only system that already *is* the product we're describing — chat over documents, deep understanding, grounded citations, agentic layer — and it is fully Apache-2.0. It proves the end-to-end pipeline and gives us a battle-tested blueprint for chunking visualization and citation UX.
|
| 155 |
+
|
| 156 |
+
**But the senior-level recommendation is a composed stack, not a fork**, because RAGFlow's infra and UI are too opinionated to host our bespoke 40/60 experience, and its heaviness is unjustified at our stage:
|
| 157 |
+
|
| 158 |
+
> **Recommended build = RAGFlow's pipeline blueprint + Docling as the extraction engine + LlamaIndex-style agentic-workflow orchestration + Dify-grade separation-of-concerns & observability + our own FastAPI backend and React 40/60 UI.**
|
| 159 |
+
|
| 160 |
+
This gives us RAGFlow's proven flow, Docling's best-in-class *and free/local* extraction, LlamaIndex's stateful agent patterns, Dify's clean architecture discipline — with none of the licensing, cost, or lock-in downsides.
|
| 161 |
+
|
| 162 |
+
---
|
| 163 |
+
|
| 164 |
+
## 4. State-of-the-Art Capabilities (what our agent should do)
|
| 165 |
+
|
| 166 |
+
Modern IDP = **OCR + ICR + NLP + VLMs + agentic AI**. Expected capabilities:
|
| 167 |
+
|
| 168 |
+
- **Ingest:** PDF (digital + scanned), DOCX, PPTX, XLSX, images (PNG/JPG/TIFF), email, HTML.
|
| 169 |
+
- **Parse/extract:** layout & reading order, **tables** (structure-aware), **key–value pairs**, **named entities**, line items, multi-page spanning content.
|
| 170 |
+
- **Classify:** document type/category (invoice, contract, form, receipt, report…), zero-shot via LLM.
|
| 171 |
+
- **Summarize:** whole-doc and section-level.
|
| 172 |
+
- **Conversational Q&A:** grounded, **with citations** back to the source span.
|
| 173 |
+
- **Validate / flag:** missing required fields, inconsistencies, anomalies, confidence scoring, **human-in-the-loop** review.
|
| 174 |
+
- **Export:** **JSON** (schema-conformant), CSV/Excel (tables), and structured records to downstream systems.
|
| 175 |
+
|
| 176 |
+
**Field direction:** extraction is shifting from a *spatial* problem to a *semantic reasoning* problem — VLMs/LLMs read layout + meaning together and emit JSON directly, which is more robust to layout drift than legacy OCR. Best practice = **hybrid**: deterministic parser (Docling) for structure + LLM/VLM for semantic extraction, classification, and Q&A.
|
| 177 |
+
|
| 178 |
+
---
|
| 179 |
+
|
| 180 |
+
## 5. Ideal UI/UX (informs the 40/60 build)
|
| 181 |
+
|
| 182 |
+
Validated patterns from current document-AI and AI-chat UX research:
|
| 183 |
+
|
| 184 |
+
- **Split-screen** (chat left / work right) is the established pattern when the AI produces artifacts — see Claude Artifacts, Perplexity. Our **40% chat / 60% workspace** split is exactly this, correctly proportioned.
|
| 185 |
+
- **Document viewer with bounding-box highlights:** extracted fields and citations should **highlight the exact source region** in the rendered document (each cell/field carries a bounding polygon + confidence). This is the single most trust-building feature.
|
| 186 |
+
- **Citations:** numbered inline references linking to expandable source cards / highlighted spans; visually distinguish quoted evidence from synthesis.
|
| 187 |
+
- **Workspace panels (the 60%)** — multiple pages/tabs encouraged: **Document Viewer**, **Extraction/Fields** (editable table with confidence + flags), **Classification** panel, **Summary**, **Export** controls.
|
| 188 |
+
- **Side-panel, no context-switch:** keep the document in view while the AI works.
|
| 189 |
+
- **Streaming + accessibility:** stream responses; `aria-live="polite"` on response containers.
|
| 190 |
+
- **Human-in-the-loop:** low-confidence fields visibly flagged and inline-editable before export.
|
| 191 |
+
|
| 192 |
+
---
|
| 193 |
+
|
| 194 |
+
## 6. Recommended Architecture, LLM & Toolchain Strategy
|
| 195 |
+
|
| 196 |
+
**Agent design patterns (2025–26).** The field has moved past static RAG toward **reasoning/agentic RAG** and multi-agent patterns. For us: an **orchestrator agent** + **tool-calling** + **agentic RAG** (the agent decides *when/what/how* to retrieve), with structured-output tools and human-in-the-loop. Keep it a single well-orchestrated agent with tools (upload, parse, classify, extract, summarize, query, validate, export) before reaching for multi-agent complexity.
|
| 197 |
+
|
| 198 |
+
**LLM strategy (matches the brief).**
|
| 199 |
+
- **Default (free):** **Google Gemini Flash** family — generous free tier, ~1M-token context (great for long documents), native multimodal/vision (can read document images directly).
|
| 200 |
+
- **Fallback (switchable):** **GPT-4o-mini** — cheap, 128k context, reliable tool-calling.
|
| 201 |
+
- **Provider-abstraction layer** so models are swappable per-task (cheap model for classification, stronger for reasoning) — mirroring Dify's model layer.
|
| 202 |
+
|
| 203 |
+
**Toolchain / OCR / orchestration.**
|
| 204 |
+
- **Extraction:** **Docling** (layout, TableFormer tables, OCR for scans, DoclingDocument → JSON/Markdown) — local, MIT, zero per-page cost. Optional VLM path (Gemini vision) for hard scans.
|
| 205 |
+
- **Structured extraction:** **Pydantic schemas + confidence scores** (LlamaExtract pattern), enforced via LLM structured output / tool-calling.
|
| 206 |
+
- **Orchestration:** lightweight agent loop in our own backend (LlamaIndex-style workflow patterns; optionally the LlamaIndex Workflows library) — avoids the heavy multi-service footprint of RAGFlow.
|
| 207 |
+
- **RAG:** chunk (Docling) → embed → vector store (pgvector / Qdrant) → agentic retrieval with citations.
|
| 208 |
+
|
| 209 |
+
**Proposed stack for the build.**
|
| 210 |
+
- **Backend:** **FastAPI** (Python) — async, clean separation (routes / services / agent / tools / schemas), production API.
|
| 211 |
+
- **Frontend:** **React + TypeScript** (Next.js or Vite) — 40/60 layout, document viewer with bounding-box overlays, streaming chat.
|
| 212 |
+
- **Storage:** Postgres (+ pgvector) for metadata + vectors; object storage (local/MinIO/S3) for files.
|
| 213 |
+
- **Models:** Gemini Flash (default) ⇄ GPT-4o-mini (fallback) behind a provider interface.
|
| 214 |
+
- **Extraction:** Docling.
|
| 215 |
+
|
| 216 |
+
---
|
| 217 |
+
|
| 218 |
+
## 7. Sources
|
| 219 |
+
|
| 220 |
+
- https://www.firecrawl.dev/blog/best-open-source-agent-frameworks
|
| 221 |
+
- https://github.com/infiniflow/ragflow
|
| 222 |
+
- https://github.com/langgenius/dify
|
| 223 |
+
- https://github.com/docling-project/docling
|
| 224 |
+
- https://github.com/opendatalab/mineru
|
| 225 |
+
- https://procycons.com/en/blogs/pdf-data-extraction-benchmark/
|
| 226 |
+
- https://n8n.io/workflows/4933-document-parsing-and-data-extraction-with-mistral-ocr-ai-processing-and-multi-channel-delivery/
|
| 227 |
+
- https://data-sleek.com/blog/document-workflow-automation-seamless-data-stack-n8n-ai/
|
| 228 |
+
- https://www.llamaindex.ai/blog/introducing-agentic-document-workflows
|
| 229 |
+
- https://www.llamaindex.ai/workflows
|
| 230 |
+
- https://www.everestgrp.com/report/egr-2025-38-r-7283/
|
| 231 |
+
- https://scalehub.com/2025-idp-guide/
|
| 232 |
+
- https://uxdesign.cc/where-should-ai-sit-in-your-ui-1710a258390e
|
| 233 |
+
- https://thefrontkit.com/blogs/ai-chat-ui-best-practices
|
| 234 |
+
- https://learn.microsoft.com/en-us/azure/ai-services/document-intelligence/prebuilt/layout
|
| 235 |
+
- https://www.azilen.com/blog/agentic-ai-design-patterns/
|
| 236 |
+
- https://learn.microsoft.com/en-us/azure/architecture/ai-ml/guide/ai-agent-design-patterns
|
| 237 |
+
- https://modal.com/blog/8-top-open-source-ocr-models-compared
|
| 238 |
+
- https://www.llamaindex.ai/insights/best-vision-language-models
|
frontend/app/globals.css
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@tailwind base;
|
| 2 |
+
@tailwind components;
|
| 3 |
+
@tailwind utilities;
|
| 4 |
+
|
| 5 |
+
:root {
|
| 6 |
+
--font-sans: "Inter", system-ui, sans-serif;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
html,
|
| 10 |
+
body {
|
| 11 |
+
height: 100%;
|
| 12 |
+
background: #07080c;
|
| 13 |
+
color: #e6e9f0;
|
| 14 |
+
-webkit-font-smoothing: antialiased;
|
| 15 |
+
text-rendering: optimizeLegibility;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
::selection {
|
| 19 |
+
background: rgba(99, 102, 241, 0.35);
|
| 20 |
+
color: #fff;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/* refined scrollbars */
|
| 24 |
+
* {
|
| 25 |
+
scrollbar-width: thin;
|
| 26 |
+
scrollbar-color: #252b3d transparent;
|
| 27 |
+
}
|
| 28 |
+
*::-webkit-scrollbar {
|
| 29 |
+
width: 9px;
|
| 30 |
+
height: 9px;
|
| 31 |
+
}
|
| 32 |
+
*::-webkit-scrollbar-thumb {
|
| 33 |
+
background: #1c2130;
|
| 34 |
+
border-radius: 9px;
|
| 35 |
+
border: 2px solid transparent;
|
| 36 |
+
background-clip: content-box;
|
| 37 |
+
}
|
| 38 |
+
*::-webkit-scrollbar-thumb:hover {
|
| 39 |
+
background: #323a52;
|
| 40 |
+
background-clip: content-box;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
@layer components {
|
| 44 |
+
/* frosted glass surface */
|
| 45 |
+
.glass {
|
| 46 |
+
background: rgba(16, 19, 28, 0.7);
|
| 47 |
+
backdrop-filter: blur(12px);
|
| 48 |
+
-webkit-backdrop-filter: blur(12px);
|
| 49 |
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
| 50 |
+
}
|
| 51 |
+
.card {
|
| 52 |
+
background: linear-gradient(180deg, rgba(22, 26, 38, 0.9), rgba(16, 19, 28, 0.9));
|
| 53 |
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
| 54 |
+
box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.04) inset,
|
| 55 |
+
0 12px 32px -16px rgba(0, 0, 0, 0.8);
|
| 56 |
+
}
|
| 57 |
+
/* gradient hairline used under headers */
|
| 58 |
+
.hairline {
|
| 59 |
+
height: 1px;
|
| 60 |
+
background: linear-gradient(
|
| 61 |
+
90deg,
|
| 62 |
+
transparent,
|
| 63 |
+
rgba(99, 102, 241, 0.4),
|
| 64 |
+
rgba(34, 211, 238, 0.25),
|
| 65 |
+
transparent
|
| 66 |
+
);
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/* skeleton shimmer */
|
| 71 |
+
.skeleton {
|
| 72 |
+
position: relative;
|
| 73 |
+
overflow: hidden;
|
| 74 |
+
background: #161a26;
|
| 75 |
+
}
|
| 76 |
+
.skeleton::after {
|
| 77 |
+
content: "";
|
| 78 |
+
position: absolute;
|
| 79 |
+
inset: 0;
|
| 80 |
+
transform: translateX(-100%);
|
| 81 |
+
background: linear-gradient(
|
| 82 |
+
90deg,
|
| 83 |
+
transparent,
|
| 84 |
+
rgba(255, 255, 255, 0.06),
|
| 85 |
+
transparent
|
| 86 |
+
);
|
| 87 |
+
animation: shimmer 1.6s infinite;
|
| 88 |
+
}
|
| 89 |
+
@keyframes shimmer {
|
| 90 |
+
100% {
|
| 91 |
+
transform: translateX(100%);
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/* streaming caret */
|
| 96 |
+
.caret::after {
|
| 97 |
+
content: "▋";
|
| 98 |
+
margin-left: 1px;
|
| 99 |
+
color: #818cf8;
|
| 100 |
+
animation: caret-blink 1s steps(2) infinite;
|
| 101 |
+
}
|
| 102 |
+
@keyframes caret-blink {
|
| 103 |
+
0%,
|
| 104 |
+
100% {
|
| 105 |
+
opacity: 0;
|
| 106 |
+
}
|
| 107 |
+
50% {
|
| 108 |
+
opacity: 1;
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
/* typing dots */
|
| 113 |
+
.typing-dot {
|
| 114 |
+
animation: blink 1.4s infinite both;
|
| 115 |
+
}
|
| 116 |
+
.typing-dot:nth-child(2) {
|
| 117 |
+
animation-delay: 0.2s;
|
| 118 |
+
}
|
| 119 |
+
.typing-dot:nth-child(3) {
|
| 120 |
+
animation-delay: 0.4s;
|
| 121 |
+
}
|
| 122 |
+
@keyframes blink {
|
| 123 |
+
0%,
|
| 124 |
+
80%,
|
| 125 |
+
100% {
|
| 126 |
+
opacity: 0.2;
|
| 127 |
+
}
|
| 128 |
+
40% {
|
| 129 |
+
opacity: 1;
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/* ---------------- markdown prose ---------------- */
|
| 134 |
+
.prose-chat {
|
| 135 |
+
font-size: 0.9rem;
|
| 136 |
+
line-height: 1.65;
|
| 137 |
+
color: #d6dae6;
|
| 138 |
+
}
|
| 139 |
+
.prose-chat > *:first-child {
|
| 140 |
+
margin-top: 0;
|
| 141 |
+
}
|
| 142 |
+
.prose-chat > *:last-child {
|
| 143 |
+
margin-bottom: 0;
|
| 144 |
+
}
|
| 145 |
+
.prose-chat p {
|
| 146 |
+
margin: 0.5rem 0;
|
| 147 |
+
}
|
| 148 |
+
.prose-chat ul,
|
| 149 |
+
.prose-chat ol {
|
| 150 |
+
margin: 0.5rem 0;
|
| 151 |
+
padding-left: 1.25rem;
|
| 152 |
+
}
|
| 153 |
+
.prose-chat li {
|
| 154 |
+
margin: 0.2rem 0;
|
| 155 |
+
}
|
| 156 |
+
.prose-chat ul {
|
| 157 |
+
list-style: disc;
|
| 158 |
+
}
|
| 159 |
+
.prose-chat ol {
|
| 160 |
+
list-style: decimal;
|
| 161 |
+
}
|
| 162 |
+
.prose-chat h1,
|
| 163 |
+
.prose-chat h2,
|
| 164 |
+
.prose-chat h3 {
|
| 165 |
+
font-weight: 600;
|
| 166 |
+
color: #fff;
|
| 167 |
+
margin: 0.8rem 0 0.4rem;
|
| 168 |
+
line-height: 1.3;
|
| 169 |
+
}
|
| 170 |
+
.prose-chat h1 {
|
| 171 |
+
font-size: 1.05rem;
|
| 172 |
+
}
|
| 173 |
+
.prose-chat h2 {
|
| 174 |
+
font-size: 1rem;
|
| 175 |
+
}
|
| 176 |
+
.prose-chat h3 {
|
| 177 |
+
font-size: 0.92rem;
|
| 178 |
+
}
|
| 179 |
+
.prose-chat code {
|
| 180 |
+
background: #0b0d14;
|
| 181 |
+
padding: 0.1rem 0.4rem;
|
| 182 |
+
border-radius: 5px;
|
| 183 |
+
font-size: 0.82em;
|
| 184 |
+
border: 1px solid rgba(255, 255, 255, 0.06);
|
| 185 |
+
}
|
| 186 |
+
.prose-chat pre {
|
| 187 |
+
background: #07080c;
|
| 188 |
+
border: 1px solid #252b3d;
|
| 189 |
+
padding: 0.85rem;
|
| 190 |
+
border-radius: 10px;
|
| 191 |
+
overflow-x: auto;
|
| 192 |
+
margin: 0.6rem 0;
|
| 193 |
+
}
|
| 194 |
+
.prose-chat pre code {
|
| 195 |
+
background: transparent;
|
| 196 |
+
border: 0;
|
| 197 |
+
padding: 0;
|
| 198 |
+
}
|
| 199 |
+
.prose-chat strong {
|
| 200 |
+
color: #fff;
|
| 201 |
+
font-weight: 600;
|
| 202 |
+
}
|
| 203 |
+
.prose-chat a {
|
| 204 |
+
color: #a5b4fc;
|
| 205 |
+
text-decoration: underline;
|
| 206 |
+
text-underline-offset: 2px;
|
| 207 |
+
}
|
| 208 |
+
.prose-chat blockquote {
|
| 209 |
+
border-left: 3px solid #4f46e5;
|
| 210 |
+
padding-left: 0.8rem;
|
| 211 |
+
margin: 0.6rem 0;
|
| 212 |
+
color: #aeb4c4;
|
| 213 |
+
}
|
| 214 |
+
.prose-chat table {
|
| 215 |
+
border-collapse: collapse;
|
| 216 |
+
margin: 0.6rem 0;
|
| 217 |
+
font-size: 0.84em;
|
| 218 |
+
width: 100%;
|
| 219 |
+
}
|
| 220 |
+
.prose-chat th,
|
| 221 |
+
.prose-chat td {
|
| 222 |
+
border: 1px solid #252b3d;
|
| 223 |
+
padding: 0.35rem 0.6rem;
|
| 224 |
+
text-align: left;
|
| 225 |
+
}
|
| 226 |
+
.prose-chat th {
|
| 227 |
+
background: #10131c;
|
| 228 |
+
font-weight: 600;
|
| 229 |
+
color: #c7d2fe;
|
| 230 |
+
}
|
| 231 |
+
.prose-chat hr {
|
| 232 |
+
border: 0;
|
| 233 |
+
border-top: 1px solid #252b3d;
|
| 234 |
+
margin: 0.8rem 0;
|
| 235 |
+
}
|
frontend/app/layout.tsx
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Metadata } from "next";
|
| 2 |
+
import "./globals.css";
|
| 3 |
+
|
| 4 |
+
export const metadata: Metadata = {
|
| 5 |
+
title: "DocAgent — Document Processing AI",
|
| 6 |
+
description:
|
| 7 |
+
"Monkhub Innovations — chat-based document processing agent. Ingest, extract, classify, summarize, and query documents.",
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
export default function RootLayout({
|
| 11 |
+
children,
|
| 12 |
+
}: {
|
| 13 |
+
children: React.ReactNode;
|
| 14 |
+
}) {
|
| 15 |
+
return (
|
| 16 |
+
<html lang="en">
|
| 17 |
+
<head>
|
| 18 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 19 |
+
<link
|
| 20 |
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
| 21 |
+
rel="stylesheet"
|
| 22 |
+
/>
|
| 23 |
+
</head>
|
| 24 |
+
<body className="font-sans antialiased">{children}</body>
|
| 25 |
+
</html>
|
| 26 |
+
);
|
| 27 |
+
}
|
frontend/app/page.tsx
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 4 |
+
import { FileText } from "lucide-react";
|
| 5 |
+
|
| 6 |
+
import { api } from "@/lib/api";
|
| 7 |
+
import type {
|
| 8 |
+
Citation,
|
| 9 |
+
DocumentDetail,
|
| 10 |
+
DocumentMeta,
|
| 11 |
+
HealthInfo,
|
| 12 |
+
} from "@/lib/types";
|
| 13 |
+
import ChatPanel from "@/components/ChatPanel";
|
| 14 |
+
import Workspace from "@/components/Workspace";
|
| 15 |
+
import Sidebar from "@/components/Sidebar";
|
| 16 |
+
import ProviderBadge from "@/components/ProviderBadge";
|
| 17 |
+
import Toaster from "@/components/Toaster";
|
| 18 |
+
|
| 19 |
+
export default function Home() {
|
| 20 |
+
const [docs, setDocs] = useState<DocumentMeta[]>([]);
|
| 21 |
+
const [activeId, setActiveId] = useState<string | null>(null);
|
| 22 |
+
const [detail, setDetail] = useState<DocumentDetail | null>(null);
|
| 23 |
+
const [health, setHealth] = useState<HealthInfo | null>(null);
|
| 24 |
+
const [provider, setProvider] = useState<string | null>(null);
|
| 25 |
+
const [focusCitation, setFocusCitation] = useState<Citation | null>(null);
|
| 26 |
+
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
| 27 |
+
|
| 28 |
+
const refreshDocs = useCallback(async () => {
|
| 29 |
+
try {
|
| 30 |
+
setDocs(await api.listDocuments());
|
| 31 |
+
} catch {
|
| 32 |
+
/* backend may be starting */
|
| 33 |
+
}
|
| 34 |
+
}, []);
|
| 35 |
+
|
| 36 |
+
const loadDetail = useCallback(async (id: string) => {
|
| 37 |
+
try {
|
| 38 |
+
setDetail(await api.getDocument(id));
|
| 39 |
+
} catch {
|
| 40 |
+
/* ignore */
|
| 41 |
+
}
|
| 42 |
+
}, []);
|
| 43 |
+
|
| 44 |
+
useEffect(() => {
|
| 45 |
+
api.health().then(setHealth).catch(() => setHealth(null));
|
| 46 |
+
refreshDocs();
|
| 47 |
+
}, [refreshDocs]);
|
| 48 |
+
|
| 49 |
+
// Poll the active document while processing so results stream in.
|
| 50 |
+
useEffect(() => {
|
| 51 |
+
if (!activeId) return;
|
| 52 |
+
loadDetail(activeId);
|
| 53 |
+
pollRef.current && clearInterval(pollRef.current);
|
| 54 |
+
pollRef.current = setInterval(async () => {
|
| 55 |
+
const d = await api.getDocument(activeId).catch(() => null);
|
| 56 |
+
if (d) {
|
| 57 |
+
setDetail(d);
|
| 58 |
+
if (d.status === "ready" || d.status === "failed") {
|
| 59 |
+
pollRef.current && clearInterval(pollRef.current);
|
| 60 |
+
refreshDocs();
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
}, 1600);
|
| 64 |
+
return () => {
|
| 65 |
+
pollRef.current && clearInterval(pollRef.current);
|
| 66 |
+
};
|
| 67 |
+
}, [activeId, loadDetail, refreshDocs]);
|
| 68 |
+
|
| 69 |
+
const handleUploaded = async (meta: DocumentMeta) => {
|
| 70 |
+
await refreshDocs();
|
| 71 |
+
setActiveId(meta.id);
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
return (
|
| 75 |
+
<div className="relative flex h-screen w-screen overflow-hidden bg-surface-950 text-ink-50">
|
| 76 |
+
{/* ambient background */}
|
| 77 |
+
<div className="pointer-events-none absolute inset-0 bg-grid-faint bg-[size:42px_42px]" />
|
| 78 |
+
<div className="pointer-events-none absolute inset-x-0 top-0 h-80 bg-radial-brand" />
|
| 79 |
+
|
| 80 |
+
<Toaster />
|
| 81 |
+
|
| 82 |
+
<div className="relative z-10 flex h-full w-full">
|
| 83 |
+
<Sidebar
|
| 84 |
+
docs={docs}
|
| 85 |
+
activeId={activeId}
|
| 86 |
+
onSelect={setActiveId}
|
| 87 |
+
onUploaded={handleUploaded}
|
| 88 |
+
onDeleted={async (id) => {
|
| 89 |
+
await api.deleteDocument(id).catch(() => {});
|
| 90 |
+
if (activeId === id) {
|
| 91 |
+
setActiveId(null);
|
| 92 |
+
setDetail(null);
|
| 93 |
+
}
|
| 94 |
+
refreshDocs();
|
| 95 |
+
}}
|
| 96 |
+
/>
|
| 97 |
+
|
| 98 |
+
<div className="flex min-w-0 flex-1 flex-col">
|
| 99 |
+
{/* header */}
|
| 100 |
+
<header className="relative flex items-center justify-between px-5 py-3">
|
| 101 |
+
<div className="flex items-center gap-3">
|
| 102 |
+
<div className="relative flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-brand-400 via-brand-500 to-brand-700 shadow-glow">
|
| 103 |
+
<FileText className="text-white" size={18} />
|
| 104 |
+
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full bg-accent-400 ring-2 ring-surface-950" />
|
| 105 |
+
</div>
|
| 106 |
+
<div>
|
| 107 |
+
<h1 className="flex items-center gap-2 text-sm font-semibold leading-tight text-white">
|
| 108 |
+
DocAgent
|
| 109 |
+
<span className="rounded-md bg-white/[0.06] px-1.5 py-0.5 text-[10px] font-medium tracking-wide text-brand-200 ring-1 ring-white/10">
|
| 110 |
+
Monkhub
|
| 111 |
+
</span>
|
| 112 |
+
</h1>
|
| 113 |
+
<p className="text-[11px] leading-tight text-white/40">
|
| 114 |
+
Document Processing AI Agent
|
| 115 |
+
</p>
|
| 116 |
+
</div>
|
| 117 |
+
</div>
|
| 118 |
+
<ProviderBadge health={health} provider={provider} onChange={setProvider} />
|
| 119 |
+
</header>
|
| 120 |
+
<div className="hairline" />
|
| 121 |
+
|
| 122 |
+
{/* 40 / 60 split */}
|
| 123 |
+
<div className="flex min-h-0 flex-1">
|
| 124 |
+
<section className="flex w-2/5 min-w-[360px] flex-col border-r border-white/[0.06] bg-surface-900/40">
|
| 125 |
+
<ChatPanel
|
| 126 |
+
documentId={activeId}
|
| 127 |
+
detail={detail}
|
| 128 |
+
provider={provider}
|
| 129 |
+
onCitationClick={setFocusCitation}
|
| 130 |
+
/>
|
| 131 |
+
</section>
|
| 132 |
+
|
| 133 |
+
<section className="flex w-3/5 min-w-0 flex-1 flex-col bg-surface-950/60">
|
| 134 |
+
<Workspace
|
| 135 |
+
detail={detail}
|
| 136 |
+
provider={provider}
|
| 137 |
+
focusCitation={focusCitation}
|
| 138 |
+
onRefresh={() => activeId && loadDetail(activeId)}
|
| 139 |
+
/>
|
| 140 |
+
</section>
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
);
|
| 146 |
+
}
|