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 +13 -0
- edip/engine.py +548 -0
- edip/inspector.py +207 -0
- edip/watcher.py +136 -0
- edip-1.1.0.dist-info/METADATA +8 -0
- edip-1.1.0.dist-info/RECORD +8 -0
- edip-1.1.0.dist-info/WHEEL +5 -0
- edip-1.1.0.dist-info/top_level.txt +1 -0
edip/__init__.py
ADDED
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 @@
|
|
|
1
|
+
edip
|