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.
@@ -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,3 @@
1
+ """Industrial RAG Agent — local document indexer for Industrial AI Agents SaaS."""
2
+
3
+ __version__ = "0.1.0"
@@ -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,2 @@
1
+ [console_scripts]
2
+ industrial-rag-agent = industrial_rag_agent.cli:main
@@ -0,0 +1,12 @@
1
+ watchdog>=4.0
2
+ python-docx>=1.1
3
+ openpyxl>=3.1
4
+ pymupdf>=1.24
5
+ uvicorn>=0.30
6
+ httpx>=0.27
7
+
8
+ [ocr-linux]
9
+ pytesseract>=0.3
10
+
11
+ [ocr-win]
12
+ pytesseract>=0.3
@@ -0,0 +1,2 @@
1
+ dist
2
+ industrial_rag_agent
@@ -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 = ["."]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+