industrial-rag-agent 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- industrial_rag_agent-0.1.0/PKG-INFO +83 -0
- industrial_rag_agent-0.1.0/README.md +64 -0
- industrial_rag_agent-0.1.0/industrial_rag_agent/__init__.py +3 -0
- industrial_rag_agent-0.1.0/industrial_rag_agent/cli.py +234 -0
- industrial_rag_agent-0.1.0/industrial_rag_agent/config.py +66 -0
- industrial_rag_agent-0.1.0/industrial_rag_agent/extractor.py +175 -0
- industrial_rag_agent-0.1.0/industrial_rag_agent/indexer.py +193 -0
- industrial_rag_agent-0.1.0/industrial_rag_agent/server.py +78 -0
- industrial_rag_agent-0.1.0/industrial_rag_agent/watcher.py +76 -0
- industrial_rag_agent-0.1.0/industrial_rag_agent.egg-info/PKG-INFO +83 -0
- industrial_rag_agent-0.1.0/industrial_rag_agent.egg-info/SOURCES.txt +15 -0
- industrial_rag_agent-0.1.0/industrial_rag_agent.egg-info/dependency_links.txt +1 -0
- industrial_rag_agent-0.1.0/industrial_rag_agent.egg-info/entry_points.txt +2 -0
- industrial_rag_agent-0.1.0/industrial_rag_agent.egg-info/requires.txt +12 -0
- industrial_rag_agent-0.1.0/industrial_rag_agent.egg-info/top_level.txt +2 -0
- industrial_rag_agent-0.1.0/pyproject.toml +32 -0
- industrial_rag_agent-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: industrial-rag-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local RAG agent for Industrial AI Agents — watches folders, extracts text, OCRs scanned PDFs, indexes in SQLite FTS5
|
|
5
|
+
Author-email: Industrial AI Agents <support@industrialagents.com>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: watchdog>=4.0
|
|
10
|
+
Requires-Dist: python-docx>=1.1
|
|
11
|
+
Requires-Dist: openpyxl>=3.1
|
|
12
|
+
Requires-Dist: pymupdf>=1.24
|
|
13
|
+
Requires-Dist: uvicorn>=0.30
|
|
14
|
+
Requires-Dist: httpx>=0.27
|
|
15
|
+
Provides-Extra: ocr-win
|
|
16
|
+
Requires-Dist: pytesseract>=0.3; extra == "ocr-win"
|
|
17
|
+
Provides-Extra: ocr-linux
|
|
18
|
+
Requires-Dist: pytesseract>=0.3; extra == "ocr-linux"
|
|
19
|
+
|
|
20
|
+
# Industrial RAG Agent
|
|
21
|
+
|
|
22
|
+
Local document indexer for **Industrial AI Agents** SaaS.
|
|
23
|
+
|
|
24
|
+
Runs on the client's computer — documents **never leave their network**.
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
📁 Document Folder
|
|
30
|
+
↓ (file watcher)
|
|
31
|
+
📄 Text Extraction (PDF, DOCX, XLSX, TXT/CSV)
|
|
32
|
+
↓ (+ OCR for scanned PDFs)
|
|
33
|
+
🗃️ SQLite FTS5 Index (local)
|
|
34
|
+
↓
|
|
35
|
+
🌐 Local API (localhost:3847/search)
|
|
36
|
+
↓
|
|
37
|
+
☁️ Industrial AI Agents (cloud) queries this endpoint
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install industrial-rag-agent
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick Start
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# 1. Configure
|
|
50
|
+
industrial-rag-agent configure
|
|
51
|
+
|
|
52
|
+
# 2. Start (watches folders + runs API)
|
|
53
|
+
industrial-rag-agent start
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Commands
|
|
57
|
+
|
|
58
|
+
| Command | Description |
|
|
59
|
+
|-------------|------------------------------------------|
|
|
60
|
+
| `configure` | Interactive setup (API key, folders, port) |
|
|
61
|
+
| `start` | Start watcher + local API server |
|
|
62
|
+
| `index` | One-time index of all existing documents |
|
|
63
|
+
| `search` | Search from the command line |
|
|
64
|
+
| `status` | Show index statistics |
|
|
65
|
+
|
|
66
|
+
## OCR Support
|
|
67
|
+
|
|
68
|
+
| OS | Engine | Install Required? |
|
|
69
|
+
|---------|---------------------------|-------------------|
|
|
70
|
+
| macOS | Apple Vision (Neural Engine) | No — built in |
|
|
71
|
+
| Windows | Tesseract OCR | Yes |
|
|
72
|
+
| Linux | Tesseract OCR | Yes |
|
|
73
|
+
|
|
74
|
+
## API Endpoints
|
|
75
|
+
|
|
76
|
+
- `GET /search?q=keyword&limit=5` — search indexed documents
|
|
77
|
+
- `GET /status` — agent status and index stats
|
|
78
|
+
- `GET /health` — health check
|
|
79
|
+
|
|
80
|
+
## Requirements
|
|
81
|
+
|
|
82
|
+
- Python 3.11+
|
|
83
|
+
- Tesseract OCR (Windows/Linux only)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Industrial RAG Agent
|
|
2
|
+
|
|
3
|
+
Local document indexer for **Industrial AI Agents** SaaS.
|
|
4
|
+
|
|
5
|
+
Runs on the client's computer — documents **never leave their network**.
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
📁 Document Folder
|
|
11
|
+
↓ (file watcher)
|
|
12
|
+
📄 Text Extraction (PDF, DOCX, XLSX, TXT/CSV)
|
|
13
|
+
↓ (+ OCR for scanned PDFs)
|
|
14
|
+
🗃️ SQLite FTS5 Index (local)
|
|
15
|
+
↓
|
|
16
|
+
🌐 Local API (localhost:3847/search)
|
|
17
|
+
↓
|
|
18
|
+
☁️ Industrial AI Agents (cloud) queries this endpoint
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install industrial-rag-agent
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# 1. Configure
|
|
31
|
+
industrial-rag-agent configure
|
|
32
|
+
|
|
33
|
+
# 2. Start (watches folders + runs API)
|
|
34
|
+
industrial-rag-agent start
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
| Command | Description |
|
|
40
|
+
|-------------|------------------------------------------|
|
|
41
|
+
| `configure` | Interactive setup (API key, folders, port) |
|
|
42
|
+
| `start` | Start watcher + local API server |
|
|
43
|
+
| `index` | One-time index of all existing documents |
|
|
44
|
+
| `search` | Search from the command line |
|
|
45
|
+
| `status` | Show index statistics |
|
|
46
|
+
|
|
47
|
+
## OCR Support
|
|
48
|
+
|
|
49
|
+
| OS | Engine | Install Required? |
|
|
50
|
+
|---------|---------------------------|-------------------|
|
|
51
|
+
| macOS | Apple Vision (Neural Engine) | No — built in |
|
|
52
|
+
| Windows | Tesseract OCR | Yes |
|
|
53
|
+
| Linux | Tesseract OCR | Yes |
|
|
54
|
+
|
|
55
|
+
## API Endpoints
|
|
56
|
+
|
|
57
|
+
- `GET /search?q=keyword&limit=5` — search indexed documents
|
|
58
|
+
- `GET /status` — agent status and index stats
|
|
59
|
+
- `GET /health` — health check
|
|
60
|
+
|
|
61
|
+
## Requirements
|
|
62
|
+
|
|
63
|
+
- Python 3.11+
|
|
64
|
+
- Tesseract OCR (Windows/Linux only)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""CLI entry point — `industrial-rag-agent start|configure|index|search|status`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .config import Config, CONFIG_DIR
|
|
14
|
+
from .extractor import extract
|
|
15
|
+
from .indexer import index_document, needs_reindex, search as do_search, get_stats
|
|
16
|
+
from .watcher import start_watching
|
|
17
|
+
from .server import create_server
|
|
18
|
+
|
|
19
|
+
logging.basicConfig(
|
|
20
|
+
level=logging.INFO,
|
|
21
|
+
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
22
|
+
datefmt="%H:%M:%S",
|
|
23
|
+
)
|
|
24
|
+
logger = logging.getLogger("rag-agent")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cmd_configure(args: argparse.Namespace) -> None:
|
|
28
|
+
"""Interactive configuration setup."""
|
|
29
|
+
config = Config.load()
|
|
30
|
+
|
|
31
|
+
print(f"\n Industrial RAG Agent v{__version__}")
|
|
32
|
+
print(f" Config: {CONFIG_DIR}\n")
|
|
33
|
+
|
|
34
|
+
# API key
|
|
35
|
+
key = input(f" API key [{config.api_key[:8] + '...' if config.api_key else 'not set'}]: ").strip()
|
|
36
|
+
if key:
|
|
37
|
+
config.api_key = key
|
|
38
|
+
|
|
39
|
+
# Watch folders
|
|
40
|
+
print(f"\n Current watch folders: {config.watch_folders or '(none)'}")
|
|
41
|
+
print(" Enter folder paths (one per line, empty line to finish):")
|
|
42
|
+
folders: list[str] = []
|
|
43
|
+
while True:
|
|
44
|
+
folder = input(" > ").strip()
|
|
45
|
+
if not folder:
|
|
46
|
+
break
|
|
47
|
+
p = Path(folder).expanduser().resolve()
|
|
48
|
+
if p.is_dir():
|
|
49
|
+
folders.append(str(p))
|
|
50
|
+
print(f" Added: {p}")
|
|
51
|
+
else:
|
|
52
|
+
print(f" [!] Not a directory: {p}")
|
|
53
|
+
if folders:
|
|
54
|
+
config.watch_folders = folders
|
|
55
|
+
|
|
56
|
+
# Port
|
|
57
|
+
port_str = input(f"\n Local API port [{config.local_port}]: ").strip()
|
|
58
|
+
if port_str.isdigit():
|
|
59
|
+
config.local_port = int(port_str)
|
|
60
|
+
|
|
61
|
+
config.save()
|
|
62
|
+
print(f"\n Config saved to {CONFIG_DIR / 'config.json'}")
|
|
63
|
+
|
|
64
|
+
issues = config.validate()
|
|
65
|
+
if issues:
|
|
66
|
+
print("\n Warnings:")
|
|
67
|
+
for issue in issues:
|
|
68
|
+
print(f" - {issue}")
|
|
69
|
+
else:
|
|
70
|
+
print(" Ready to start: industrial-rag-agent start")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cmd_index(args: argparse.Namespace) -> None:
|
|
74
|
+
"""Index all existing documents in configured folders."""
|
|
75
|
+
config = Config.load()
|
|
76
|
+
issues = config.validate()
|
|
77
|
+
if issues:
|
|
78
|
+
print("Configuration issues:")
|
|
79
|
+
for i in issues:
|
|
80
|
+
print(f" - {i}")
|
|
81
|
+
print("Run: industrial-rag-agent configure")
|
|
82
|
+
sys.exit(1)
|
|
83
|
+
|
|
84
|
+
total_files = 0
|
|
85
|
+
total_chunks = 0
|
|
86
|
+
|
|
87
|
+
for folder in config.watch_folders:
|
|
88
|
+
folder_path = Path(folder)
|
|
89
|
+
print(f"\nScanning: {folder_path}")
|
|
90
|
+
for ext in config.extensions:
|
|
91
|
+
for file_path in folder_path.rglob(f"*{ext}"):
|
|
92
|
+
if file_path.name.startswith(".") or file_path.name.startswith("~$"):
|
|
93
|
+
continue
|
|
94
|
+
if not needs_reindex(file_path):
|
|
95
|
+
logger.debug("Skipping (unchanged): %s", file_path.name)
|
|
96
|
+
continue
|
|
97
|
+
print(f" Indexing: {file_path.name}...", end=" ", flush=True)
|
|
98
|
+
text = extract(file_path)
|
|
99
|
+
if text:
|
|
100
|
+
chunks = index_document(file_path, text)
|
|
101
|
+
print(f"{chunks} chunks")
|
|
102
|
+
total_files += 1
|
|
103
|
+
total_chunks += chunks
|
|
104
|
+
else:
|
|
105
|
+
print("(no text extracted)")
|
|
106
|
+
|
|
107
|
+
print(f"\nDone: {total_files} files, {total_chunks} chunks indexed.")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def cmd_search(args: argparse.Namespace) -> None:
|
|
111
|
+
"""Search indexed documents."""
|
|
112
|
+
results = do_search(args.query, limit=args.limit)
|
|
113
|
+
if not results:
|
|
114
|
+
print("No results found.")
|
|
115
|
+
return
|
|
116
|
+
for i, r in enumerate(results, 1):
|
|
117
|
+
print(f"\n--- Result {i} (score: {r['score']}) ---")
|
|
118
|
+
print(f"File: {r['file_name']}")
|
|
119
|
+
print(f"Path: {r['file_path']}")
|
|
120
|
+
print(f"Snippet: {r['snippet']}")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def cmd_status(args: argparse.Namespace) -> None:
|
|
124
|
+
"""Show agent status and index stats."""
|
|
125
|
+
config = Config.load()
|
|
126
|
+
stats = get_stats()
|
|
127
|
+
print(f"\n Industrial RAG Agent v{__version__}")
|
|
128
|
+
print(f" OS: {config.os_type}")
|
|
129
|
+
print(f" Port: {config.local_port}")
|
|
130
|
+
print(f" Watch folders: {config.watch_folders}")
|
|
131
|
+
print(f" Documents indexed: {stats['documents']}")
|
|
132
|
+
print(f" Total chunks: {stats['chunks']}")
|
|
133
|
+
print(f" Database size: {stats['db_size_mb']} MB")
|
|
134
|
+
issues = config.validate()
|
|
135
|
+
if issues:
|
|
136
|
+
print("\n Issues:")
|
|
137
|
+
for i in issues:
|
|
138
|
+
print(f" - {i}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _process_file(file_path: Path) -> None:
|
|
142
|
+
"""Extract and index a single file (used by watcher and initial scan)."""
|
|
143
|
+
if needs_reindex(file_path):
|
|
144
|
+
text = extract(file_path)
|
|
145
|
+
if text:
|
|
146
|
+
index_document(file_path, text)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cmd_start(args: argparse.Namespace) -> None:
|
|
150
|
+
"""Start the agent — watches folders + runs local API server."""
|
|
151
|
+
config = Config.load()
|
|
152
|
+
issues = config.validate()
|
|
153
|
+
if issues:
|
|
154
|
+
print("Configuration issues:")
|
|
155
|
+
for i in issues:
|
|
156
|
+
print(f" - {i}")
|
|
157
|
+
print("Run: industrial-rag-agent configure")
|
|
158
|
+
sys.exit(1)
|
|
159
|
+
|
|
160
|
+
print(f"\n Industrial RAG Agent v{__version__}")
|
|
161
|
+
print(f" API: http://127.0.0.1:{config.local_port}")
|
|
162
|
+
print(f" Watching: {', '.join(config.watch_folders)}")
|
|
163
|
+
print(" Press Ctrl+C to stop.\n")
|
|
164
|
+
|
|
165
|
+
# Initial scan
|
|
166
|
+
logger.info("Running initial scan...")
|
|
167
|
+
for folder in config.watch_folders:
|
|
168
|
+
for ext in config.extensions:
|
|
169
|
+
for file_path in Path(folder).rglob(f"*{ext}"):
|
|
170
|
+
if not file_path.name.startswith(".") and not file_path.name.startswith("~$"):
|
|
171
|
+
_process_file(file_path)
|
|
172
|
+
|
|
173
|
+
stats = get_stats()
|
|
174
|
+
logger.info("Initial scan complete: %d documents, %d chunks", stats["documents"], stats["chunks"])
|
|
175
|
+
|
|
176
|
+
# Start file watcher
|
|
177
|
+
observer = start_watching(
|
|
178
|
+
folders=config.watch_folders,
|
|
179
|
+
extensions=config.extensions,
|
|
180
|
+
on_file=_process_file,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Start API server in a thread
|
|
184
|
+
server = create_server(config)
|
|
185
|
+
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
186
|
+
server_thread.start()
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
while True:
|
|
190
|
+
time.sleep(1)
|
|
191
|
+
except KeyboardInterrupt:
|
|
192
|
+
print("\nShutting down...")
|
|
193
|
+
observer.stop()
|
|
194
|
+
server.shutdown()
|
|
195
|
+
observer.join()
|
|
196
|
+
print("Done.")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def main() -> None:
|
|
200
|
+
parser = argparse.ArgumentParser(
|
|
201
|
+
prog="industrial-rag-agent",
|
|
202
|
+
description="Local RAG agent for Industrial AI Agents — indexes your documents securely on your machine.",
|
|
203
|
+
)
|
|
204
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
205
|
+
|
|
206
|
+
sub = parser.add_subparsers(dest="command")
|
|
207
|
+
|
|
208
|
+
sub.add_parser("configure", help="Interactive configuration setup")
|
|
209
|
+
sub.add_parser("start", help="Start the agent (watcher + API server)")
|
|
210
|
+
sub.add_parser("index", help="Index all documents in configured folders")
|
|
211
|
+
sub.add_parser("status", help="Show agent status and index statistics")
|
|
212
|
+
|
|
213
|
+
sp_search = sub.add_parser("search", help="Search indexed documents")
|
|
214
|
+
sp_search.add_argument("query", help="Search query")
|
|
215
|
+
sp_search.add_argument("--limit", type=int, default=5, help="Max results")
|
|
216
|
+
|
|
217
|
+
args = parser.parse_args()
|
|
218
|
+
|
|
219
|
+
if args.command is None:
|
|
220
|
+
parser.print_help()
|
|
221
|
+
sys.exit(0)
|
|
222
|
+
|
|
223
|
+
commands = {
|
|
224
|
+
"configure": cmd_configure,
|
|
225
|
+
"start": cmd_start,
|
|
226
|
+
"index": cmd_index,
|
|
227
|
+
"search": cmd_search,
|
|
228
|
+
"status": cmd_status,
|
|
229
|
+
}
|
|
230
|
+
commands[args.command](args)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
main()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Configuration management — reads/writes ~/.industrial-rag/config.json."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
from dataclasses import dataclass, field, asdict
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
CONFIG_DIR = Path.home() / ".industrial-rag"
|
|
11
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
12
|
+
DB_FILE = CONFIG_DIR / "index.db"
|
|
13
|
+
LOG_FILE = CONFIG_DIR / "agent.log"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Config:
|
|
18
|
+
"""Runtime configuration for the local RAG agent."""
|
|
19
|
+
|
|
20
|
+
# Folders the agent watches for documents
|
|
21
|
+
watch_folders: list[str] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
# API key linking this install to the client's SaaS account
|
|
24
|
+
api_key: str = ""
|
|
25
|
+
|
|
26
|
+
# URL of the Industrial AI Agents backend (cloud)
|
|
27
|
+
api_url: str = "https://industrial-agents.vercel.app"
|
|
28
|
+
|
|
29
|
+
# Local API port — the agents query localhost:<port>/search
|
|
30
|
+
local_port: int = 3847
|
|
31
|
+
|
|
32
|
+
# Detected OS (auto-filled on first run)
|
|
33
|
+
os_type: str = field(default_factory=lambda: platform.system().lower())
|
|
34
|
+
|
|
35
|
+
# Supported file extensions
|
|
36
|
+
extensions: list[str] = field(
|
|
37
|
+
default_factory=lambda: [".pdf", ".docx", ".xlsx", ".txt", ".csv"]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def load(cls) -> "Config":
|
|
42
|
+
"""Load config from disk, or return defaults."""
|
|
43
|
+
if CONFIG_FILE.exists():
|
|
44
|
+
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
45
|
+
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
|
46
|
+
return cls()
|
|
47
|
+
|
|
48
|
+
def save(self) -> None:
|
|
49
|
+
"""Persist config to disk."""
|
|
50
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
CONFIG_FILE.write_text(
|
|
52
|
+
json.dumps(asdict(self), indent=2, ensure_ascii=False),
|
|
53
|
+
encoding="utf-8",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def validate(self) -> list[str]:
|
|
57
|
+
"""Return list of configuration problems (empty = OK)."""
|
|
58
|
+
issues: list[str] = []
|
|
59
|
+
if not self.watch_folders:
|
|
60
|
+
issues.append("No watch folders configured.")
|
|
61
|
+
for f in self.watch_folders:
|
|
62
|
+
if not Path(f).is_dir():
|
|
63
|
+
issues.append(f"Watch folder does not exist: {f}")
|
|
64
|
+
if not self.api_key:
|
|
65
|
+
issues.append("API key not set.")
|
|
66
|
+
return issues
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Text extraction from PDF, DOCX, XLSX, TXT/CSV — with platform-aware OCR."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import logging
|
|
8
|
+
import platform
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("rag-agent.extractor")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# OCR back-ends
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def _ocr_apple_vision(image_path: str) -> str:
|
|
21
|
+
"""Use macOS Apple Vision Framework via a small Swift helper."""
|
|
22
|
+
script = """
|
|
23
|
+
import Foundation
|
|
24
|
+
import Vision
|
|
25
|
+
|
|
26
|
+
let url = URL(fileURLWithPath: CommandLine.arguments[1])
|
|
27
|
+
guard let img = CGImage.from(url: url) else { exit(1) }
|
|
28
|
+
|
|
29
|
+
let request = VNRecognizeTextRequest()
|
|
30
|
+
request.recognitionLevel = .accurate
|
|
31
|
+
request.recognitionLanguages = ["es", "en", "de", "fr"]
|
|
32
|
+
|
|
33
|
+
let handler = VNImageRequestHandler(cgImage: img)
|
|
34
|
+
try handler.perform([request])
|
|
35
|
+
|
|
36
|
+
let text = request.results?.compactMap { $0.topCandidates(1).first?.string }.joined(separator: "\\n") ?? ""
|
|
37
|
+
print(text)
|
|
38
|
+
"""
|
|
39
|
+
# Apple Vision is called via the system's `swift` interpreter
|
|
40
|
+
try:
|
|
41
|
+
result = subprocess.run(
|
|
42
|
+
["swift", "-e", script, image_path],
|
|
43
|
+
capture_output=True, text=True, timeout=30,
|
|
44
|
+
)
|
|
45
|
+
return result.stdout.strip()
|
|
46
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
47
|
+
logger.warning("Apple Vision OCR failed for %s", image_path)
|
|
48
|
+
return ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ocr_tesseract(image_path: str) -> str:
|
|
52
|
+
"""Use Tesseract OCR (Windows/Linux)."""
|
|
53
|
+
tesseract = shutil.which("tesseract")
|
|
54
|
+
if not tesseract:
|
|
55
|
+
logger.warning("Tesseract not found in PATH — skipping OCR for %s", image_path)
|
|
56
|
+
return ""
|
|
57
|
+
try:
|
|
58
|
+
result = subprocess.run(
|
|
59
|
+
[tesseract, image_path, "stdout", "-l", "spa+eng"],
|
|
60
|
+
capture_output=True, text=True, timeout=60,
|
|
61
|
+
)
|
|
62
|
+
return result.stdout.strip()
|
|
63
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
64
|
+
logger.warning("Tesseract OCR failed for %s", image_path)
|
|
65
|
+
return ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def ocr_image(image_path: str) -> str:
|
|
69
|
+
"""OCR a single image using the platform's native engine."""
|
|
70
|
+
system = platform.system()
|
|
71
|
+
if system == "Darwin":
|
|
72
|
+
return _ocr_apple_vision(image_path)
|
|
73
|
+
else: # Windows or Linux
|
|
74
|
+
return _ocr_tesseract(image_path)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Document extractors
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def extract_pdf(file_path: Path) -> str:
|
|
82
|
+
"""Extract text from PDF; OCR pages that have no text layer."""
|
|
83
|
+
import fitz # PyMuPDF
|
|
84
|
+
|
|
85
|
+
doc = fitz.open(str(file_path))
|
|
86
|
+
pages: list[str] = []
|
|
87
|
+
|
|
88
|
+
for page_num, page in enumerate(doc):
|
|
89
|
+
text = page.get_text().strip()
|
|
90
|
+
if text:
|
|
91
|
+
pages.append(text)
|
|
92
|
+
else:
|
|
93
|
+
# Page is likely scanned — render to image and OCR
|
|
94
|
+
logger.info("OCR page %d of %s", page_num + 1, file_path.name)
|
|
95
|
+
pix = page.get_pixmap(dpi=300)
|
|
96
|
+
img_path = f"/tmp/rag_ocr_{file_path.stem}_p{page_num}.png"
|
|
97
|
+
pix.save(img_path)
|
|
98
|
+
ocr_text = ocr_image(img_path)
|
|
99
|
+
if ocr_text:
|
|
100
|
+
pages.append(ocr_text)
|
|
101
|
+
# Clean up temp image
|
|
102
|
+
Path(img_path).unlink(missing_ok=True)
|
|
103
|
+
|
|
104
|
+
doc.close()
|
|
105
|
+
return "\n\n".join(pages)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def extract_docx(file_path: Path) -> str:
|
|
109
|
+
"""Extract text from Word .docx file."""
|
|
110
|
+
from docx import Document
|
|
111
|
+
|
|
112
|
+
doc = Document(str(file_path))
|
|
113
|
+
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
|
|
114
|
+
# Also extract text from tables
|
|
115
|
+
for table in doc.tables:
|
|
116
|
+
for row in table.rows:
|
|
117
|
+
cells = [cell.text.strip() for cell in row.cells if cell.text.strip()]
|
|
118
|
+
if cells:
|
|
119
|
+
paragraphs.append(" | ".join(cells))
|
|
120
|
+
return "\n".join(paragraphs)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def extract_xlsx(file_path: Path) -> str:
|
|
124
|
+
"""Extract text from Excel .xlsx file."""
|
|
125
|
+
from openpyxl import load_workbook
|
|
126
|
+
|
|
127
|
+
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
|
128
|
+
parts: list[str] = []
|
|
129
|
+
for sheet in wb.sheetnames:
|
|
130
|
+
ws = wb[sheet]
|
|
131
|
+
parts.append(f"=== {sheet} ===")
|
|
132
|
+
for row in ws.iter_rows(values_only=True):
|
|
133
|
+
cells = [str(c) if c is not None else "" for c in row]
|
|
134
|
+
if any(cells):
|
|
135
|
+
parts.append(" | ".join(cells))
|
|
136
|
+
wb.close()
|
|
137
|
+
return "\n".join(parts)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def extract_text(file_path: Path) -> str:
|
|
141
|
+
"""Extract text from .txt or .csv file."""
|
|
142
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
143
|
+
if file_path.suffix.lower() == ".csv":
|
|
144
|
+
# Re-format CSV for better search
|
|
145
|
+
reader = csv.reader(io.StringIO(content))
|
|
146
|
+
lines = [" | ".join(row) for row in reader]
|
|
147
|
+
return "\n".join(lines)
|
|
148
|
+
return content
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# Main entry point
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
EXTRACTORS = {
|
|
156
|
+
".pdf": extract_pdf,
|
|
157
|
+
".docx": extract_docx,
|
|
158
|
+
".xlsx": extract_xlsx,
|
|
159
|
+
".txt": extract_text,
|
|
160
|
+
".csv": extract_text,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def extract(file_path: Path) -> str:
|
|
165
|
+
"""Extract text from a supported file. Returns empty string on error."""
|
|
166
|
+
ext = file_path.suffix.lower()
|
|
167
|
+
extractor = EXTRACTORS.get(ext)
|
|
168
|
+
if not extractor:
|
|
169
|
+
logger.warning("Unsupported file type: %s", ext)
|
|
170
|
+
return ""
|
|
171
|
+
try:
|
|
172
|
+
return extractor(file_path)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
logger.error("Extraction failed for %s: %s", file_path, e)
|
|
175
|
+
return ""
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""SQLite FTS5 indexer — full-text search over extracted document content."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import sqlite3
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .config import DB_FILE
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("rag-agent.indexer")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_db(db_path: Path | None = None) -> sqlite3.Connection:
|
|
17
|
+
"""Open (and initialize) the SQLite database."""
|
|
18
|
+
path = db_path or DB_FILE
|
|
19
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
conn = sqlite3.connect(str(path))
|
|
21
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
22
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
23
|
+
|
|
24
|
+
# Main documents table
|
|
25
|
+
conn.execute("""
|
|
26
|
+
CREATE TABLE IF NOT EXISTS documents (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
file_path TEXT NOT NULL,
|
|
29
|
+
file_name TEXT NOT NULL,
|
|
30
|
+
file_hash TEXT NOT NULL,
|
|
31
|
+
file_size INTEGER NOT NULL,
|
|
32
|
+
indexed_at REAL NOT NULL,
|
|
33
|
+
chunk_count INTEGER DEFAULT 0
|
|
34
|
+
)
|
|
35
|
+
""")
|
|
36
|
+
|
|
37
|
+
# FTS5 virtual table for search
|
|
38
|
+
conn.execute("""
|
|
39
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS chunks USING fts5(
|
|
40
|
+
doc_id,
|
|
41
|
+
chunk_index,
|
|
42
|
+
content,
|
|
43
|
+
content_type,
|
|
44
|
+
tokenize='porter unicode61'
|
|
45
|
+
)
|
|
46
|
+
""")
|
|
47
|
+
|
|
48
|
+
conn.commit()
|
|
49
|
+
return conn
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _file_hash(file_path: Path) -> str:
|
|
53
|
+
"""SHA-256 hash of file contents for change detection."""
|
|
54
|
+
h = hashlib.sha256()
|
|
55
|
+
with open(file_path, "rb") as f:
|
|
56
|
+
for block in iter(lambda: f.read(65536), b""):
|
|
57
|
+
h.update(block)
|
|
58
|
+
return h.hexdigest()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _chunk_text(text: str, max_chars: int = 1000, overlap: int = 200) -> list[str]:
|
|
62
|
+
"""Split text into overlapping chunks for better search relevance."""
|
|
63
|
+
if len(text) <= max_chars:
|
|
64
|
+
return [text]
|
|
65
|
+
|
|
66
|
+
chunks: list[str] = []
|
|
67
|
+
start = 0
|
|
68
|
+
while start < len(text):
|
|
69
|
+
end = start + max_chars
|
|
70
|
+
# Try to break at a paragraph or sentence boundary
|
|
71
|
+
chunk = text[start:end]
|
|
72
|
+
if end < len(text):
|
|
73
|
+
# Look for last paragraph break
|
|
74
|
+
last_para = chunk.rfind("\n\n")
|
|
75
|
+
if last_para > max_chars // 2:
|
|
76
|
+
end = start + last_para + 2
|
|
77
|
+
chunk = text[start:end]
|
|
78
|
+
else:
|
|
79
|
+
# Look for last sentence break
|
|
80
|
+
last_period = chunk.rfind(". ")
|
|
81
|
+
if last_period > max_chars // 2:
|
|
82
|
+
end = start + last_period + 2
|
|
83
|
+
chunk = text[start:end]
|
|
84
|
+
|
|
85
|
+
chunks.append(chunk.strip())
|
|
86
|
+
start = end - overlap
|
|
87
|
+
|
|
88
|
+
return chunks
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def needs_reindex(file_path: Path, db_path: Path | None = None) -> bool:
|
|
92
|
+
"""Check if a file needs (re-)indexing based on its hash."""
|
|
93
|
+
conn = _get_db(db_path)
|
|
94
|
+
try:
|
|
95
|
+
row = conn.execute(
|
|
96
|
+
"SELECT file_hash FROM documents WHERE file_path = ?",
|
|
97
|
+
(str(file_path),),
|
|
98
|
+
).fetchone()
|
|
99
|
+
if row is None:
|
|
100
|
+
return True
|
|
101
|
+
return row[0] != _file_hash(file_path)
|
|
102
|
+
finally:
|
|
103
|
+
conn.close()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def index_document(file_path: Path, text: str, db_path: Path | None = None) -> int:
|
|
107
|
+
"""Index a document's extracted text into FTS5. Returns chunk count."""
|
|
108
|
+
conn = _get_db(db_path)
|
|
109
|
+
doc_id = hashlib.md5(str(file_path).encode()).hexdigest()
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
# Remove old data for this doc
|
|
113
|
+
conn.execute("DELETE FROM chunks WHERE doc_id = ?", (doc_id,))
|
|
114
|
+
conn.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
|
|
115
|
+
|
|
116
|
+
# Chunk and index
|
|
117
|
+
chunks = _chunk_text(text)
|
|
118
|
+
for i, chunk in enumerate(chunks):
|
|
119
|
+
conn.execute(
|
|
120
|
+
"INSERT INTO chunks (doc_id, chunk_index, content, content_type) VALUES (?, ?, ?, ?)",
|
|
121
|
+
(doc_id, str(i), chunk, file_path.suffix.lower()),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Record document metadata
|
|
125
|
+
conn.execute(
|
|
126
|
+
"INSERT INTO documents (id, file_path, file_name, file_hash, file_size, indexed_at, chunk_count) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
127
|
+
(
|
|
128
|
+
doc_id,
|
|
129
|
+
str(file_path),
|
|
130
|
+
file_path.name,
|
|
131
|
+
_file_hash(file_path),
|
|
132
|
+
file_path.stat().st_size,
|
|
133
|
+
time.time(),
|
|
134
|
+
len(chunks),
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
conn.commit()
|
|
139
|
+
logger.info("Indexed %s → %d chunks", file_path.name, len(chunks))
|
|
140
|
+
return len(chunks)
|
|
141
|
+
except Exception as e:
|
|
142
|
+
conn.rollback()
|
|
143
|
+
logger.error("Failed to index %s: %s", file_path, e)
|
|
144
|
+
return 0
|
|
145
|
+
finally:
|
|
146
|
+
conn.close()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def search(query: str, limit: int = 5, db_path: Path | None = None) -> list[dict]:
|
|
150
|
+
"""Search indexed documents. Returns list of {file_name, file_path, snippet, rank}."""
|
|
151
|
+
conn = _get_db(db_path)
|
|
152
|
+
try:
|
|
153
|
+
rows = conn.execute(
|
|
154
|
+
"""
|
|
155
|
+
SELECT
|
|
156
|
+
d.file_name,
|
|
157
|
+
d.file_path,
|
|
158
|
+
snippet(chunks, 2, '<b>', '</b>', '...', 40) AS snippet,
|
|
159
|
+
rank
|
|
160
|
+
FROM chunks
|
|
161
|
+
JOIN documents d ON d.id = chunks.doc_id
|
|
162
|
+
WHERE chunks MATCH ?
|
|
163
|
+
ORDER BY rank
|
|
164
|
+
LIMIT ?
|
|
165
|
+
""",
|
|
166
|
+
(query, limit),
|
|
167
|
+
).fetchall()
|
|
168
|
+
|
|
169
|
+
return [
|
|
170
|
+
{
|
|
171
|
+
"file_name": r[0],
|
|
172
|
+
"file_path": r[1],
|
|
173
|
+
"snippet": r[2],
|
|
174
|
+
"score": round(abs(r[3]), 4),
|
|
175
|
+
}
|
|
176
|
+
for r in rows
|
|
177
|
+
]
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.error("Search failed: %s", e)
|
|
180
|
+
return []
|
|
181
|
+
finally:
|
|
182
|
+
conn.close()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_stats(db_path: Path | None = None) -> dict:
|
|
186
|
+
"""Return index statistics."""
|
|
187
|
+
conn = _get_db(db_path)
|
|
188
|
+
try:
|
|
189
|
+
doc_count = conn.execute("SELECT COUNT(*) FROM documents").fetchone()[0]
|
|
190
|
+
chunk_count = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
|
|
191
|
+
return {"documents": doc_count, "chunks": chunk_count, "db_size_mb": round(DB_FILE.stat().st_size / 1024 / 1024, 2) if DB_FILE.exists() else 0}
|
|
192
|
+
finally:
|
|
193
|
+
conn.close()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Local HTTP API server — the SaaS agents query this endpoint to search client docs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
8
|
+
from urllib.parse import urlparse, parse_qs
|
|
9
|
+
|
|
10
|
+
from .indexer import search, get_stats
|
|
11
|
+
from .config import Config
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("rag-agent.server")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RAGHandler(BaseHTTPRequestHandler):
|
|
17
|
+
"""Minimal HTTP handler: GET /search?q=...&limit=5 and GET /status."""
|
|
18
|
+
|
|
19
|
+
config: Config # Set by the server factory
|
|
20
|
+
|
|
21
|
+
def log_message(self, format: str, *args) -> None:
|
|
22
|
+
logger.debug(format, *args)
|
|
23
|
+
|
|
24
|
+
def _json_response(self, status: int, data: dict) -> None:
|
|
25
|
+
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
26
|
+
self.send_response(status)
|
|
27
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
28
|
+
self.send_header("Content-Length", str(len(body)))
|
|
29
|
+
# CORS — allow the SaaS app to call this from the browser
|
|
30
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
31
|
+
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
32
|
+
self.end_headers()
|
|
33
|
+
self.wfile.write(body)
|
|
34
|
+
|
|
35
|
+
def do_OPTIONS(self) -> None:
|
|
36
|
+
self.send_response(204)
|
|
37
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
38
|
+
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
39
|
+
self.send_header("Access-Control-Allow-Headers", "Authorization")
|
|
40
|
+
self.end_headers()
|
|
41
|
+
|
|
42
|
+
def do_GET(self) -> None:
|
|
43
|
+
parsed = urlparse(self.path)
|
|
44
|
+
path = parsed.path.rstrip("/")
|
|
45
|
+
params = parse_qs(parsed.query)
|
|
46
|
+
|
|
47
|
+
if path == "/search":
|
|
48
|
+
query = params.get("q", [""])[0]
|
|
49
|
+
if not query:
|
|
50
|
+
self._json_response(400, {"error": "Missing ?q= parameter"})
|
|
51
|
+
return
|
|
52
|
+
limit = int(params.get("limit", ["5"])[0])
|
|
53
|
+
results = search(query, limit=limit)
|
|
54
|
+
self._json_response(200, {"query": query, "results": results, "count": len(results)})
|
|
55
|
+
|
|
56
|
+
elif path == "/status":
|
|
57
|
+
stats = get_stats()
|
|
58
|
+
self._json_response(200, {
|
|
59
|
+
"status": "running",
|
|
60
|
+
"version": "0.1.0",
|
|
61
|
+
"os": self.config.os_type,
|
|
62
|
+
"index": stats,
|
|
63
|
+
"watch_folders": self.config.watch_folders,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
elif path == "/health":
|
|
67
|
+
self._json_response(200, {"ok": True})
|
|
68
|
+
|
|
69
|
+
else:
|
|
70
|
+
self._json_response(404, {"error": "Not found. Use /search?q=... or /status"})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def create_server(config: Config) -> HTTPServer:
|
|
74
|
+
"""Create the local API server."""
|
|
75
|
+
RAGHandler.config = config
|
|
76
|
+
server = HTTPServer(("127.0.0.1", config.local_port), RAGHandler)
|
|
77
|
+
logger.info("API server listening on http://127.0.0.1:%d", config.local_port)
|
|
78
|
+
return server
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""File system watcher — monitors folders for new/changed documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
from watchdog.observers import Observer
|
|
11
|
+
from watchdog.events import FileSystemEventHandler, FileSystemEvent
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("rag-agent.watcher")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DocumentHandler(FileSystemEventHandler):
|
|
17
|
+
"""Handles file create/modify events for supported document types."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
extensions: list[str],
|
|
22
|
+
on_file: Callable[[Path], None],
|
|
23
|
+
debounce_seconds: float = 2.0,
|
|
24
|
+
):
|
|
25
|
+
super().__init__()
|
|
26
|
+
self.extensions = {ext.lower() for ext in extensions}
|
|
27
|
+
self.on_file = on_file
|
|
28
|
+
self.debounce = debounce_seconds
|
|
29
|
+
self._last_seen: dict[str, float] = {}
|
|
30
|
+
|
|
31
|
+
def _should_process(self, path: str) -> bool:
|
|
32
|
+
ext = Path(path).suffix.lower()
|
|
33
|
+
if ext not in self.extensions:
|
|
34
|
+
return False
|
|
35
|
+
# Ignore temp/hidden files
|
|
36
|
+
name = Path(path).name
|
|
37
|
+
if name.startswith(".") or name.startswith("~$"):
|
|
38
|
+
return False
|
|
39
|
+
# Debounce — same file within N seconds
|
|
40
|
+
now = time.time()
|
|
41
|
+
last = self._last_seen.get(path, 0)
|
|
42
|
+
if now - last < self.debounce:
|
|
43
|
+
return False
|
|
44
|
+
self._last_seen[path] = now
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
def on_created(self, event: FileSystemEvent) -> None:
|
|
48
|
+
if not event.is_directory and self._should_process(event.src_path):
|
|
49
|
+
logger.info("New file: %s", event.src_path)
|
|
50
|
+
self.on_file(Path(event.src_path))
|
|
51
|
+
|
|
52
|
+
def on_modified(self, event: FileSystemEvent) -> None:
|
|
53
|
+
if not event.is_directory and self._should_process(event.src_path):
|
|
54
|
+
logger.info("Modified file: %s", event.src_path)
|
|
55
|
+
self.on_file(Path(event.src_path))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def start_watching(
|
|
59
|
+
folders: list[str],
|
|
60
|
+
extensions: list[str],
|
|
61
|
+
on_file: Callable[[Path], None],
|
|
62
|
+
) -> Observer:
|
|
63
|
+
"""Start watching folders for document changes. Returns the Observer (call .stop() to end)."""
|
|
64
|
+
observer = Observer()
|
|
65
|
+
handler = DocumentHandler(extensions=extensions, on_file=on_file)
|
|
66
|
+
|
|
67
|
+
for folder in folders:
|
|
68
|
+
folder_path = Path(folder)
|
|
69
|
+
if folder_path.is_dir():
|
|
70
|
+
observer.schedule(handler, str(folder_path), recursive=True)
|
|
71
|
+
logger.info("Watching: %s", folder_path)
|
|
72
|
+
else:
|
|
73
|
+
logger.warning("Folder does not exist, skipping: %s", folder)
|
|
74
|
+
|
|
75
|
+
observer.start()
|
|
76
|
+
return observer
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: industrial-rag-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local RAG agent for Industrial AI Agents — watches folders, extracts text, OCRs scanned PDFs, indexes in SQLite FTS5
|
|
5
|
+
Author-email: Industrial AI Agents <support@industrialagents.com>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: watchdog>=4.0
|
|
10
|
+
Requires-Dist: python-docx>=1.1
|
|
11
|
+
Requires-Dist: openpyxl>=3.1
|
|
12
|
+
Requires-Dist: pymupdf>=1.24
|
|
13
|
+
Requires-Dist: uvicorn>=0.30
|
|
14
|
+
Requires-Dist: httpx>=0.27
|
|
15
|
+
Provides-Extra: ocr-win
|
|
16
|
+
Requires-Dist: pytesseract>=0.3; extra == "ocr-win"
|
|
17
|
+
Provides-Extra: ocr-linux
|
|
18
|
+
Requires-Dist: pytesseract>=0.3; extra == "ocr-linux"
|
|
19
|
+
|
|
20
|
+
# Industrial RAG Agent
|
|
21
|
+
|
|
22
|
+
Local document indexer for **Industrial AI Agents** SaaS.
|
|
23
|
+
|
|
24
|
+
Runs on the client's computer — documents **never leave their network**.
|
|
25
|
+
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
📁 Document Folder
|
|
30
|
+
↓ (file watcher)
|
|
31
|
+
📄 Text Extraction (PDF, DOCX, XLSX, TXT/CSV)
|
|
32
|
+
↓ (+ OCR for scanned PDFs)
|
|
33
|
+
🗃️ SQLite FTS5 Index (local)
|
|
34
|
+
↓
|
|
35
|
+
🌐 Local API (localhost:3847/search)
|
|
36
|
+
↓
|
|
37
|
+
☁️ Industrial AI Agents (cloud) queries this endpoint
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install industrial-rag-agent
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quick Start
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# 1. Configure
|
|
50
|
+
industrial-rag-agent configure
|
|
51
|
+
|
|
52
|
+
# 2. Start (watches folders + runs API)
|
|
53
|
+
industrial-rag-agent start
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Commands
|
|
57
|
+
|
|
58
|
+
| Command | Description |
|
|
59
|
+
|-------------|------------------------------------------|
|
|
60
|
+
| `configure` | Interactive setup (API key, folders, port) |
|
|
61
|
+
| `start` | Start watcher + local API server |
|
|
62
|
+
| `index` | One-time index of all existing documents |
|
|
63
|
+
| `search` | Search from the command line |
|
|
64
|
+
| `status` | Show index statistics |
|
|
65
|
+
|
|
66
|
+
## OCR Support
|
|
67
|
+
|
|
68
|
+
| OS | Engine | Install Required? |
|
|
69
|
+
|---------|---------------------------|-------------------|
|
|
70
|
+
| macOS | Apple Vision (Neural Engine) | No — built in |
|
|
71
|
+
| Windows | Tesseract OCR | Yes |
|
|
72
|
+
| Linux | Tesseract OCR | Yes |
|
|
73
|
+
|
|
74
|
+
## API Endpoints
|
|
75
|
+
|
|
76
|
+
- `GET /search?q=keyword&limit=5` — search indexed documents
|
|
77
|
+
- `GET /status` — agent status and index stats
|
|
78
|
+
- `GET /health` — health check
|
|
79
|
+
|
|
80
|
+
## Requirements
|
|
81
|
+
|
|
82
|
+
- Python 3.11+
|
|
83
|
+
- Tesseract OCR (Windows/Linux only)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
industrial_rag_agent/__init__.py
|
|
4
|
+
industrial_rag_agent/cli.py
|
|
5
|
+
industrial_rag_agent/config.py
|
|
6
|
+
industrial_rag_agent/extractor.py
|
|
7
|
+
industrial_rag_agent/indexer.py
|
|
8
|
+
industrial_rag_agent/server.py
|
|
9
|
+
industrial_rag_agent/watcher.py
|
|
10
|
+
industrial_rag_agent.egg-info/PKG-INFO
|
|
11
|
+
industrial_rag_agent.egg-info/SOURCES.txt
|
|
12
|
+
industrial_rag_agent.egg-info/dependency_links.txt
|
|
13
|
+
industrial_rag_agent.egg-info/entry_points.txt
|
|
14
|
+
industrial_rag_agent.egg-info/requires.txt
|
|
15
|
+
industrial_rag_agent.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "industrial-rag-agent"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Local RAG agent for Industrial AI Agents — watches folders, extracts text, OCRs scanned PDFs, indexes in SQLite FTS5"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "Proprietary"}
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Industrial AI Agents", email = "support@industrialagents.com"},
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"watchdog>=4.0",
|
|
17
|
+
"python-docx>=1.1",
|
|
18
|
+
"openpyxl>=3.1",
|
|
19
|
+
"pymupdf>=1.24",
|
|
20
|
+
"uvicorn>=0.30",
|
|
21
|
+
"httpx>=0.27",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
ocr-win = ["pytesseract>=0.3"]
|
|
26
|
+
ocr-linux = ["pytesseract>=0.3"]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
industrial-rag-agent = "industrial_rag_agent.cli:main"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["."]
|