Files changed (14) hide show
  1. LICENSE +0 -19
  2. README.md +14 -243
  3. __init__.py +0 -0
  4. app.py +0 -182
  5. audio.py +0 -53
  6. db.py +0 -122
  7. exporters.py +0 -44
  8. extractor.py +0 -147
  9. llm_local.py +0 -73
  10. ocr.py +0 -44
  11. pdf_extract.py +0 -48
  12. pipeline.py +0 -110
  13. requirements.txt +2 -19
  14. 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
- ## ✅ What's fully working out of the box (zero extra downloads)
14
-
15
- - **Images → OCR → JSON** (Tesseract, native)
16
- - **PDFs → text/OCR → JSON** (`pdfplumber`, with automatic OCR fallback for scanned pages)
17
- - **Plain text → JSON**
18
- - Automatic **document type classification** (invoice, resume, receipt, meeting notes, contract,
19
- medical report, letter, research paper, certificate, general)
20
- - Rule-based **entity extraction**: people, emails, phones, dates, amounts, organizations,
21
- invoice numbers, keywords, tags, summary, confidence score
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
- ## Architecture
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
- ## License
244
- For open source projects, say how it is licensed.
245
 
246
- ## Project status
247
- If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
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
- # Core app (required)
2
- streamlit>=1.35
3
  pandas
4
- openpyxl
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)