Jai-rathore29 commited on
Commit
f65e025
·
0 Parent(s):

Deploy: DocAgent backend (deterministic date-anomaly fix)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +28 -0
  2. .gitignore +34 -0
  3. Dockerfile +68 -0
  4. README.md +164 -0
  5. backend/.dockerignore +22 -0
  6. backend/.env.example +36 -0
  7. backend/Dockerfile +61 -0
  8. backend/app/__init__.py +0 -0
  9. backend/app/agent/__init__.py +0 -0
  10. backend/app/agent/orchestrator.py +92 -0
  11. backend/app/agent/prompts.py +21 -0
  12. backend/app/agent/tools.py +139 -0
  13. backend/app/api/__init__.py +0 -0
  14. backend/app/api/actions.py +88 -0
  15. backend/app/api/chat.py +75 -0
  16. backend/app/api/documents.py +79 -0
  17. backend/app/api/meta.py +30 -0
  18. backend/app/core/__init__.py +0 -0
  19. backend/app/core/config.py +97 -0
  20. backend/app/core/logging.py +26 -0
  21. backend/app/llm/__init__.py +0 -0
  22. backend/app/llm/base.py +110 -0
  23. backend/app/llm/gemini_provider.py +157 -0
  24. backend/app/llm/openai_provider.py +140 -0
  25. backend/app/llm/registry.py +81 -0
  26. backend/app/main.py +61 -0
  27. backend/app/schemas/__init__.py +0 -0
  28. backend/app/schemas/documents.py +172 -0
  29. backend/app/schemas/templates.py +22 -0
  30. backend/app/services/__init__.py +0 -0
  31. backend/app/services/anomaly.py +241 -0
  32. backend/app/services/classification.py +51 -0
  33. backend/app/services/export.py +99 -0
  34. backend/app/services/extraction.py +273 -0
  35. backend/app/services/ingestion.py +243 -0
  36. backend/app/services/pipeline.py +97 -0
  37. backend/app/services/qa.py +63 -0
  38. backend/app/services/storage.py +86 -0
  39. backend/app/services/summary.py +58 -0
  40. backend/app/services/vectorstore.py +120 -0
  41. backend/requirements.txt +29 -0
  42. backend/scripts/prefetch_models.py +36 -0
  43. docker-compose.yml +24 -0
  44. docs/ARCHITECTURE.md +65 -0
  45. docs/DEPLOY.md +125 -0
  46. docs/OVERVIEW.md +253 -0
  47. docs/RESEARCH.md +238 -0
  48. frontend/app/globals.css +235 -0
  49. frontend/app/layout.tsx +27 -0
  50. 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
+ }