Spaces:
Configuration error
Configuration error
title
#1
by sriharshini2901 - opened
- LICENSE +0 -19
- README.md +14 -243
- __init__.py +0 -0
- app.py +0 -182
- audio.py +0 -53
- db.py +0 -122
- exporters.py +0 -44
- extractor.py +0 -147
- llm_local.py +0 -73
- ocr.py +0 -44
- pdf_extract.py +0 -48
- pipeline.py +0 -110
- requirements.txt +2 -19
- video.py +0 -34
LICENSE
DELETED
|
@@ -1,19 +0,0 @@
|
|
| 1 |
-
Local AI - Offline Multi-Modal Document Intelligence System
|
| 2 |
-
Copyright (C) 2026 the project authors
|
| 3 |
-
|
| 4 |
-
This program is free software: you can redistribute it and/or modify
|
| 5 |
-
it under the terms of the GNU General Public License as published by
|
| 6 |
-
the Free Software Foundation, either version 3 of the License, or
|
| 7 |
-
(at your option) any later version.
|
| 8 |
-
|
| 9 |
-
This program is distributed in the hope that it will be useful,
|
| 10 |
-
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
| 11 |
-
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
| 12 |
-
GNU General Public License for more details.
|
| 13 |
-
|
| 14 |
-
The full text of the GNU General Public License v3.0 is available at:
|
| 15 |
-
https://www.gnu.org/licenses/gpl-3.0.txt
|
| 16 |
-
|
| 17 |
-
You should have received a copy of the GNU General Public License
|
| 18 |
-
along with this program. If you did not, obtain one from the URL above
|
| 19 |
-
before distributing this software.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -1,248 +1,19 @@
|
|
| 1 |
-
<<<<<<< HEAD
|
| 2 |
-
# Local AI — Offline Multi-Modal Document Intelligence System
|
| 3 |
-
|
| 4 |
-
Turn images, PDFs, plain text, audio, and video into structured JSON — **100% offline, CPU-only.**
|
| 5 |
-
No cloud APIs, no API keys, no telemetry, no internet dependency after setup.
|
| 6 |
-
|
| 7 |
-
Built with **Streamlit** (not React/FastAPI — swapped per project requirements for a single-process,
|
| 8 |
-
easy-to-run app), Tesseract OCR, and a lightweight rule-based local extraction engine, with an
|
| 9 |
-
optional upgrade path to a real local LLM via `llama-cpp-python`.
|
| 10 |
-
|
| 11 |
---
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
- **SQLite storage**, full **History** view, **Search**, **Dashboard** with charts
|
| 23 |
-
- **Export** to JSON / CSV / Excel
|
| 24 |
-
- **Duplicate detection** via SHA-256 file hashing
|
| 25 |
-
- **Demo Mode** (one click, no upload needed)
|
| 26 |
-
|
| 27 |
-
This mode (`rules` in Settings) is what the app uses by default and what was tested end-to-end
|
| 28 |
-
while building this — see "What was tested" below.
|
| 29 |
-
|
| 30 |
-
## 🔌 Optional upgrades (require one-time internet access on *your* machine)
|
| 31 |
-
|
| 32 |
-
| Feature | Install | Notes |
|
| 33 |
-
|---|---|---|
|
| 34 |
-
| Real local LLM extraction | `pip install llama-cpp-python` + a `.gguf` model in `models/` | Switch to "llm" mode in Settings. See `backend/llm_local.py` for model recommendations (TinyLlama, Phi-3-mini, Qwen2.5-3B). |
|
| 35 |
-
| Audio transcription | `pip install openai-whisper` + `ffmpeg` on PATH | Powers `.mp3/.wav/.m4a` uploads |
|
| 36 |
-
| Video transcription | same as above | Extracts audio via ffmpeg, then transcribes |
|
| 37 |
-
|
| 38 |
-
Once installed, these still run **entirely locally / CPU-only** — the "offline" install step is a
|
| 39 |
-
one-time download of the model file, not a runtime API call.
|
| 40 |
-
|
| 41 |
-
---
|
| 42 |
-
|
| 43 |
-
## Quickstart
|
| 44 |
-
|
| 45 |
-
```bash
|
| 46 |
-
git clone <this-repo>
|
| 47 |
-
cd local-ai
|
| 48 |
-
python -m venv venv && source venv/bin/activate # optional but recommended
|
| 49 |
-
pip install -r requirements.txt
|
| 50 |
-
|
| 51 |
-
# Tesseract binary (required for OCR):
|
| 52 |
-
# Ubuntu/Debian: sudo apt install tesseract-ocr
|
| 53 |
-
# macOS: brew install tesseract
|
| 54 |
-
# Windows: https://github.com/UB-Mannheim/tesseract/wiki
|
| 55 |
-
|
| 56 |
-
streamlit run app.py
|
| 57 |
-
```
|
| 58 |
-
|
| 59 |
-
Open the URL Streamlit prints (usually http://localhost:8501). Try **Home → Run Demo Mode**, or
|
| 60 |
-
upload a file from `sample_docs/`.
|
| 61 |
-
|
| 62 |
---
|
| 63 |
|
| 64 |
-
#
|
| 65 |
-
|
| 66 |
-
```
|
| 67 |
-
Input (image/pdf/text/audio/video)
|
| 68 |
-
│
|
| 69 |
-
▼
|
| 70 |
-
OCR / Transcription (Tesseract / pdfplumber / Whisper)
|
| 71 |
-
│
|
| 72 |
-
▼
|
| 73 |
-
Text Cleaning
|
| 74 |
-
│
|
| 75 |
-
▼
|
| 76 |
-
Local AI Extraction (rule-based engine, or local LLM via llama.cpp)
|
| 77 |
-
│
|
| 78 |
-
▼
|
| 79 |
-
Structured JSON
|
| 80 |
-
│
|
| 81 |
-
▼
|
| 82 |
-
SQLite Storage ──► Dashboard / History / Search / Export
|
| 83 |
-
```
|
| 84 |
-
|
| 85 |
-
## Folder structure
|
| 86 |
-
|
| 87 |
-
```
|
| 88 |
-
local-ai/
|
| 89 |
-
├── app.py # Streamlit UI (Home, Upload, Dashboard, History, Search, Settings)
|
| 90 |
-
├── backend/
|
| 91 |
-
│ ├── ocr.py # Tesseract image OCR
|
| 92 |
-
│ ├── pdf_extract.py # PDF text extraction + OCR fallback for scans
|
| 93 |
-
│ ├── extractor.py # Document classification + rule-based JSON extraction
|
| 94 |
-
│ ├── llm_local.py # Optional llama-cpp-python local LLM extraction
|
| 95 |
-
│ ├── audio.py # Optional Whisper transcription
|
| 96 |
-
│ ├── video.py # Optional ffmpeg audio extraction + Whisper
|
| 97 |
-
│ ├── pipeline.py # Orchestrates the full flow + status updates
|
| 98 |
-
│ ├── db.py # SQLite storage (stdlib sqlite3, no ORM dependency)
|
| 99 |
-
│ └── exporters.py # JSON / CSV / Excel export
|
| 100 |
-
├── prompts/
|
| 101 |
-
│ └── templates.py # Prompt templates per document type (used in LLM mode)
|
| 102 |
-
├── sample_docs/ # Sample files for quick testing / Demo Mode
|
| 103 |
-
├── models/ # Drop a .gguf model here to enable LLM mode
|
| 104 |
-
├── data/ # SQLite database lives here (created at runtime)
|
| 105 |
-
├── requirements.txt
|
| 106 |
-
├── LICENSE # GPLv3
|
| 107 |
-
└── README.md
|
| 108 |
-
```
|
| 109 |
-
|
| 110 |
-
## API surface (internal, called by the Streamlit UI)
|
| 111 |
-
|
| 112 |
-
These are plain Python functions, not HTTP endpoints (no separate backend server is
|
| 113 |
-
needed since Streamlit handles both UI and processing in one process):
|
| 114 |
-
|
| 115 |
-
- `pipeline.process_file(filename, bytes, mode, model_path, ocr_lang, status_callback)`
|
| 116 |
-
- `db.list_documents()`, `db.search_documents(query)`, `db.get_document(id)`, `db.delete_document(id)`, `db.stats()`
|
| 117 |
-
- `exporters.to_json_bytes(rows)`, `to_csv_bytes(rows)`, `to_excel_bytes(rows)`
|
| 118 |
-
|
| 119 |
-
If you specifically need a separate REST API (e.g. to call from another app), the same
|
| 120 |
-
`backend/` modules can be wrapped in FastAPI with a thin `main.py` — they have no
|
| 121 |
-
Streamlit dependency themselves.
|
| 122 |
-
|
| 123 |
-
## What was tested while building this (in a sandboxed, no-internet environment)
|
| 124 |
-
|
| 125 |
-
- Image OCR on a generated test image ✅
|
| 126 |
-
- PDF native-text extraction on a generated test PDF ✅
|
| 127 |
-
- Document classification across invoice/resume/meeting-notes samples ✅
|
| 128 |
-
- Full entity extraction (emails, phones, dates, amounts, orgs, names) ✅
|
| 129 |
-
- SQLite insert/list/search/stats/delete ✅
|
| 130 |
-
- Duplicate detection via hashing ✅
|
| 131 |
-
- JSON/CSV/Excel export ✅
|
| 132 |
-
|
| 133 |
-
Not testable in that environment (no internet to install/download): the Streamlit UI render
|
| 134 |
-
itself, `llama-cpp-python` LLM mode, and Whisper audio/video transcription. The code for all
|
| 135 |
-
three is complete and follows each library's standard, stable API — install the optional
|
| 136 |
-
dependencies above to light them up.
|
| 137 |
-
|
| 138 |
-
## Security & privacy
|
| 139 |
-
|
| 140 |
-
No cloud calls, no telemetry, no analytics, no external API keys anywhere in this codebase.
|
| 141 |
-
All processing and storage stays on the machine running `streamlit run app.py`.
|
| 142 |
-
|
| 143 |
-
## License
|
| 144 |
-
|
| 145 |
-
GPLv3 — see [LICENSE](LICENSE).
|
| 146 |
-
|
| 147 |
-
## Future improvements
|
| 148 |
-
|
| 149 |
-
- Folder-watch mode for auto-processing new files
|
| 150 |
-
- Custom JSON schema builder in Settings
|
| 151 |
-
- Batch processing queue with pause/resume/retry
|
| 152 |
-
- Side-by-side original-document + JSON viewer with click-to-highlight
|
| 153 |
-
- Multi-user auth if deployed beyond a single local user
|
| 154 |
-
=======
|
| 155 |
-
# offline notes analyser
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
## Getting started
|
| 160 |
-
|
| 161 |
-
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
|
| 162 |
-
|
| 163 |
-
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
|
| 164 |
-
|
| 165 |
-
## Add your files
|
| 166 |
-
|
| 167 |
-
* [Create](https://docs.gitlab.com/user/project/repository/web_editor/#create-a-file) or [upload](https://docs.gitlab.com/user/project/repository/web_editor/#upload-a-file) files
|
| 168 |
-
* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
|
| 169 |
-
|
| 170 |
-
```
|
| 171 |
-
cd existing_repo
|
| 172 |
-
git remote add origin https://code.swecha.org/sriharshini2901/offline-notes-analyser.git
|
| 173 |
-
git branch -M main
|
| 174 |
-
git push -uf origin main
|
| 175 |
-
```
|
| 176 |
-
|
| 177 |
-
## Integrate with your tools
|
| 178 |
-
|
| 179 |
-
* [Set up project integrations](https://code.swecha.org/sriharshini2901/offline-notes-analyser/-/settings/integrations)
|
| 180 |
-
|
| 181 |
-
## Collaborate with your team
|
| 182 |
-
|
| 183 |
-
* [Invite team members and collaborators](https://docs.gitlab.com/user/project/members/)
|
| 184 |
-
* [Create a new merge request](https://docs.gitlab.com/user/project/merge_requests/creating_merge_requests/)
|
| 185 |
-
* [Automatically close issues from merge requests](https://docs.gitlab.com/user/project/issues/managing_issues/#closing-issues-automatically)
|
| 186 |
-
* [Enable merge request approvals](https://docs.gitlab.com/user/project/merge_requests/approvals/)
|
| 187 |
-
* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
|
| 188 |
-
|
| 189 |
-
## Test and Deploy
|
| 190 |
-
|
| 191 |
-
Use the built-in continuous integration in GitLab.
|
| 192 |
-
|
| 193 |
-
* [Get started with GitLab CI/CD](https://docs.gitlab.com/ci/quick_start/)
|
| 194 |
-
* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/user/application_security/sast/)
|
| 195 |
-
* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/topics/autodevops/requirements/)
|
| 196 |
-
* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/user/clusters/agent/)
|
| 197 |
-
* [Set up protected environments](https://docs.gitlab.com/ci/environments/protected_environments/)
|
| 198 |
-
|
| 199 |
-
***
|
| 200 |
-
|
| 201 |
-
# Editing this README
|
| 202 |
-
|
| 203 |
-
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
|
| 204 |
-
|
| 205 |
-
## Suggestions for a good README
|
| 206 |
-
|
| 207 |
-
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
|
| 208 |
-
|
| 209 |
-
## Name
|
| 210 |
-
Choose a self-explaining name for your project.
|
| 211 |
-
|
| 212 |
-
## Description
|
| 213 |
-
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
|
| 214 |
-
|
| 215 |
-
## Badges
|
| 216 |
-
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
|
| 217 |
-
|
| 218 |
-
## Visuals
|
| 219 |
-
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
|
| 220 |
-
|
| 221 |
-
## Installation
|
| 222 |
-
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
|
| 223 |
-
|
| 224 |
-
## Usage
|
| 225 |
-
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
|
| 226 |
-
|
| 227 |
-
## Support
|
| 228 |
-
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
|
| 229 |
-
|
| 230 |
-
## Roadmap
|
| 231 |
-
If you have ideas for releases in the future, it is a good idea to list them in the README.
|
| 232 |
-
|
| 233 |
-
## Contributing
|
| 234 |
-
State if you are open to contributions and what your requirements are for accepting them.
|
| 235 |
-
|
| 236 |
-
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
|
| 237 |
-
|
| 238 |
-
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
|
| 239 |
-
|
| 240 |
-
## Authors and acknowledgment
|
| 241 |
-
Show your appreciation to those who have contributed to the project.
|
| 242 |
|
| 243 |
-
|
| 244 |
-
For open source projects, say how it is licensed.
|
| 245 |
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
>>>>>>> 5198c728ba5184d464aecc5842b792739ae0ed27
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Offlinenotesanalyzer
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 8501
|
| 8 |
+
tags:
|
| 9 |
+
- streamlit
|
| 10 |
+
pinned: false
|
| 11 |
+
short_description: Streamlit template space
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# Welcome to Streamlit!
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
Edit `/src/streamlit_app.py` to customize this app to your heart's desire. :heart:
|
|
|
|
| 17 |
|
| 18 |
+
If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
|
| 19 |
+
forums](https://discuss.streamlit.io).
|
|
|
__init__.py
DELETED
|
File without changes
|
app.py
DELETED
|
@@ -1,182 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
app.py - Offline Notes Analyser
|
| 3 |
-
Streamlit front-end. Run with: streamlit run app.py
|
| 4 |
-
"""
|
| 5 |
-
import sys
|
| 6 |
-
import os
|
| 7 |
-
import json
|
| 8 |
-
import time
|
| 9 |
-
import logging
|
| 10 |
-
|
| 11 |
-
logging.getLogger("pdfminer").setLevel(logging.ERROR)
|
| 12 |
-
|
| 13 |
-
sys.path.insert(0, os.path.dirname(__file__))
|
| 14 |
-
|
| 15 |
-
import streamlit as st
|
| 16 |
-
import pandas as pd
|
| 17 |
-
|
| 18 |
-
from backend import db, pipeline, exporters
|
| 19 |
-
|
| 20 |
-
st.set_page_config(
|
| 21 |
-
page_title="Offline Notes Analyser",
|
| 22 |
-
page_icon="🧠",
|
| 23 |
-
layout="wide",
|
| 24 |
-
initial_sidebar_state="expanded",
|
| 25 |
-
)
|
| 26 |
-
|
| 27 |
-
db.init_db()
|
| 28 |
-
|
| 29 |
-
# Fixed settings - kept simple, no settings page.
|
| 30 |
-
PIPELINE_MODE = "rules" # switch to "llm" in code if you set up a local model
|
| 31 |
-
MODEL_PATH = "models/model.gguf"
|
| 32 |
-
OCR_LANG = "eng"
|
| 33 |
-
|
| 34 |
-
if "page" not in st.session_state:
|
| 35 |
-
st.session_state.page = "Analyze"
|
| 36 |
-
|
| 37 |
-
# ------------------------------------------------------------------ style --
|
| 38 |
-
st.markdown("""
|
| 39 |
-
<style>
|
| 40 |
-
.metric-card {
|
| 41 |
-
border:1px solid #2a2a2a22; border-radius:12px; padding:16px;
|
| 42 |
-
background:linear-gradient(135deg, #f8fafc 0%, #eef2ff 100%);
|
| 43 |
-
}
|
| 44 |
-
</style>
|
| 45 |
-
""", unsafe_allow_html=True)
|
| 46 |
-
|
| 47 |
-
# ---------------------------------------------------------------- sidebar --
|
| 48 |
-
with st.sidebar:
|
| 49 |
-
st.markdown("## 🧠 Offline Notes Analyser")
|
| 50 |
-
st.caption("Upload a file, get structured notes, search them later.")
|
| 51 |
-
st.session_state.page = st.radio(
|
| 52 |
-
"Navigate",
|
| 53 |
-
["Analyze", "History"],
|
| 54 |
-
index=["Analyze", "History"].index(st.session_state.page),
|
| 55 |
-
label_visibility="collapsed",
|
| 56 |
-
)
|
| 57 |
-
st.divider()
|
| 58 |
-
s = db.stats()
|
| 59 |
-
st.metric("Notes analyzed", s["total_documents"])
|
| 60 |
-
st.success("🟢 100% Offline")
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
# ===================================================== ANALYZE (UPLOAD) ===
|
| 64 |
-
def render_analyze():
|
| 65 |
-
st.title("📤 Analyze")
|
| 66 |
-
st.caption("Drop a file below. Everything happens locally on this machine.")
|
| 67 |
-
|
| 68 |
-
uploaded = st.file_uploader(
|
| 69 |
-
"Choose a file",
|
| 70 |
-
type=["jpg", "jpeg", "png", "pdf", "txt", "mp3", "wav", "m4a", "mp4", "mov", "mkv"],
|
| 71 |
-
accept_multiple_files=True,
|
| 72 |
-
)
|
| 73 |
-
|
| 74 |
-
if not uploaded:
|
| 75 |
-
st.info("Upload an image, PDF, text file, audio, or video to begin.")
|
| 76 |
-
return
|
| 77 |
-
|
| 78 |
-
for f in uploaded:
|
| 79 |
-
filename, file_bytes = f.name, f.read()
|
| 80 |
-
with st.container(border=True):
|
| 81 |
-
st.markdown(f"**{filename}**")
|
| 82 |
-
status_box = st.empty()
|
| 83 |
-
progress = st.progress(0)
|
| 84 |
-
steps_seen = []
|
| 85 |
-
|
| 86 |
-
def on_step(step_name, _seen=steps_seen, _box=status_box, _prog=progress):
|
| 87 |
-
_seen.append(step_name)
|
| 88 |
-
_box.write(" → ".join(_seen))
|
| 89 |
-
_prog.progress(min(0.95, len(_seen) * 0.15))
|
| 90 |
-
|
| 91 |
-
try:
|
| 92 |
-
result = pipeline.process_file(
|
| 93 |
-
filename, file_bytes,
|
| 94 |
-
mode=PIPELINE_MODE, model_path=MODEL_PATH, ocr_lang=OCR_LANG,
|
| 95 |
-
status_callback=on_step,
|
| 96 |
-
)
|
| 97 |
-
progress.progress(1.0)
|
| 98 |
-
except Exception as e:
|
| 99 |
-
st.error(f"Processing failed: {e}")
|
| 100 |
-
continue
|
| 101 |
-
|
| 102 |
-
if result.get("duplicate"):
|
| 103 |
-
st.warning(f"⚠️ Already analyzed before — see document #{result['existing_id']} in History.")
|
| 104 |
-
continue
|
| 105 |
-
|
| 106 |
-
c1, c2, c3 = st.columns(3)
|
| 107 |
-
c1.metric("Type", result["doc_type"])
|
| 108 |
-
c2.metric("Confidence", f"{result['confidence']*100:.0f}%")
|
| 109 |
-
c3.metric("Time", f"{result['processing_time']}s")
|
| 110 |
-
|
| 111 |
-
tab1, tab2 = st.tabs(["📋 Structured result", "📝 Raw text"])
|
| 112 |
-
with tab1:
|
| 113 |
-
st.json(result["extracted"])
|
| 114 |
-
st.download_button(
|
| 115 |
-
"⬇️ Download JSON",
|
| 116 |
-
data=json.dumps(result["extracted"], indent=2),
|
| 117 |
-
file_name=f"{filename}.json",
|
| 118 |
-
mime="application/json",
|
| 119 |
-
key=f"dl_{filename}_{result['id']}",
|
| 120 |
-
)
|
| 121 |
-
st.caption("Want CSV or Excel instead? Open this document in the **History** tab to download it in any format.")
|
| 122 |
-
with tab2:
|
| 123 |
-
st.text_area("Raw extracted text", result["raw_text"], height=200, key=f"raw_{result['id']}")
|
| 124 |
-
|
| 125 |
-
st.success("Done — find this anytime in History.")
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
# ================================================ HISTORY (+ SEARCH) ======
|
| 129 |
-
def render_history():
|
| 130 |
-
st.title("🗂️ History")
|
| 131 |
-
|
| 132 |
-
query = st.text_input("🔍 Search by name, email, company, invoice number, keyword…")
|
| 133 |
-
rows = db.search_documents(query) if query else db.list_documents(limit=500)
|
| 134 |
-
|
| 135 |
-
if not rows:
|
| 136 |
-
st.info("Nothing here yet — analyze a file first." if not query else "No matches.")
|
| 137 |
-
return
|
| 138 |
-
|
| 139 |
-
col1, col2, col3 = st.columns(3)
|
| 140 |
-
with col1:
|
| 141 |
-
try:
|
| 142 |
-
st.download_button("⬇️ Export all (JSON)", exporters.to_json_bytes(rows), "notes.json", "application/json")
|
| 143 |
-
except Exception as e:
|
| 144 |
-
st.error(f"JSON export failed: {e}")
|
| 145 |
-
with col2:
|
| 146 |
-
try:
|
| 147 |
-
st.download_button("⬇️ Export all (CSV)", exporters.to_csv_bytes(rows), "notes.csv", "text/csv")
|
| 148 |
-
except Exception as e:
|
| 149 |
-
st.error(f"CSV export failed: {e}")
|
| 150 |
-
with col3:
|
| 151 |
-
try:
|
| 152 |
-
st.download_button("⬇️ Export all (Excel)", exporters.to_excel_bytes(rows), "notes.xlsx",
|
| 153 |
-
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
| 154 |
-
except Exception as e:
|
| 155 |
-
st.error(f"Excel export failed: {e}")
|
| 156 |
-
|
| 157 |
-
st.divider()
|
| 158 |
-
for r in rows:
|
| 159 |
-
with st.expander(f"#{r['id']} · {r['filename']} · {r['doc_type']} · {r['confidence']*100:.0f}% confidence"):
|
| 160 |
-
st.caption(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(r["created_at"])))
|
| 161 |
-
extracted = json.loads(r["extracted_json"])
|
| 162 |
-
st.json(extracted)
|
| 163 |
-
c_json, c_csv, c_xlsx, c_del = st.columns(4)
|
| 164 |
-
with c_json:
|
| 165 |
-
st.download_button("⬇️ JSON", json.dumps(extracted, indent=2),
|
| 166 |
-
f"{r['filename']}.json", key=f"hist_json_{r['id']}")
|
| 167 |
-
with c_csv:
|
| 168 |
-
st.download_button("⬇️ CSV", exporters.to_csv_bytes([r]),
|
| 169 |
-
f"{r['filename']}.csv", "text/csv", key=f"hist_csv_{r['id']}")
|
| 170 |
-
with c_xlsx:
|
| 171 |
-
st.download_button("⬇️ Excel", exporters.to_excel_bytes([r]),
|
| 172 |
-
f"{r['filename']}.xlsx",
|
| 173 |
-
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 174 |
-
key=f"hist_xlsx_{r['id']}")
|
| 175 |
-
with c_del:
|
| 176 |
-
if st.button("🗑️ Delete", key=f"hist_del_{r['id']}"):
|
| 177 |
-
db.delete_document(r["id"])
|
| 178 |
-
st.rerun()
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
PAGES = {"Analyze": render_analyze, "History": render_history}
|
| 182 |
-
PAGES[st.session_state.page]()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
audio.py
DELETED
|
@@ -1,53 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
audio.py - Offline speech-to-text via OpenAI's open-source Whisper model,
|
| 3 |
-
run locally on CPU (NOT the OpenAI cloud API - this is the open-weights
|
| 4 |
-
model running entirely on your machine).
|
| 5 |
-
|
| 6 |
-
One-time setup on your own machine (needs internet just to install):
|
| 7 |
-
pip install openai-whisper
|
| 8 |
-
(also requires ffmpeg installed on the system PATH)
|
| 9 |
-
|
| 10 |
-
After that, transcription runs fully offline / CPU-only.
|
| 11 |
-
"""
|
| 12 |
-
import tempfile
|
| 13 |
-
import os
|
| 14 |
-
|
| 15 |
-
_MODEL_CACHE = {}
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def _get_model(size: str = "base"):
|
| 19 |
-
if size in _MODEL_CACHE:
|
| 20 |
-
return _MODEL_CACHE[size]
|
| 21 |
-
try:
|
| 22 |
-
import whisper # lazy import - only needed if audio/video features are used
|
| 23 |
-
except ImportError:
|
| 24 |
-
raise RuntimeError(
|
| 25 |
-
"Audio/video transcription needs the 'openai-whisper' package, "
|
| 26 |
-
"which isn't installed.\nRun: pip install openai-whisper\n"
|
| 27 |
-
"(also requires ffmpeg installed on your system PATH)"
|
| 28 |
-
)
|
| 29 |
-
model = whisper.load_model(size) # downloads once, then cached locally & used offline
|
| 30 |
-
_MODEL_CACHE[size] = model
|
| 31 |
-
return model
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def transcribe_audio(audio_bytes: bytes, filename_hint: str = "audio.wav", model_size: str = "base") -> dict:
|
| 35 |
-
suffix = os.path.splitext(filename_hint)[1] or ".wav"
|
| 36 |
-
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
| 37 |
-
tmp.write(audio_bytes)
|
| 38 |
-
tmp_path = tmp.name
|
| 39 |
-
|
| 40 |
-
try:
|
| 41 |
-
model = _get_model(model_size)
|
| 42 |
-
result = model.transcribe(tmp_path)
|
| 43 |
-
text = result.get("text", "").strip()
|
| 44 |
-
# Whisper doesn't give a single confidence number; approximate from
|
| 45 |
-
# average per-segment no-speech probability (inverted)
|
| 46 |
-
segments = result.get("segments", [])
|
| 47 |
-
if segments:
|
| 48 |
-
avg_conf = 1 - (sum(s.get("no_speech_prob", 0.2) for s in segments) / len(segments))
|
| 49 |
-
else:
|
| 50 |
-
avg_conf = 0.7
|
| 51 |
-
return {"text": text, "confidence": round(max(0.0, min(1.0, avg_conf)), 3)}
|
| 52 |
-
finally:
|
| 53 |
-
os.unlink(tmp_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
db.py
DELETED
|
@@ -1,122 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
db.py - SQLite storage layer for Local AI.
|
| 3 |
-
|
| 4 |
-
Uses Python's built-in sqlite3 module (zero extra dependencies, fully offline).
|
| 5 |
-
"""
|
| 6 |
-
import sqlite3
|
| 7 |
-
import json
|
| 8 |
-
import os
|
| 9 |
-
import time
|
| 10 |
-
import hashlib
|
| 11 |
-
from contextlib import contextmanager
|
| 12 |
-
from typing import Optional
|
| 13 |
-
|
| 14 |
-
DB_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "local_ai.db")
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
SCHEMA = """
|
| 18 |
-
CREATE TABLE IF NOT EXISTS documents (
|
| 19 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 20 |
-
filename TEXT NOT NULL,
|
| 21 |
-
file_hash TEXT,
|
| 22 |
-
doc_type TEXT,
|
| 23 |
-
raw_text TEXT,
|
| 24 |
-
extracted_json TEXT,
|
| 25 |
-
confidence REAL,
|
| 26 |
-
processing_time REAL,
|
| 27 |
-
created_at REAL,
|
| 28 |
-
pipeline_mode TEXT
|
| 29 |
-
);
|
| 30 |
-
CREATE INDEX IF NOT EXISTS idx_doc_type ON documents(doc_type);
|
| 31 |
-
CREATE INDEX IF NOT EXISTS idx_file_hash ON documents(file_hash);
|
| 32 |
-
"""
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
@contextmanager
|
| 36 |
-
def get_conn():
|
| 37 |
-
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
| 38 |
-
conn = sqlite3.connect(DB_PATH)
|
| 39 |
-
conn.row_factory = sqlite3.Row
|
| 40 |
-
try:
|
| 41 |
-
yield conn
|
| 42 |
-
conn.commit()
|
| 43 |
-
finally:
|
| 44 |
-
conn.close()
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def init_db():
|
| 48 |
-
with get_conn() as conn:
|
| 49 |
-
conn.executescript(SCHEMA)
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def file_hash_bytes(data: bytes) -> str:
|
| 53 |
-
return hashlib.sha256(data).hexdigest()
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def find_by_hash(file_hash: str) -> Optional[sqlite3.Row]:
|
| 57 |
-
with get_conn() as conn:
|
| 58 |
-
cur = conn.execute("SELECT * FROM documents WHERE file_hash = ? LIMIT 1", (file_hash,))
|
| 59 |
-
return cur.fetchone()
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def insert_document(filename, file_hash, doc_type, raw_text, extracted: dict,
|
| 63 |
-
confidence: float, processing_time: float, pipeline_mode: str) -> int:
|
| 64 |
-
with get_conn() as conn:
|
| 65 |
-
cur = conn.execute(
|
| 66 |
-
"""INSERT INTO documents
|
| 67 |
-
(filename, file_hash, doc_type, raw_text, extracted_json,
|
| 68 |
-
confidence, processing_time, created_at, pipeline_mode)
|
| 69 |
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
| 70 |
-
(filename, file_hash, doc_type, raw_text, json.dumps(extracted),
|
| 71 |
-
confidence, processing_time, time.time(), pipeline_mode),
|
| 72 |
-
)
|
| 73 |
-
return cur.lastrowid
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
def list_documents(limit: int = 200):
|
| 77 |
-
with get_conn() as conn:
|
| 78 |
-
cur = conn.execute(
|
| 79 |
-
"SELECT * FROM documents ORDER BY created_at DESC LIMIT ?", (limit,)
|
| 80 |
-
)
|
| 81 |
-
return cur.fetchall()
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
def get_document(doc_id: int):
|
| 85 |
-
with get_conn() as conn:
|
| 86 |
-
cur = conn.execute("SELECT * FROM documents WHERE id = ?", (doc_id,))
|
| 87 |
-
return cur.fetchone()
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
def delete_document(doc_id: int):
|
| 91 |
-
with get_conn() as conn:
|
| 92 |
-
conn.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
def search_documents(query: str):
|
| 96 |
-
q = f"%{query.lower()}%"
|
| 97 |
-
with get_conn() as conn:
|
| 98 |
-
cur = conn.execute(
|
| 99 |
-
"""SELECT * FROM documents
|
| 100 |
-
WHERE lower(filename) LIKE ?
|
| 101 |
-
OR lower(raw_text) LIKE ?
|
| 102 |
-
OR lower(extracted_json) LIKE ?
|
| 103 |
-
ORDER BY created_at DESC""",
|
| 104 |
-
(q, q, q),
|
| 105 |
-
)
|
| 106 |
-
return cur.fetchall()
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
def stats():
|
| 110 |
-
with get_conn() as conn:
|
| 111 |
-
total = conn.execute("SELECT COUNT(*) c FROM documents").fetchone()["c"]
|
| 112 |
-
avg_conf = conn.execute("SELECT AVG(confidence) c FROM documents").fetchone()["c"] or 0
|
| 113 |
-
avg_time = conn.execute("SELECT AVG(processing_time) c FROM documents").fetchone()["c"] or 0
|
| 114 |
-
by_type = conn.execute(
|
| 115 |
-
"SELECT doc_type, COUNT(*) c FROM documents GROUP BY doc_type"
|
| 116 |
-
).fetchall()
|
| 117 |
-
return {
|
| 118 |
-
"total_documents": total,
|
| 119 |
-
"avg_confidence": round(avg_conf, 3),
|
| 120 |
-
"avg_processing_time": round(avg_time, 3),
|
| 121 |
-
"by_type": {r["doc_type"]: r["c"] for r in by_type},
|
| 122 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
exporters.py
DELETED
|
@@ -1,44 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
exporters.py - Export stored documents as JSON, CSV, or Excel.
|
| 3 |
-
"""
|
| 4 |
-
import json
|
| 5 |
-
import io
|
| 6 |
-
import pandas as pd
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
def to_json_bytes(rows) -> bytes:
|
| 10 |
-
docs = []
|
| 11 |
-
for r in rows:
|
| 12 |
-
docs.append({
|
| 13 |
-
"id": r["id"], "filename": r["filename"], "doc_type": r["doc_type"],
|
| 14 |
-
"confidence": r["confidence"], "processing_time": r["processing_time"],
|
| 15 |
-
"extracted": json.loads(r["extracted_json"]),
|
| 16 |
-
})
|
| 17 |
-
return json.dumps(docs, indent=2).encode("utf-8")
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def _flatten_rows(rows):
|
| 21 |
-
flat = []
|
| 22 |
-
for r in rows:
|
| 23 |
-
extracted = json.loads(r["extracted_json"])
|
| 24 |
-
row = {
|
| 25 |
-
"id": r["id"], "filename": r["filename"], "doc_type": r["doc_type"],
|
| 26 |
-
"confidence": r["confidence"], "processing_time": r["processing_time"],
|
| 27 |
-
}
|
| 28 |
-
for k, v in extracted.items():
|
| 29 |
-
row[k] = json.dumps(v) if isinstance(v, (list, dict)) else v
|
| 30 |
-
flat.append(row)
|
| 31 |
-
return flat
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
def to_csv_bytes(rows) -> bytes:
|
| 35 |
-
df = pd.DataFrame(_flatten_rows(rows))
|
| 36 |
-
return df.to_csv(index=False).encode("utf-8")
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
def to_excel_bytes(rows) -> bytes:
|
| 40 |
-
df = pd.DataFrame(_flatten_rows(rows))
|
| 41 |
-
buf = io.BytesIO()
|
| 42 |
-
with pd.ExcelWriter(buf, engine="openpyxl") as writer:
|
| 43 |
-
df.to_excel(writer, index=False, sheet_name="Documents")
|
| 44 |
-
return buf.getvalue()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
extractor.py
DELETED
|
@@ -1,147 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
extractor.py - Turns cleaned text into structured JSON, 100% offline.
|
| 3 |
-
|
| 4 |
-
Two modes (selectable in Settings):
|
| 5 |
-
- "rules" : fast regex/heuristic extraction. No model download needed,
|
| 6 |
-
works immediately, zero extra dependencies. This is the
|
| 7 |
-
default and is what powers the app out of the box.
|
| 8 |
-
- "llm" : routes through backend/llm_local.py, which calls a local
|
| 9 |
-
quantized GGUF model via llama-cpp-python (CPU only).
|
| 10 |
-
Requires the user to `pip install llama-cpp-python` and
|
| 11 |
-
place a .gguf model in models/ (see README) - both need
|
| 12 |
-
internet/disk access on the user's own machine.
|
| 13 |
-
|
| 14 |
-
Both modes return the same JSON shape so the rest of the app doesn't care
|
| 15 |
-
which one produced it.
|
| 16 |
-
"""
|
| 17 |
-
import re
|
| 18 |
-
from collections import Counter
|
| 19 |
-
|
| 20 |
-
STOPWORDS = set("""
|
| 21 |
-
a an the and or but if of in on at to for with from by is are was were be
|
| 22 |
-
been being this that these those it its as not no yes you your we our
|
| 23 |
-
they their he she his her i me my mine us them than then so such can will
|
| 24 |
-
would should could may might must do does did have has had
|
| 25 |
-
""".split())
|
| 26 |
-
|
| 27 |
-
EMAIL_RE = re.compile(r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+")
|
| 28 |
-
PHONE_RE = re.compile(r"(?<!\w)(\+?\d{1,3}[\s.-])?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}(?!\w)")
|
| 29 |
-
DATE_RE = re.compile(
|
| 30 |
-
r"\b(\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|\d{4}-\d{2}-\d{2}|"
|
| 31 |
-
r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4})\b",
|
| 32 |
-
re.IGNORECASE,
|
| 33 |
-
)
|
| 34 |
-
AMOUNT_RE = re.compile(r"(?:[$₹€£]\s?\d[\d,]*\.?\d{0,2}|\b\d[\d,]*\.\d{2}\b)")
|
| 35 |
-
INVOICE_NO_RE = re.compile(r"\b(?:invoice|inv|receipt|order)[\s#:no.]*([A-Z0-9-]{4,})\b", re.IGNORECASE)
|
| 36 |
-
NAME_LINE_RE = re.compile(r"\b([A-Z][a-z]+(?:[ \t]+[A-Z][a-z]+){1,2})\b")
|
| 37 |
-
NAME_BLOCKLIST = {
|
| 38 |
-
"bill to", "amount due", "invoice no", "ship to", "pay to", "total due",
|
| 39 |
-
"due date", "order no", "purchase order", "account number", "sold to",
|
| 40 |
-
"action items", "meeting notes", "product roadmap",
|
| 41 |
-
}
|
| 42 |
-
ORG_HINT_RE = re.compile(
|
| 43 |
-
r"\b([A-Z][\w&]*(?:\s+[A-Z][\w&]*)*\s+(?:Inc\.?|LLC|Ltd\.?|Corp\.?|Company|Co\.|Technologies|Solutions|Group))\b"
|
| 44 |
-
)
|
| 45 |
-
|
| 46 |
-
DOC_TYPE_KEYWORDS = {
|
| 47 |
-
"invoice": ["invoice", "bill to", "subtotal", "tax", "amount due", "po number"],
|
| 48 |
-
"receipt": ["receipt", "total", "cash", "change due", "thank you for your purchase"],
|
| 49 |
-
"resume": ["experience", "education", "skills", "resume", "curriculum vitae", "objective"],
|
| 50 |
-
"medical_report": ["diagnosis", "patient", "physician", "prescribed", "symptoms", "treatment"],
|
| 51 |
-
"meeting_notes": ["agenda", "attendees", "action items", "minutes of meeting", "next steps"],
|
| 52 |
-
"contract": ["agreement", "party", "parties", "hereby", "terms and conditions", "termination"],
|
| 53 |
-
"letter": ["dear", "sincerely", "regards", "yours truly"],
|
| 54 |
-
"research_paper": ["abstract", "references", "introduction", "methodology", "conclusion"],
|
| 55 |
-
"certificate": ["certificate", "certify", "awarded", "completion"],
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
def classify_document(text: str) -> str:
|
| 60 |
-
lower = text.lower()
|
| 61 |
-
scores = {}
|
| 62 |
-
for doc_type, keywords in DOC_TYPE_KEYWORDS.items():
|
| 63 |
-
scores[doc_type] = sum(lower.count(k) for k in keywords)
|
| 64 |
-
best_type, best_score = max(scores.items(), key=lambda kv: kv[1])
|
| 65 |
-
return best_type if best_score > 0 else "general"
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
def _top_keywords(text: str, n: int = 10) -> list:
|
| 69 |
-
words = re.findall(r"[A-Za-z]{4,}", text.lower())
|
| 70 |
-
words = [w for w in words if w not in STOPWORDS]
|
| 71 |
-
return [w for w, _ in Counter(words).most_common(n)]
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def _summary(text: str, max_sentences: int = 2) -> str:
|
| 75 |
-
sentences = re.split(r"(?<=[.!?])\s+", text.strip())
|
| 76 |
-
return " ".join(sentences[:max_sentences])[:400]
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
def extract_entities(text: str) -> dict:
|
| 80 |
-
"""Common entities every document type shares."""
|
| 81 |
-
emails = sorted(set(EMAIL_RE.findall(text)))
|
| 82 |
-
phones = sorted(set(m.group(0).strip() for m in PHONE_RE.finditer(text)))
|
| 83 |
-
dates = sorted(set(DATE_RE.findall(text)))
|
| 84 |
-
amounts = sorted(set(AMOUNT_RE.findall(text)))
|
| 85 |
-
orgs = sorted(set(ORG_HINT_RE.findall(text)))
|
| 86 |
-
invoice_match = INVOICE_NO_RE.search(text)
|
| 87 |
-
names_raw = set(NAME_LINE_RE.findall(text))
|
| 88 |
-
names = sorted(n for n in names_raw if n.lower() not in NAME_BLOCKLIST and n not in orgs)[:10]
|
| 89 |
-
|
| 90 |
-
return {
|
| 91 |
-
"people": names,
|
| 92 |
-
"emails": emails,
|
| 93 |
-
"phones": phones,
|
| 94 |
-
"dates": dates,
|
| 95 |
-
"amounts": amounts,
|
| 96 |
-
"organizations": orgs,
|
| 97 |
-
"invoice_number": invoice_match.group(1) if invoice_match else None,
|
| 98 |
-
}
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
def rule_based_extract(text: str, doc_type: str) -> dict:
|
| 102 |
-
entities = extract_entities(text)
|
| 103 |
-
result = {
|
| 104 |
-
"document_category": doc_type,
|
| 105 |
-
"summary": _summary(text),
|
| 106 |
-
"tags": _top_keywords(text, 8),
|
| 107 |
-
"keywords": _top_keywords(text, 15),
|
| 108 |
-
**entities,
|
| 109 |
-
}
|
| 110 |
-
|
| 111 |
-
# Type-specific shaping so the JSON looks purpose-built, not generic
|
| 112 |
-
if doc_type == "resume":
|
| 113 |
-
result["full_name"] = entities["people"][0] if entities["people"] else None
|
| 114 |
-
result["email"] = entities["emails"][0] if entities["emails"] else None
|
| 115 |
-
result["phone"] = entities["phones"][0] if entities["phones"] else None
|
| 116 |
-
skill_kw = ["python", "java", "sql", "react", "fastapi", "excel", "design",
|
| 117 |
-
"leadership", "communication", "javascript", "aws", "docker"]
|
| 118 |
-
result["skills"] = [k for k in skill_kw if k in text.lower()]
|
| 119 |
-
elif doc_type in ("invoice", "receipt"):
|
| 120 |
-
result["total_amount"] = entities["amounts"][-1] if entities["amounts"] else None
|
| 121 |
-
result["vendor"] = entities["organizations"][0] if entities["organizations"] else None
|
| 122 |
-
result["date"] = entities["dates"][0] if entities["dates"] else None
|
| 123 |
-
|
| 124 |
-
# crude confidence: more populated fields = higher confidence
|
| 125 |
-
populated = sum(1 for v in result.values() if v)
|
| 126 |
-
result["confidence_score"] = round(min(0.95, 0.35 + populated * 0.05), 2)
|
| 127 |
-
return result
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
def extract(text: str, doc_type: str = None, mode: str = "rules", model_path: str = None) -> dict:
|
| 131 |
-
"""Main entry point. `mode` is 'rules' (default, always works offline)
|
| 132 |
-
or 'llm' (requires llama-cpp-python + a local GGUF model)."""
|
| 133 |
-
if not doc_type:
|
| 134 |
-
doc_type = classify_document(text)
|
| 135 |
-
|
| 136 |
-
if mode == "llm":
|
| 137 |
-
from . import llm_local
|
| 138 |
-
try:
|
| 139 |
-
return llm_local.extract_with_llm(text, doc_type, model_path)
|
| 140 |
-
except Exception as e:
|
| 141 |
-
# Always fall back to rule-based so the pipeline never hard-fails
|
| 142 |
-
result = rule_based_extract(text, doc_type)
|
| 143 |
-
result["_llm_error"] = str(e)
|
| 144 |
-
result["_fallback_used"] = "rules"
|
| 145 |
-
return result
|
| 146 |
-
|
| 147 |
-
return rule_based_extract(text, doc_type)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
llm_local.py
DELETED
|
@@ -1,73 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
llm_local.py - Optional local LLM extraction via llama-cpp-python.
|
| 3 |
-
|
| 4 |
-
This module is only imported when the user enables "LLM mode" in Settings.
|
| 5 |
-
It is NOT required for the app to work - the default "rules" mode (see
|
| 6 |
-
extractor.py) needs zero extra downloads.
|
| 7 |
-
|
| 8 |
-
To enable real local-LLM extraction on your own machine (with internet
|
| 9 |
-
access, just for the one-time setup):
|
| 10 |
-
pip install llama-cpp-python
|
| 11 |
-
Download a small instruction-tuned GGUF model, e.g.:
|
| 12 |
-
- TinyLlama-1.1B-Chat (~600MB)
|
| 13 |
-
- Phi-3-mini-4k-instruct (Q4_K_M, ~2.3GB)
|
| 14 |
-
- Qwen2.5-3B-Instruct (Q4_K_M, ~2GB)
|
| 15 |
-
Place the .gguf file in models/ and set its path in the Settings page
|
| 16 |
-
(or LOCAL_AI_MODEL_PATH env var).
|
| 17 |
-
|
| 18 |
-
After that one-time setup, everything still runs 100% offline / CPU-only -
|
| 19 |
-
no API keys, no cloud calls.
|
| 20 |
-
"""
|
| 21 |
-
import json
|
| 22 |
-
import re
|
| 23 |
-
import os
|
| 24 |
-
|
| 25 |
-
_MODEL_CACHE = {}
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def _get_model(model_path: str):
|
| 29 |
-
if not model_path or not os.path.exists(model_path):
|
| 30 |
-
raise FileNotFoundError(
|
| 31 |
-
f"No GGUF model found at '{model_path}'. Download one and set "
|
| 32 |
-
f"its path in Settings (see backend/llm_local.py docstring)."
|
| 33 |
-
)
|
| 34 |
-
if model_path in _MODEL_CACHE:
|
| 35 |
-
return _MODEL_CACHE[model_path]
|
| 36 |
-
|
| 37 |
-
from llama_cpp import Llama # lazy import - only needed for LLM mode
|
| 38 |
-
llm = Llama(model_path=model_path, n_ctx=4096, n_threads=os.cpu_count(), verbose=False)
|
| 39 |
-
_MODEL_CACHE[model_path] = llm
|
| 40 |
-
return llm
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def _extract_json(raw: str) -> dict:
|
| 44 |
-
"""Models sometimes wrap JSON in markdown fences or add stray text -
|
| 45 |
-
pull out the first {...} block."""
|
| 46 |
-
raw = raw.strip()
|
| 47 |
-
raw = re.sub(r"^```(json)?", "", raw).strip()
|
| 48 |
-
raw = re.sub(r"```$", "", raw).strip()
|
| 49 |
-
match = re.search(r"\{.*\}", raw, re.DOTALL)
|
| 50 |
-
if not match:
|
| 51 |
-
raise ValueError("Model did not return parseable JSON")
|
| 52 |
-
return json.loads(match.group(0))
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def extract_with_llm(text: str, doc_type: str, model_path: str = None) -> dict:
|
| 56 |
-
from prompts.templates import build_prompt
|
| 57 |
-
|
| 58 |
-
model_path = model_path or os.environ.get("LOCAL_AI_MODEL_PATH", "models/model.gguf")
|
| 59 |
-
llm = _get_model(model_path)
|
| 60 |
-
prompt = build_prompt(doc_type, text)
|
| 61 |
-
|
| 62 |
-
output = llm(
|
| 63 |
-
prompt,
|
| 64 |
-
max_tokens=800,
|
| 65 |
-
temperature=0.1,
|
| 66 |
-
stop=["</s>", "```"],
|
| 67 |
-
)
|
| 68 |
-
raw_text = output["choices"][0]["text"]
|
| 69 |
-
result = _extract_json(raw_text)
|
| 70 |
-
result.setdefault("document_category", doc_type)
|
| 71 |
-
result.setdefault("confidence_score", 0.8)
|
| 72 |
-
result["_pipeline_mode"] = "llm"
|
| 73 |
-
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ocr.py
DELETED
|
@@ -1,44 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
ocr.py - Image OCR using Tesseract (offline, CPU-only).
|
| 3 |
-
|
| 4 |
-
Tesseract is a mature, fully offline OCR engine - no internet or cloud
|
| 5 |
-
calls are made. Requires the `tesseract` binary + `pytesseract` package.
|
| 6 |
-
"""
|
| 7 |
-
from PIL import Image
|
| 8 |
-
import pytesseract
|
| 9 |
-
import io
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def ocr_image(image_bytes: bytes, lang: str = "eng") -> dict:
|
| 13 |
-
"""Run OCR on raw image bytes. Returns text + a rough confidence score."""
|
| 14 |
-
img = Image.open(io.BytesIO(image_bytes))
|
| 15 |
-
if img.mode != "RGB":
|
| 16 |
-
img = img.convert("RGB")
|
| 17 |
-
|
| 18 |
-
try:
|
| 19 |
-
text = pytesseract.image_to_string(img, lang=lang)
|
| 20 |
-
except pytesseract.TesseractNotFoundError:
|
| 21 |
-
raise RuntimeError(
|
| 22 |
-
"Tesseract OCR engine is not installed on this machine.\n"
|
| 23 |
-
"Install it, then restart the app:\n"
|
| 24 |
-
" Windows: https://github.com/UB-Mannheim/tesseract/wiki\n"
|
| 25 |
-
" macOS: brew install tesseract\n"
|
| 26 |
-
" Linux: sudo apt install tesseract-ocr"
|
| 27 |
-
)
|
| 28 |
-
|
| 29 |
-
# image_to_data gives per-word confidence; average the valid ones
|
| 30 |
-
try:
|
| 31 |
-
data = pytesseract.image_to_data(img, lang=lang, output_type=pytesseract.Output.DICT)
|
| 32 |
-
confs = [int(c) for c in data["conf"] if c not in ("-1", -1)]
|
| 33 |
-
avg_conf = (sum(confs) / len(confs) / 100.0) if confs else 0.5
|
| 34 |
-
except Exception:
|
| 35 |
-
avg_conf = 0.5
|
| 36 |
-
|
| 37 |
-
return {"text": text.strip(), "confidence": round(avg_conf, 3)}
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
def available_languages() -> list:
|
| 41 |
-
try:
|
| 42 |
-
return pytesseract.get_languages(config="")
|
| 43 |
-
except Exception:
|
| 44 |
-
return ["eng"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pdf_extract.py
DELETED
|
@@ -1,48 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
pdf_extract.py - Extract text from PDFs (offline).
|
| 3 |
-
|
| 4 |
-
Strategy:
|
| 5 |
-
1. Try pdfplumber for native text extraction (fast, accurate for text PDFs).
|
| 6 |
-
2. If a page has no extractable text (scanned/image PDF), rasterize it
|
| 7 |
-
and run it through Tesseract OCR.
|
| 8 |
-
"""
|
| 9 |
-
import io
|
| 10 |
-
import pdfplumber
|
| 11 |
-
from PIL import Image
|
| 12 |
-
from . import ocr
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
def extract_pdf(pdf_bytes: bytes, lang: str = "eng") -> dict:
|
| 16 |
-
pages_text = []
|
| 17 |
-
ocr_used = False
|
| 18 |
-
confidences = []
|
| 19 |
-
|
| 20 |
-
with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
|
| 21 |
-
for page in pdf.pages:
|
| 22 |
-
text = (page.extract_text() or "").strip()
|
| 23 |
-
if text:
|
| 24 |
-
pages_text.append(text)
|
| 25 |
-
confidences.append(0.95) # native text is high-confidence
|
| 26 |
-
else:
|
| 27 |
-
# Scanned page -> rasterize + OCR
|
| 28 |
-
try:
|
| 29 |
-
im = page.to_image(resolution=200).original
|
| 30 |
-
buf = io.BytesIO()
|
| 31 |
-
im.save(buf, format="PNG")
|
| 32 |
-
result = ocr.ocr_image(buf.getvalue(), lang=lang)
|
| 33 |
-
pages_text.append(result["text"])
|
| 34 |
-
confidences.append(result["confidence"])
|
| 35 |
-
ocr_used = True
|
| 36 |
-
except Exception as e:
|
| 37 |
-
pages_text.append("")
|
| 38 |
-
confidences.append(0.0)
|
| 39 |
-
|
| 40 |
-
full_text = "\n\n".join(p for p in pages_text if p)
|
| 41 |
-
avg_conf = (sum(confidences) / len(confidences)) if confidences else 0.0
|
| 42 |
-
|
| 43 |
-
return {
|
| 44 |
-
"text": full_text.strip(),
|
| 45 |
-
"page_count": len(pages_text),
|
| 46 |
-
"ocr_used": ocr_used,
|
| 47 |
-
"confidence": round(avg_conf, 3),
|
| 48 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pipeline.py
DELETED
|
@@ -1,110 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
pipeline.py - Orchestrates the full Input -> Clean -> Extract -> JSON -> SQLite
|
| 3 |
-
flow for every supported input type. Yields step-by-step status so the UI
|
| 4 |
-
can show live progress.
|
| 5 |
-
"""
|
| 6 |
-
import time
|
| 7 |
-
from . import ocr, pdf_extract, extractor, db
|
| 8 |
-
|
| 9 |
-
STEPS_BY_TYPE = {
|
| 10 |
-
"image": ["Uploading", "Running OCR", "Cleaning text", "AI extracting", "Generating JSON", "Saving to database"],
|
| 11 |
-
"pdf": ["Uploading", "Extracting pages", "Cleaning text", "AI extracting", "Generating JSON", "Saving to database"],
|
| 12 |
-
"text": ["Uploading", "Cleaning text", "AI extracting", "Generating JSON", "Saving to database"],
|
| 13 |
-
"audio": ["Uploading", "Transcribing (Whisper)", "Cleaning text", "AI extracting", "Generating JSON", "Saving to database"],
|
| 14 |
-
"video": ["Uploading", "Extracting audio", "Transcribing (Whisper)", "Cleaning text", "AI extracting", "Generating JSON", "Saving to database"],
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
def _clean_text(text: str) -> str:
|
| 19 |
-
return "\n".join(line.strip() for line in text.splitlines() if line.strip())
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def file_kind(filename: str) -> str:
|
| 23 |
-
ext = filename.lower().rsplit(".", 1)[-1] if "." in filename else ""
|
| 24 |
-
if ext in ("jpg", "jpeg", "png"):
|
| 25 |
-
return "image"
|
| 26 |
-
if ext == "pdf":
|
| 27 |
-
return "pdf"
|
| 28 |
-
if ext == "txt":
|
| 29 |
-
return "text"
|
| 30 |
-
if ext in ("mp3", "wav", "m4a"):
|
| 31 |
-
return "audio"
|
| 32 |
-
if ext in ("mp4", "mov", "mkv"):
|
| 33 |
-
return "video"
|
| 34 |
-
return "unknown"
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def process_file(filename: str, file_bytes: bytes, mode: str = "rules",
|
| 38 |
-
model_path: str = None, ocr_lang: str = "eng",
|
| 39 |
-
status_callback=None):
|
| 40 |
-
"""Runs the full pipeline. status_callback(step_name) is called for each
|
| 41 |
-
stage if provided (used by the Streamlit UI to show live progress)."""
|
| 42 |
-
start = time.time()
|
| 43 |
-
kind = file_kind(filename)
|
| 44 |
-
if kind == "unknown":
|
| 45 |
-
raise ValueError(f"Unsupported file type: {filename}")
|
| 46 |
-
|
| 47 |
-
def report(step):
|
| 48 |
-
if status_callback:
|
| 49 |
-
status_callback(step)
|
| 50 |
-
|
| 51 |
-
steps = iter(STEPS_BY_TYPE[kind])
|
| 52 |
-
report(next(steps)) # Uploading
|
| 53 |
-
|
| 54 |
-
file_hash = db.file_hash_bytes(file_bytes)
|
| 55 |
-
existing = db.find_by_hash(file_hash)
|
| 56 |
-
if existing:
|
| 57 |
-
return {"duplicate": True, "existing_id": existing["id"]}
|
| 58 |
-
|
| 59 |
-
raw_text = ""
|
| 60 |
-
ocr_confidence = 1.0
|
| 61 |
-
|
| 62 |
-
if kind == "image":
|
| 63 |
-
report(next(steps))
|
| 64 |
-
r = ocr.ocr_image(file_bytes, lang=ocr_lang)
|
| 65 |
-
raw_text, ocr_confidence = r["text"], r["confidence"]
|
| 66 |
-
elif kind == "pdf":
|
| 67 |
-
report(next(steps))
|
| 68 |
-
r = pdf_extract.extract_pdf(file_bytes, lang=ocr_lang)
|
| 69 |
-
raw_text, ocr_confidence = r["text"], r["confidence"]
|
| 70 |
-
elif kind == "text":
|
| 71 |
-
raw_text = file_bytes.decode("utf-8", errors="ignore")
|
| 72 |
-
elif kind == "audio":
|
| 73 |
-
report(next(steps))
|
| 74 |
-
from . import audio as audio_mod
|
| 75 |
-
r = audio_mod.transcribe_audio(file_bytes, filename_hint=filename)
|
| 76 |
-
raw_text, ocr_confidence = r["text"], r["confidence"]
|
| 77 |
-
elif kind == "video":
|
| 78 |
-
report(next(steps))
|
| 79 |
-
from . import video as video_mod
|
| 80 |
-
report(next(steps))
|
| 81 |
-
r = video_mod.transcribe_video(file_bytes, filename_hint=filename)
|
| 82 |
-
raw_text, ocr_confidence = r["text"], r["confidence"]
|
| 83 |
-
|
| 84 |
-
report(next(steps)) # Cleaning text
|
| 85 |
-
cleaned = _clean_text(raw_text)
|
| 86 |
-
|
| 87 |
-
report(next(steps)) # AI extracting
|
| 88 |
-
doc_type = extractor.classify_document(cleaned)
|
| 89 |
-
extracted = extractor.extract(cleaned, doc_type=doc_type, mode=mode, model_path=model_path)
|
| 90 |
-
|
| 91 |
-
report(next(steps)) # Generating JSON
|
| 92 |
-
confidence = round((ocr_confidence + extracted.get("confidence_score", 0.7)) / 2, 3)
|
| 93 |
-
|
| 94 |
-
report(next(steps)) # Saving to database
|
| 95 |
-
processing_time = round(time.time() - start, 3)
|
| 96 |
-
doc_id = db.insert_document(
|
| 97 |
-
filename=filename, file_hash=file_hash, doc_type=doc_type,
|
| 98 |
-
raw_text=cleaned, extracted=extracted, confidence=confidence,
|
| 99 |
-
processing_time=processing_time, pipeline_mode=mode,
|
| 100 |
-
)
|
| 101 |
-
|
| 102 |
-
return {
|
| 103 |
-
"duplicate": False,
|
| 104 |
-
"id": doc_id,
|
| 105 |
-
"doc_type": doc_type,
|
| 106 |
-
"raw_text": cleaned,
|
| 107 |
-
"extracted": extracted,
|
| 108 |
-
"confidence": confidence,
|
| 109 |
-
"processing_time": processing_time,
|
| 110 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
requirements.txt
CHANGED
|
@@ -1,20 +1,3 @@
|
|
| 1 |
-
|
| 2 |
-
streamlit>=1.35
|
| 3 |
pandas
|
| 4 |
-
|
| 5 |
-
pillow
|
| 6 |
-
pytesseract
|
| 7 |
-
pdfplumber
|
| 8 |
-
pypdf
|
| 9 |
-
|
| 10 |
-
# Note: pytesseract requires the `tesseract` binary on your system:
|
| 11 |
-
# Ubuntu/Debian: sudo apt install tesseract-ocr
|
| 12 |
-
# macOS: brew install tesseract
|
| 13 |
-
# Windows: https://github.com/UB-Mannheim/tesseract/wiki
|
| 14 |
-
|
| 15 |
-
# ---- Optional: enable real local-LLM extraction mode (Settings > LLM) ----
|
| 16 |
-
# llama-cpp-python
|
| 17 |
-
|
| 18 |
-
# ---- Optional: enable audio/video transcription ----
|
| 19 |
-
# openai-whisper
|
| 20 |
-
# (also requires ffmpeg on your system PATH: sudo apt install ffmpeg)
|
|
|
|
| 1 |
+
altair
|
|
|
|
| 2 |
pandas
|
| 3 |
+
streamlit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
video.py
DELETED
|
@@ -1,34 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
video.py - Extracts the audio track from a video file (using ffmpeg,
|
| 3 |
-
which must be installed locally) and transcribes it via audio.py.
|
| 4 |
-
|
| 5 |
-
Requires ffmpeg on PATH (e.g. `apt install ffmpeg` / `brew install ffmpeg`).
|
| 6 |
-
No video data ever leaves the machine.
|
| 7 |
-
"""
|
| 8 |
-
import subprocess
|
| 9 |
-
import tempfile
|
| 10 |
-
import os
|
| 11 |
-
from . import audio
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
def transcribe_video(video_bytes: bytes, filename_hint: str = "video.mp4", model_size: str = "base") -> dict:
|
| 15 |
-
suffix = os.path.splitext(filename_hint)[1] or ".mp4"
|
| 16 |
-
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as vtmp:
|
| 17 |
-
vtmp.write(video_bytes)
|
| 18 |
-
video_path = vtmp.name
|
| 19 |
-
|
| 20 |
-
audio_path = video_path + ".wav"
|
| 21 |
-
try:
|
| 22 |
-
subprocess.run(
|
| 23 |
-
["ffmpeg", "-y", "-i", video_path, "-vn", "-ac", "1", "-ar", "16000", audio_path],
|
| 24 |
-
check=True, capture_output=True,
|
| 25 |
-
)
|
| 26 |
-
with open(audio_path, "rb") as f:
|
| 27 |
-
audio_bytes = f.read()
|
| 28 |
-
return audio.transcribe_audio(audio_bytes, filename_hint="extracted.wav", model_size=model_size)
|
| 29 |
-
except FileNotFoundError:
|
| 30 |
-
raise RuntimeError("ffmpeg not found. Install ffmpeg to enable video processing.")
|
| 31 |
-
finally:
|
| 32 |
-
for p in (video_path, audio_path):
|
| 33 |
-
if os.path.exists(p):
|
| 34 |
-
os.unlink(p)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|