edip 1.1.0__py3-none-any.whl

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.

Potentially problematic release.


This version of edip might be problematic. Click here for more details.

edip/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+
2
+ from .inspector import SchemaInspector
3
+ from .engine import EDIPEngine
4
+ from .watcher import AmbientWatcher
5
+
6
+
7
+ __all__ = [
8
+ "SchemaInspector",
9
+ "EDIPEngine",
10
+ "AmbientWatcher"
11
+ ]
12
+
13
+ __version__ = "1.0.0"
edip/engine.py ADDED
@@ -0,0 +1,548 @@
1
+
2
+ import os
3
+ import json
4
+ import shutil
5
+ import sqlite3
6
+ import datetime
7
+ import requests
8
+
9
+ from typing import Optional, Dict, Any, Tuple, List, Union
10
+
11
+ from .inspector import SchemaInspector
12
+
13
+
14
+ class EDIPEngine:
15
+ """
16
+ Universal Edge Deterministic Ingestion Protocol (EDIP) Engine.
17
+
18
+ Handles arbitrary SQLite schemas, multi-table extraction,
19
+ foreign key resolution, atomic transaction rollback loops,
20
+ automated DLQ quarantine logging, and physical file routing
21
+ (Processed Archive vs. Human Review Quarantine).
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ db_path: str,
27
+ target_tables: Optional[Union[str, List[str]]] = None,
28
+ model: str = "llama3.2:1b",
29
+ api_url: str = "http://localhost:11434/api/generate",
30
+ max_retries: int = 3,
31
+ timeout: int = 15,
32
+ enable_quarantine: bool = True,
33
+ quarantine_table: str = "quarantine_invoices",
34
+ quarantine_dir: Optional[str] = "./Quarantine_Documents",
35
+ archive_dir: Optional[str] = "./Processed_Archive"
36
+ ):
37
+ self.db_path = db_path
38
+ self.model = model
39
+ self.api_url = api_url
40
+ self.max_retries = max_retries
41
+ self.timeout = timeout
42
+ self.enable_quarantine = enable_quarantine
43
+ self.quarantine_table = quarantine_table
44
+ self.quarantine_dir = quarantine_dir
45
+ self.archive_dir = archive_dir
46
+
47
+ self.inspector = SchemaInspector(db_path)
48
+
49
+ # Standardize target_tables or inspect all non-system tables
50
+ if isinstance(target_tables, str):
51
+ self.target_tables = [target_tables]
52
+
53
+ elif isinstance(target_tables, list):
54
+ self.target_tables = target_tables
55
+
56
+ else:
57
+ full_meta = self.inspector.inspect_full_database()
58
+ self.target_tables = list(full_meta.keys())
59
+
60
+ # Compile database schema rules dynamically
61
+ self.schema_prompt = (
62
+ self.inspector.build_system_prompt_schema(
63
+ self.target_tables
64
+ )
65
+ )
66
+
67
+ # Ensure directories and DLQ table exist
68
+ if self.enable_quarantine:
69
+ self._ensure_quarantine_table()
70
+
71
+ if self.quarantine_dir:
72
+ os.makedirs(
73
+ self.quarantine_dir,
74
+ exist_ok=True
75
+ )
76
+
77
+ if self.archive_dir:
78
+ os.makedirs(
79
+ self.archive_dir,
80
+ exist_ok=True
81
+ )
82
+
83
+ def get_connection(self) -> sqlite3.Connection:
84
+ """Creates a configured SQLite database connection."""
85
+
86
+ conn = sqlite3.connect(
87
+ self.db_path,
88
+ timeout=self.timeout
89
+ )
90
+
91
+ conn.execute("PRAGMA journal_mode=WAL;")
92
+ conn.execute("PRAGMA foreign_keys = ON;")
93
+
94
+ return conn
95
+
96
+ def _ensure_quarantine_table(self) -> None:
97
+ """
98
+ Ensures the Dead-Letter Queue (DLQ)
99
+ quarantine table exists.
100
+ """
101
+
102
+ conn = self.get_connection()
103
+
104
+ conn.execute(f"""
105
+ CREATE TABLE IF NOT EXISTS {self.quarantine_table} (
106
+ quarantine_id INTEGER PRIMARY KEY AUTOINCREMENT,
107
+ timestamp TEXT NOT NULL,
108
+ file_reference TEXT,
109
+ target_table TEXT,
110
+ raw_document TEXT,
111
+ last_offending_payload TEXT,
112
+ rejection_reason TEXT,
113
+ retry_attempts INTEGER,
114
+ audit_status TEXT DEFAULT 'PENDING_HUMAN_AUDIT'
115
+ );
116
+ """)
117
+
118
+ conn.commit()
119
+ conn.close()
120
+
121
+ def _move_file_safely(
122
+ self,
123
+ source_path: str,
124
+ target_directory: str
125
+ ) -> Optional[str]:
126
+ """
127
+ Safely moves a physical file to a destination directory,
128
+ avoiding filename collisions.
129
+ """
130
+
131
+ if (
132
+ not source_path
133
+ or not os.path.exists(source_path)
134
+ or not target_directory
135
+ ):
136
+ return None
137
+
138
+ os.makedirs(
139
+ target_directory,
140
+ exist_ok=True
141
+ )
142
+
143
+ filename = os.path.basename(source_path)
144
+
145
+ dest_path = os.path.join(
146
+ target_directory,
147
+ filename
148
+ )
149
+
150
+ # Resolve collisions by appending a sequential counter
151
+ base, ext = os.path.splitext(filename)
152
+
153
+ counter = 1
154
+
155
+ while os.path.exists(dest_path):
156
+ dest_path = os.path.join(
157
+ target_directory,
158
+ f"{base}_{counter}{ext}"
159
+ )
160
+
161
+ counter += 1
162
+
163
+ try:
164
+ shutil.move(
165
+ source_path,
166
+ dest_path
167
+ )
168
+
169
+ return dest_path
170
+
171
+ except Exception as e:
172
+ print(
173
+ f"[EDIP WARNING] Failed to move file "
174
+ f"{source_path} to {target_directory}: {e}"
175
+ )
176
+
177
+ return None
178
+
179
+ def _log_to_quarantine(
180
+ self,
181
+ raw_document: str,
182
+ rejection_reason: str,
183
+ attempts: int,
184
+ last_payload: Optional[Dict[str, Any]] = None,
185
+ file_reference: Optional[str] = None
186
+ ) -> int:
187
+ """
188
+ Logs an unresolvable or rejected record
189
+ into the Dead-Letter Queue.
190
+ """
191
+
192
+ conn = self.get_connection()
193
+ cursor = conn.cursor()
194
+
195
+ now_ts = datetime.datetime.now(
196
+ datetime.timezone.utc
197
+ ).isoformat()
198
+
199
+ cursor.execute(
200
+ f"""
201
+ INSERT INTO {self.quarantine_table} (
202
+ timestamp,
203
+ file_reference,
204
+ target_table,
205
+ raw_document,
206
+ last_offending_payload,
207
+ rejection_reason,
208
+ retry_attempts,
209
+ audit_status
210
+ )
211
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
212
+ """,
213
+ (
214
+ now_ts,
215
+ file_reference or "inline_document",
216
+ ", ".join(self.target_tables),
217
+ raw_document,
218
+ (
219
+ json.dumps(last_payload)
220
+ if last_payload else "None"
221
+ ),
222
+ rejection_reason,
223
+ attempts,
224
+ "PENDING_HUMAN_AUDIT"
225
+ )
226
+ )
227
+
228
+ conn.commit()
229
+
230
+ qid = cursor.lastrowid
231
+
232
+ conn.close()
233
+
234
+ return qid
235
+
236
+ def _call_llm(self, prompt: str) -> str:
237
+ """Sends the extraction prompt to the local Ollama model."""
238
+
239
+ response = requests.post(
240
+ self.api_url,
241
+ json={
242
+ "model": self.model,
243
+ "prompt": prompt,
244
+ "stream": False,
245
+ "format": "json"
246
+ },
247
+ timeout=45
248
+ )
249
+
250
+ response.raise_for_status()
251
+
252
+ return response.json().get("response", "{}")
253
+
254
+ def _insert_payload_transaction(
255
+ self,
256
+ conn: sqlite3.Connection,
257
+ payload: Dict[str, Any]
258
+ ) -> Dict[str, Any]:
259
+ """
260
+ Dynamically executes parameterized SQL inserts
261
+ across arbitrary tables.
262
+ """
263
+
264
+ cursor = conn.cursor()
265
+ inserted_summary = {}
266
+
267
+ # Normalize single-table payloads
268
+ normalized_payload = payload
269
+
270
+ if (
271
+ len(self.target_tables) == 1
272
+ and self.target_tables[0] not in payload
273
+ ):
274
+ normalized_payload = {
275
+ self.target_tables[0]: (
276
+ [payload] if isinstance(payload, dict)
277
+ else payload
278
+ )
279
+ }
280
+
281
+ for table_name, records in normalized_payload.items():
282
+
283
+ if table_name not in self.target_tables:
284
+ continue
285
+
286
+ if isinstance(records, dict):
287
+ records = [records]
288
+
289
+ inserted_rows = 0
290
+
291
+ for record in records:
292
+
293
+ if not isinstance(record, dict) or not record:
294
+ continue
295
+
296
+ columns = list(record.keys())
297
+
298
+ placeholders = ", ".join(
299
+ ["?"] * len(columns)
300
+ )
301
+
302
+ col_names = ", ".join(columns)
303
+
304
+ values = [
305
+ record[col]
306
+ for col in columns
307
+ ]
308
+
309
+ sql = (
310
+ f"INSERT INTO {table_name} "
311
+ f"({col_names}) "
312
+ f"VALUES ({placeholders})"
313
+ )
314
+
315
+ cursor.execute(sql, values)
316
+
317
+ inserted_rows += 1
318
+
319
+ inserted_summary[table_name] = inserted_rows
320
+
321
+ return inserted_summary
322
+
323
+ def ingest_text(
324
+ self,
325
+ document_text: str,
326
+ file_path: Optional[str] = None
327
+ ) -> Tuple[bool, Dict[str, Any]]:
328
+ """
329
+ Executes the closed-loop ingestion protocol.
330
+
331
+ On success, moves the file to archive_dir (if provided).
332
+ On rejection/exhaustion, logs to DLQ and moves the file
333
+ to quarantine_dir.
334
+ """
335
+
336
+ prompt = (
337
+ "You are an automated deterministic data extraction engine.\n"
338
+ "Extract structured data from the document text into "
339
+ "valid JSON matching this schema:\n\n"
340
+ f"{self.schema_prompt}\n\n"
341
+ "Rules:\n"
342
+ "1. Output a JSON object mapping table names "
343
+ "to objects or lists of rows.\n"
344
+ "2. Ensure all column names and foreign keys "
345
+ "match the schema strictly.\n"
346
+ "3. Output ONLY the JSON object.\n\n"
347
+ f"Document Text:\n{document_text}"
348
+ )
349
+
350
+ last_payload = None
351
+ last_error = "Unknown error"
352
+
353
+ file_ref = (
354
+ os.path.basename(file_path)
355
+ if file_path else "inline_text"
356
+ )
357
+
358
+ for attempt in range(1, self.max_retries + 1):
359
+
360
+ try:
361
+ llm_output = self._call_llm(prompt)
362
+ last_payload = json.loads(llm_output)
363
+
364
+ except Exception as e:
365
+ last_error = f"JSON/API parsing error: {e}"
366
+
367
+ prompt = (
368
+ "Your response could not be parsed as valid JSON. "
369
+ f"Error: {e}.\n"
370
+ "Output ONLY valid JSON matching the schema:\n"
371
+ f"{self.schema_prompt}"
372
+ )
373
+
374
+ continue
375
+
376
+ conn = self.get_connection()
377
+
378
+ try:
379
+ # Execute inside an atomic transaction
380
+ with conn:
381
+ summary = self._insert_payload_transaction(
382
+ conn,
383
+ last_payload
384
+ )
385
+
386
+ conn.close()
387
+
388
+ # Physical routing:
389
+ # Move file to archive directory on successful ingestion
390
+ archived_path = None
391
+
392
+ if file_path and self.archive_dir:
393
+ archived_path = self._move_file_safely(
394
+ file_path,
395
+ self.archive_dir
396
+ )
397
+
398
+ return True, {
399
+ "status": "inserted",
400
+ "summary": summary,
401
+ "attempt": attempt,
402
+ "payload": last_payload,
403
+ "archived_path": archived_path
404
+ }
405
+
406
+ except sqlite3.IntegrityError as db_err:
407
+ conn.close()
408
+
409
+ last_error = f"SQLite IntegrityError: {db_err}"
410
+
411
+ # Idempotent deduplication check
412
+ if "UNIQUE constraint failed" in str(db_err):
413
+
414
+ archived_path = None
415
+
416
+ if file_path and self.archive_dir:
417
+ archived_path = self._move_file_safely(
418
+ file_path,
419
+ self.archive_dir
420
+ )
421
+
422
+ return True, {
423
+ "status": "idempotent_duplicate",
424
+ "error": str(db_err),
425
+ "attempt": attempt,
426
+ "payload": last_payload,
427
+ "archived_path": archived_path
428
+ }
429
+
430
+ prompt = (
431
+ "Your extracted data was rejected by SQLite "
432
+ f"constraint validation: {db_err}.\n"
433
+ f"Offending payload: {json.dumps(last_payload)}\n"
434
+ f"Database Schema Constraints:\n"
435
+ f"{self.schema_prompt}\n\n"
436
+ "Correct the foreign keys, required values, "
437
+ "or types and output ONLY valid corrected JSON.\n"
438
+ f"Original Document:\n{document_text}"
439
+ )
440
+
441
+ except Exception as err:
442
+ conn.close()
443
+
444
+ last_error = f"Execution error: {err}"
445
+
446
+ prompt = (
447
+ f"Execution failed with error: {err}.\n"
448
+ f"Schema:\n{self.schema_prompt}\n\n"
449
+ f"Original Document:\n{document_text}"
450
+ )
451
+
452
+ # Retry budget exhausted:
453
+ # Isolate to Dead-Letter Queue and physical quarantine directory
454
+ qid = None
455
+
456
+ if self.enable_quarantine:
457
+ qid = self._log_to_quarantine(
458
+ raw_document=document_text,
459
+ rejection_reason=last_error,
460
+ attempts=self.max_retries,
461
+ last_payload=last_payload,
462
+ file_reference=file_ref
463
+ )
464
+
465
+ quarantined_path = None
466
+
467
+ if file_path and self.quarantine_dir:
468
+ quarantined_path = self._move_file_safely(
469
+ file_path,
470
+ self.quarantine_dir
471
+ )
472
+
473
+ return False, {
474
+ "status": "quarantined",
475
+ "error": (
476
+ "Exceeded maximum retry limit without "
477
+ "satisfying constraints"
478
+ ),
479
+ "last_error": last_error,
480
+ "attempts": self.max_retries,
481
+ "quarantine_id": qid,
482
+ "quarantined_path": quarantined_path,
483
+ "audit_status": (
484
+ "PENDING_HUMAN_AUDIT" if qid else None
485
+ )
486
+ }
487
+
488
+ def ingest_document(
489
+ self,
490
+ file_path: str
491
+ ) -> Tuple[bool, Dict[str, Any]]:
492
+ """
493
+ Extracts text from a local document (PDF or text)
494
+ and runs the ingestion protocol.
495
+ """
496
+
497
+ if not os.path.exists(file_path):
498
+ return False, {
499
+ "status": "file_not_found",
500
+ "error": f"File does not exist: {file_path}"
501
+ }
502
+
503
+ text = ""
504
+
505
+ try:
506
+ if file_path.lower().endswith(".pdf"):
507
+
508
+ try:
509
+ import pymupdf
510
+
511
+ doc = pymupdf.open(file_path)
512
+
513
+ text = "".join(
514
+ page.get_text()
515
+ for page in doc
516
+ )
517
+
518
+ doc.close()
519
+
520
+ except ImportError:
521
+ from pypdf import PdfReader
522
+
523
+ reader = PdfReader(file_path)
524
+
525
+ text = "".join(
526
+ page.extract_text() or ""
527
+ for page in reader.pages
528
+ )
529
+
530
+ else:
531
+ with open(
532
+ file_path,
533
+ "r",
534
+ encoding="utf-8",
535
+ errors="ignore"
536
+ ) as f:
537
+ text = f.read()
538
+
539
+ return self.ingest_text(
540
+ document_text=text,
541
+ file_path=file_path
542
+ )
543
+
544
+ except Exception as e:
545
+ return False, {
546
+ "status": "read_error",
547
+ "error": str(e)
548
+ }
edip/inspector.py ADDED
@@ -0,0 +1,207 @@
1
+
2
+ import sqlite3
3
+ from typing import Dict, Any, List
4
+
5
+
6
+ class SchemaInspector:
7
+ """
8
+ Dynamically maps any SQLite database:
9
+ tables, columns, constraints, and relational dependencies.
10
+ """
11
+
12
+ def __init__(self, db_path: str):
13
+ self.db_path = db_path
14
+
15
+ def get_connection(self) -> sqlite3.Connection:
16
+ """Creates a database connection with foreign keys enabled."""
17
+ conn = sqlite3.connect(self.db_path)
18
+ conn.execute("PRAGMA foreign_keys = ON;")
19
+ return conn
20
+
21
+ def inspect_full_database(self) -> Dict[str, Any]:
22
+ """
23
+ Discovers all non-system tables, columns, constraints,
24
+ and valid foreign references.
25
+ """
26
+ conn = self.get_connection()
27
+ cursor = conn.cursor()
28
+
29
+ # 1. Discover all user tables
30
+ cursor.execute("""
31
+ SELECT name
32
+ FROM sqlite_master
33
+ WHERE type = 'table'
34
+ AND name NOT LIKE 'sqlite_%';
35
+ """)
36
+
37
+ tables = [row[0] for row in cursor.fetchall()]
38
+ full_schema: Dict[str, Any] = {}
39
+
40
+ for table in tables:
41
+
42
+ # 2. Inspect table columns
43
+ cursor.execute(f"PRAGMA table_info('{table}');")
44
+ cols_raw = cursor.fetchall()
45
+
46
+ columns = [
47
+ {
48
+ "name": col[1],
49
+ "type": col[2] or "TEXT",
50
+ "required": bool(col[3]),
51
+ "is_pk": bool(col[5])
52
+ }
53
+ for col in cols_raw
54
+ ]
55
+
56
+ # 3. Inspect foreign key constraints
57
+ cursor.execute(f"PRAGMA foreign_key_list('{table}');")
58
+ fks_raw = cursor.fetchall()
59
+
60
+ foreign_keys = [
61
+ {
62
+ "from_col": fk[3],
63
+ "to_table": fk[2],
64
+ "to_col": fk[4]
65
+ }
66
+ for fk in fks_raw
67
+ ]
68
+
69
+ # 4. Fetch active reference values from parent tables
70
+ valid_references: Dict[str, List[str]] = {}
71
+
72
+ for fk in foreign_keys:
73
+ parent_table = fk["to_table"]
74
+ parent_col = fk["to_col"]
75
+
76
+ try:
77
+ cursor.execute(
78
+ f"PRAGMA table_info('{parent_table}');"
79
+ )
80
+
81
+ p_cols = [
82
+ c[1] for c in cursor.fetchall()
83
+ ]
84
+
85
+ name_col = next(
86
+ (
87
+ c for c in p_cols
88
+ if any(
89
+ k in c.lower()
90
+ for k in [
91
+ "name",
92
+ "title",
93
+ "vendor",
94
+ "label"
95
+ ]
96
+ )
97
+ ),
98
+ None
99
+ )
100
+
101
+ if name_col:
102
+ cursor.execute(
103
+ f"""
104
+ SELECT "{parent_col}", "{name_col}"
105
+ FROM "{parent_table}"
106
+ LIMIT 50;
107
+ """
108
+ )
109
+
110
+ valid_references[fk["from_col"]] = [
111
+ f"{r[0]} ({r[1]})"
112
+ for r in cursor.fetchall()
113
+ ]
114
+
115
+ else:
116
+ cursor.execute(
117
+ f"""
118
+ SELECT "{parent_col}"
119
+ FROM "{parent_table}"
120
+ LIMIT 50;
121
+ """
122
+ )
123
+
124
+ valid_references[fk["from_col"]] = [
125
+ str(r[0])
126
+ for r in cursor.fetchall()
127
+ ]
128
+
129
+ except sqlite3.OperationalError:
130
+ pass
131
+
132
+ # 5. Store complete table metadata
133
+ full_schema[table] = {
134
+ "columns": columns,
135
+ "foreign_keys": foreign_keys,
136
+ "valid_references": valid_references
137
+ }
138
+
139
+ conn.close()
140
+
141
+ return full_schema
142
+
143
+ def build_system_prompt_schema(
144
+ self,
145
+ target_tables: List[str] = None
146
+ ) -> str:
147
+ """
148
+ Translates database metadata into concise,
149
+ multi-table JSON extraction rules for LLMs.
150
+ """
151
+
152
+ schema = self.inspect_full_database()
153
+
154
+ tables_to_include = target_tables or list(schema.keys())
155
+
156
+ schema_lines = []
157
+
158
+ for table in tables_to_include:
159
+
160
+ if table not in schema:
161
+ continue
162
+
163
+ meta = schema[table]
164
+
165
+ # Identify insertable columns
166
+ insertable_cols = [
167
+ f"{c['name']} ({c['type']})"
168
+ for c in meta["columns"]
169
+ if not c["is_pk"]
170
+ ]
171
+
172
+ # Build foreign key constraint rules
173
+ fk_rules = ""
174
+
175
+ if meta["foreign_keys"]:
176
+ fk_clauses = [
177
+ (
178
+ f"{fk['from_col']} -> "
179
+ f"{fk['to_table']}({fk['to_col']})"
180
+ )
181
+ for fk in meta["foreign_keys"]
182
+ ]
183
+
184
+ fk_rules = (
185
+ " | Constraints: "
186
+ + "; ".join(fk_clauses)
187
+ )
188
+
189
+ # Build valid reference rules
190
+ ref_rules = ""
191
+
192
+ if meta["valid_references"]:
193
+ ref_clauses = [
194
+ f"Valid {col}: {vals}"
195
+ for col, vals in meta["valid_references"].items()
196
+ ]
197
+
198
+ ref_rules = " | " + "; ".join(ref_clauses)
199
+
200
+ # Build final table schema description
201
+ schema_lines.append(
202
+ f"Table '{table}': "
203
+ f"Columns: [{', '.join(insertable_cols)}]"
204
+ f"{fk_rules}{ref_rules}"
205
+ )
206
+
207
+ return "\n".join(schema_lines)
edip/watcher.py ADDED
@@ -0,0 +1,136 @@
1
+
2
+ import os
3
+ import time
4
+
5
+ from typing import Optional, Callable
6
+
7
+ from watchdog.observers import Observer
8
+ from watchdog.events import FileSystemEventHandler
9
+
10
+ from .engine import EDIPEngine
11
+
12
+
13
+ class DocumentWatchHandler(FileSystemEventHandler):
14
+ """
15
+ Handles incoming document events and sends supported
16
+ files to the EDIP ingestion engine.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ engine: EDIPEngine,
22
+ callback: Optional[Callable] = None
23
+ ):
24
+ super().__init__()
25
+
26
+ self.engine = engine
27
+ self.callback = callback
28
+
29
+ def on_created(self, event):
30
+ """
31
+ Processes newly created PDF and TXT files.
32
+ """
33
+
34
+ # Ignore directories and unsupported file types
35
+ if (
36
+ event.is_directory
37
+ or not event.src_path.lower().endswith((".pdf", ".txt"))
38
+ ):
39
+ return
40
+
41
+ # Wait for OS file write operations to complete
42
+ time.sleep(1.2)
43
+
44
+ file_path = event.src_path
45
+
46
+ # If the file has already been moved or deleted, skip it
47
+ if not os.path.exists(file_path):
48
+ return
49
+
50
+ try:
51
+ # Process document through EDIP
52
+ success, info = self.engine.ingest_document(file_path)
53
+
54
+ # Notify callback with processing results
55
+ if self.callback:
56
+ self.callback(
57
+ file_path,
58
+ success,
59
+ info
60
+ )
61
+
62
+ except Exception as e:
63
+ if self.callback:
64
+ self.callback(
65
+ file_path,
66
+ False,
67
+ {"error": str(e)}
68
+ )
69
+
70
+
71
+ class AmbientWatcher:
72
+ """
73
+ Monitors a local inbound directory, runs EDIP,
74
+ and routes files to Archive or Quarantine.
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ engine: EDIPEngine,
80
+ watch_dir: str = "./Inbound_Invoices",
81
+ callback: Optional[Callable] = None
82
+ ):
83
+ self.engine = engine
84
+ self.watch_dir = watch_dir
85
+ self.callback = callback
86
+
87
+ self.observer: Optional[Observer] = None
88
+
89
+ def start(self, blocking: bool = True):
90
+ """
91
+ Starts monitoring the inbound directory.
92
+ """
93
+
94
+ # Ensure the monitored directory exists
95
+ os.makedirs(
96
+ self.watch_dir,
97
+ exist_ok=True
98
+ )
99
+
100
+ # Initialize document event handler
101
+ handler = DocumentWatchHandler(
102
+ self.engine,
103
+ self.callback
104
+ )
105
+
106
+ # Initialize filesystem observer
107
+ self.observer = Observer()
108
+
109
+ self.observer.schedule(
110
+ handler,
111
+ path=self.watch_dir,
112
+ recursive=False
113
+ )
114
+
115
+ self.observer.start()
116
+
117
+ # Keep the watcher running
118
+ if blocking:
119
+ try:
120
+ while True:
121
+ time.sleep(1)
122
+
123
+ except KeyboardInterrupt:
124
+ self.stop()
125
+
126
+ def stop(self):
127
+ """
128
+ Stops filesystem monitoring safely.
129
+ """
130
+
131
+ if (
132
+ self.observer
133
+ and self.observer.is_alive()
134
+ ):
135
+ self.observer.stop()
136
+ self.observer.join()
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: edip
3
+ Version: 1.1.0
4
+ Summary: Edge Deterministic Ingestion Protocol using dynamic SQLite constraint reflection
5
+ Author: Uditha Manohara Prasad
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: requests>=2.28.0
8
+ Requires-Dist: watchdog>=3.0.0
@@ -0,0 +1,8 @@
1
+ edip/__init__.py,sha256=EDFrUs6WSL69J7ZuSoNPsnoJ81nVTaOac1pNi8iSo8g,221
2
+ edip/engine.py,sha256=HDV11zRK1v-bM03-e2vlrvNLjvFhZwBKekuhYU1JL0A,16136
3
+ edip/inspector.py,sha256=9FZZwWNiH4JJkVDrLCeZmzj4eaxMAPnBswl0vJvp1rg,6125
4
+ edip/watcher.py,sha256=Gn86d-QSd737ZF1FmEkDJGUHwlDc2EADqJz0CUOle18,3320
5
+ edip-1.1.0.dist-info/METADATA,sha256=GnvD3lHOJcsEmjYOnCGKbDA1IxtunLoolPcNHYuhUtg,263
6
+ edip-1.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ edip-1.1.0.dist-info/top_level.txt,sha256=g21vaLf3JPYAh4da0CvJ7iflfpUzYU5NHlguAfjPIJ4,5
8
+ edip-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ edip