upii 0.7.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.
- upii/__init__.py +1 -0
- upii/ambient/calendar_connector.py +106 -0
- upii/ambient/dummy_sources.py +54 -0
- upii/ambient/email_connector.py +74 -0
- upii/ambient/sources.py +111 -0
- upii/ambient/storage.py +159 -0
- upii/ambient/watcher.py +381 -0
- upii/analysis/__init__.py +0 -0
- upii/analysis/diagnostics.py +104 -0
- upii/analysis/embeddings.py +18 -0
- upii/analysis/entity_extractor.py +168 -0
- upii/analysis/graph.py +312 -0
- upii/analysis/llm.py +120 -0
- upii/analysis/metrics.py +107 -0
- upii/analysis/nlp.py +51 -0
- upii/analysis/rehydration.py +324 -0
- upii/analysis/search.py +39 -0
- upii/cli.py +1028 -0
- upii/core/__init__.py +0 -0
- upii/core/concurrency.py +44 -0
- upii/core/config.py +65 -0
- upii/core/errors.py +15 -0
- upii/core/features.py +67 -0
- upii/core/logger.py +41 -0
- upii/core/types.py +65 -0
- upii/core/utils.py +5 -0
- upii/ingestion/__init__.py +0 -0
- upii/ingestion/chunker.py +49 -0
- upii/ingestion/identity.py +24 -0
- upii/ingestion/loader.py +147 -0
- upii/ingestion/pipeline.py +218 -0
- upii/overlay/app.py +241 -0
- upii/overlay/daemon.py +62 -0
- upii/storage/__init__.py +0 -0
- upii/storage/db.py +592 -0
- upii/storage/vector.py +165 -0
- upii-0.7.0.dist-info/METADATA +199 -0
- upii-0.7.0.dist-info/RECORD +42 -0
- upii-0.7.0.dist-info/WHEEL +5 -0
- upii-0.7.0.dist-info/entry_points.txt +2 -0
- upii-0.7.0.dist-info/licenses/LICENSE +21 -0
- upii-0.7.0.dist-info/top_level.txt +1 -0
upii/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.7.0"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import datetime
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import List, Dict, Generator
|
|
6
|
+
from upii.core.logger import logger
|
|
7
|
+
from upii.storage.db import DB
|
|
8
|
+
|
|
9
|
+
class CalendarConnector:
|
|
10
|
+
"""
|
|
11
|
+
Parses .ics files and ingests metadata into LTM.
|
|
12
|
+
Focus: Title, Start, End, Participants.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self):
|
|
16
|
+
self.db = DB()
|
|
17
|
+
|
|
18
|
+
def process_file(self, file_path: str) -> int:
|
|
19
|
+
"""Parses an ICS file and saves events. Returns count."""
|
|
20
|
+
if not os.path.exists(file_path):
|
|
21
|
+
logger.error(f"Calendar file not found: {file_path}")
|
|
22
|
+
return 0
|
|
23
|
+
|
|
24
|
+
count = 0
|
|
25
|
+
try:
|
|
26
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
27
|
+
content = f.read()
|
|
28
|
+
|
|
29
|
+
events = self._parse_ics(content)
|
|
30
|
+
self.db.init_db()
|
|
31
|
+
|
|
32
|
+
for event in events:
|
|
33
|
+
event['source_file'] = os.path.basename(file_path)
|
|
34
|
+
self.db.add_calendar_event(event)
|
|
35
|
+
count += 1
|
|
36
|
+
|
|
37
|
+
logger.info(f"Ingested {count} calendar events from {file_path}")
|
|
38
|
+
except Exception as e:
|
|
39
|
+
logger.error(f"Failed to process calendar {file_path}: {e}")
|
|
40
|
+
|
|
41
|
+
return count
|
|
42
|
+
|
|
43
|
+
def _parse_ics(self, content: str) -> List[Dict]:
|
|
44
|
+
"""
|
|
45
|
+
Robust-ish Regex parser for ICS.
|
|
46
|
+
(Production would use `icalendar` lib, but we avoid deps here).
|
|
47
|
+
"""
|
|
48
|
+
events = []
|
|
49
|
+
# Split by VEVENT
|
|
50
|
+
raw_events = re.findall(r'BEGIN:VEVENT(.*?)END:VEVENT', content, re.DOTALL)
|
|
51
|
+
|
|
52
|
+
for raw in raw_events:
|
|
53
|
+
try:
|
|
54
|
+
# 1. Title (SUMMARY)
|
|
55
|
+
summary_match = re.search(r'SUMMARY:(.*)', raw)
|
|
56
|
+
title = summary_match.group(1).strip() if summary_match else "Untitled Event"
|
|
57
|
+
|
|
58
|
+
# 2. Date (DTSTART/DTEND)
|
|
59
|
+
# Formats: 20230101T120000Z or ;TZID=...:2023...
|
|
60
|
+
dtstart_match = re.search(r'DTSTART.*?:(\d{8}T\d{6}Z|\d{8}T\d{6}|\d{8})', raw)
|
|
61
|
+
dtend_match = re.search(r'DTEND.*?:(\d{8}T\d{6}Z|\d{8}T\d{6}|\d{8})', raw)
|
|
62
|
+
|
|
63
|
+
start_str = dtstart_match.group(1) if dtstart_match else None
|
|
64
|
+
end_str = dtend_match.group(1) if dtend_match else None
|
|
65
|
+
|
|
66
|
+
if not start_str: continue # Skip if no time
|
|
67
|
+
|
|
68
|
+
# Normalize Time
|
|
69
|
+
start_time = self._parse_ical_date(start_str)
|
|
70
|
+
end_time = self._parse_ical_date(end_str) if end_str else start_time
|
|
71
|
+
|
|
72
|
+
# 3. Participants (ATTENDEE)
|
|
73
|
+
# ATTENDEE;CN="Alice Smith":mailto:alice@example.com
|
|
74
|
+
attendees = []
|
|
75
|
+
for line in raw.split('\n'):
|
|
76
|
+
if line.startswith('ATTENDEE'):
|
|
77
|
+
# Extract email or CN
|
|
78
|
+
if 'mailto:' in line:
|
|
79
|
+
email = line.split('mailto:')[-1].strip()
|
|
80
|
+
attendees.append(email)
|
|
81
|
+
elif 'CN=' in line:
|
|
82
|
+
cn = line.split('CN=')[1].split(':')[0].replace('"','').strip()
|
|
83
|
+
attendees.append(cn)
|
|
84
|
+
|
|
85
|
+
events.append({
|
|
86
|
+
"event_id": str(uuid.uuid4()),
|
|
87
|
+
"title": title,
|
|
88
|
+
"start_time": start_time,
|
|
89
|
+
"end_time": end_time,
|
|
90
|
+
"participants": attendees
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
except Exception as e:
|
|
94
|
+
logger.warning(f"Failed to parse event: {e}")
|
|
95
|
+
|
|
96
|
+
return events
|
|
97
|
+
|
|
98
|
+
def _parse_ical_date(self, date_str: str) -> str:
|
|
99
|
+
"""Converts ICS date/datetime to ISO8601 string."""
|
|
100
|
+
# YYYYMMDD
|
|
101
|
+
if len(date_str) == 8:
|
|
102
|
+
return f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:]}T00:00:00"
|
|
103
|
+
|
|
104
|
+
# YYYYMMDDTHHMMSS(Z)
|
|
105
|
+
clean = date_str.replace('Z', '')
|
|
106
|
+
return f"{clean[:4]}-{clean[4:6]}-{clean[6:8]}T{clean[9:11]}:{clean[11:13]}:{clean[13:]}"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import random
|
|
3
|
+
from upii.ambient.sources import Source, registry
|
|
4
|
+
from upii.core.concurrency import SafeRunner
|
|
5
|
+
from upii.core.logger import logger
|
|
6
|
+
|
|
7
|
+
class BrowserSource(Source):
|
|
8
|
+
"""Simulates capturing high-dwell web pages."""
|
|
9
|
+
def __init__(self):
|
|
10
|
+
super().__init__("browser", "Captures metadata from high-dwell browser tabs.")
|
|
11
|
+
self.thread = None
|
|
12
|
+
|
|
13
|
+
def start(self):
|
|
14
|
+
self.is_running = True
|
|
15
|
+
self.thread = SafeRunner.run_daemon(self._loop, name="BrowserWatcher")
|
|
16
|
+
|
|
17
|
+
def stop(self):
|
|
18
|
+
self.is_running = False
|
|
19
|
+
|
|
20
|
+
def _loop(self):
|
|
21
|
+
while self.is_running:
|
|
22
|
+
time.sleep(10) # Simulate infrequent capture
|
|
23
|
+
# In a real app, this would poll a browser extension API
|
|
24
|
+
# Here we just randomly decide to 'capture' something if enabled
|
|
25
|
+
if random.random() < 0.1: # 10% chance per loop
|
|
26
|
+
logger.info("BrowserSource: simulating capture")
|
|
27
|
+
fake_url = "https://example.com/interesting-article"
|
|
28
|
+
self.storage.add_event("browser_visit", fake_url)
|
|
29
|
+
self.storage.log_audit(self.name, "capture", {"url": fake_url, "dwell_time": 120})
|
|
30
|
+
|
|
31
|
+
class CalendarSource(Source):
|
|
32
|
+
"""Simulates capturing calendar metadata."""
|
|
33
|
+
def __init__(self):
|
|
34
|
+
super().__init__("calendar", "Ingests event titles and participants.")
|
|
35
|
+
|
|
36
|
+
def start(self):
|
|
37
|
+
self.is_running = True
|
|
38
|
+
# Calendar might be poll-once or interval
|
|
39
|
+
self.thread = SafeRunner.run_daemon(self._loop, name="CalendarWatcher")
|
|
40
|
+
|
|
41
|
+
def stop(self):
|
|
42
|
+
self.is_running = False
|
|
43
|
+
|
|
44
|
+
def _loop(self):
|
|
45
|
+
while self.is_running:
|
|
46
|
+
time.sleep(20)
|
|
47
|
+
if random.random() < 0.1:
|
|
48
|
+
logger.info("CalendarSource: simulating capture")
|
|
49
|
+
self.storage.add_event("calendar_event", "Meeting with Omega Team")
|
|
50
|
+
self.storage.log_audit(self.name, "capture", {"summary": "Meeting", "participants": ["alice@example.com"]})
|
|
51
|
+
|
|
52
|
+
# Register
|
|
53
|
+
registry.register(BrowserSource())
|
|
54
|
+
registry.register(CalendarSource())
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import mailbox
|
|
2
|
+
import os
|
|
3
|
+
import email
|
|
4
|
+
from email.header import decode_header
|
|
5
|
+
from typing import Generator, Dict, Any
|
|
6
|
+
from upii.core.logger import logger
|
|
7
|
+
|
|
8
|
+
class EmailConnector:
|
|
9
|
+
"""
|
|
10
|
+
Parses email files (.mbox, .eml) and extracts content.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def parse_mbox(file_path: str) -> Generator[Dict[str, Any], None, None]:
|
|
15
|
+
"""Parses an MBOX file and yields email data dictionaries."""
|
|
16
|
+
if not os.path.exists(file_path):
|
|
17
|
+
logger.error(f"Mbox file not found: {file_path}")
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
mbox = mailbox.mbox(file_path)
|
|
22
|
+
for message in mbox:
|
|
23
|
+
try:
|
|
24
|
+
yield EmailConnector._extract_message_data(message, file_path)
|
|
25
|
+
except Exception as e:
|
|
26
|
+
logger.warning(f"Failed to parse email message: {e}")
|
|
27
|
+
except Exception as e:
|
|
28
|
+
logger.error(f"Failed to open mbox {file_path}: {e}")
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def _extract_message_data(msg, source_file: str) -> Dict[str, Any]:
|
|
32
|
+
"""Extracts title, sender, date, and body from an email message."""
|
|
33
|
+
|
|
34
|
+
def decode_str(s):
|
|
35
|
+
if not s: return ""
|
|
36
|
+
try:
|
|
37
|
+
decoded_list = decode_header(s)
|
|
38
|
+
return "".join([
|
|
39
|
+
(t[0].decode(t[1] or 'utf-8') if isinstance(t[0], bytes) else t[0])
|
|
40
|
+
for t in decoded_list
|
|
41
|
+
])
|
|
42
|
+
except:
|
|
43
|
+
return str(s)
|
|
44
|
+
|
|
45
|
+
subject = decode_str(msg.get('subject', 'No Subject'))
|
|
46
|
+
sender = decode_str(msg.get('from', 'Unknown'))
|
|
47
|
+
date = msg.get('date', '')
|
|
48
|
+
|
|
49
|
+
# Body extraction
|
|
50
|
+
body = ""
|
|
51
|
+
if msg.is_multipart():
|
|
52
|
+
for part in msg.walk():
|
|
53
|
+
content_type = part.get_content_type()
|
|
54
|
+
content_disposition = str(part.get("Content-Disposition"))
|
|
55
|
+
|
|
56
|
+
if content_type == "text/plain" and "attachment" not in content_disposition:
|
|
57
|
+
try:
|
|
58
|
+
body += part.get_payload(decode=True).decode('utf-8', errors='ignore')
|
|
59
|
+
except: pass
|
|
60
|
+
else:
|
|
61
|
+
try:
|
|
62
|
+
body = msg.get_payload(decode=True).decode('utf-8', errors='ignore')
|
|
63
|
+
except: pass
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
"title": subject,
|
|
67
|
+
"sender": sender,
|
|
68
|
+
"date": date,
|
|
69
|
+
"content": f"From: {sender}\nDate: {date}\nSubject: {subject}\n\n{body}",
|
|
70
|
+
"source_type": "email",
|
|
71
|
+
# We might want a unique ID for the email if available, else random
|
|
72
|
+
"email_id": msg.get('Message-ID', ''),
|
|
73
|
+
"source_file": source_file
|
|
74
|
+
}
|
upii/ambient/sources.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
import os
|
|
3
|
+
import yaml
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Dict, Any, List, Optional
|
|
6
|
+
from upii.core.config import config
|
|
7
|
+
from upii.core.concurrency import SafeRunner
|
|
8
|
+
from upii.ambient.storage import StagingDB
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("upii.ambient.sources")
|
|
11
|
+
|
|
12
|
+
class Source(abc.ABC):
|
|
13
|
+
"""Abstract Base Class for Ambient Sources."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, name: str, description: str):
|
|
16
|
+
self.name = name
|
|
17
|
+
self.description = description
|
|
18
|
+
self.is_running = False
|
|
19
|
+
self.config: Dict[str, Any] = {}
|
|
20
|
+
self.storage = StagingDB()
|
|
21
|
+
|
|
22
|
+
@abc.abstractmethod
|
|
23
|
+
def start(self):
|
|
24
|
+
"""Start the passive capture loop."""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
@abc.abstractmethod
|
|
28
|
+
def stop(self):
|
|
29
|
+
"""Stop capture."""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
def configure(self, new_config: Dict[str, Any]):
|
|
33
|
+
"""Update configuration."""
|
|
34
|
+
self.config.update(new_config)
|
|
35
|
+
|
|
36
|
+
class SourceRegistry:
|
|
37
|
+
"""Manages lifecycle and configuration of sources."""
|
|
38
|
+
|
|
39
|
+
_instance = None
|
|
40
|
+
|
|
41
|
+
def __new__(cls):
|
|
42
|
+
if cls._instance is None:
|
|
43
|
+
cls._instance = super(SourceRegistry, cls).__new__(cls)
|
|
44
|
+
cls._instance._init()
|
|
45
|
+
return cls._instance
|
|
46
|
+
|
|
47
|
+
def _init(self):
|
|
48
|
+
self.sources: Dict[str, Source] = {}
|
|
49
|
+
self.enabled_state: Dict[str, bool] = {} # Persistent state
|
|
50
|
+
self.config_path = os.path.join(os.path.dirname(config.db_path), "sources.yaml")
|
|
51
|
+
self._load_state()
|
|
52
|
+
|
|
53
|
+
def register(self, source: Source):
|
|
54
|
+
"""Register a source plugin."""
|
|
55
|
+
self.sources[source.name] = source
|
|
56
|
+
# Load config if exists
|
|
57
|
+
# (For MVP we rely on defaults or manual config injection)
|
|
58
|
+
logger.debug(f"Registered source: {source.name}")
|
|
59
|
+
|
|
60
|
+
def is_enabled(self, name: str) -> bool:
|
|
61
|
+
return self.enabled_state.get(name, False)
|
|
62
|
+
|
|
63
|
+
def enable(self, name: str):
|
|
64
|
+
if name not in self.sources:
|
|
65
|
+
raise ValueError(f"Source {name} not found")
|
|
66
|
+
|
|
67
|
+
self.enabled_state[name] = True
|
|
68
|
+
self.sources[name].start()
|
|
69
|
+
self._save_state()
|
|
70
|
+
self.sources[name].storage.log_audit(name, "enable", {"status": "started"})
|
|
71
|
+
logger.info(f"Source enabled: {name}")
|
|
72
|
+
|
|
73
|
+
def disable(self, name: str):
|
|
74
|
+
if name not in self.sources:
|
|
75
|
+
raise ValueError(f"Source {name} not found")
|
|
76
|
+
|
|
77
|
+
self.enabled_state[name] = False
|
|
78
|
+
self.sources[name].stop()
|
|
79
|
+
self._save_state()
|
|
80
|
+
self.sources[name].storage.log_audit(name, "disable", {"status": "stopped"})
|
|
81
|
+
logger.info(f"Source disabled: {name}")
|
|
82
|
+
|
|
83
|
+
def _load_state(self):
|
|
84
|
+
if os.path.exists(self.config_path):
|
|
85
|
+
try:
|
|
86
|
+
with open(self.config_path, 'r') as f:
|
|
87
|
+
data = yaml.safe_load(f) or {}
|
|
88
|
+
self.enabled_state = data.get("enabled", {})
|
|
89
|
+
# Ideally we also load per-source config here
|
|
90
|
+
except Exception as e:
|
|
91
|
+
logger.error(f"Failed to load source state: {e}")
|
|
92
|
+
|
|
93
|
+
def _save_state(self):
|
|
94
|
+
try:
|
|
95
|
+
with open(self.config_path, 'w') as f:
|
|
96
|
+
yaml.dump({"enabled": self.enabled_state}, f)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.error(f"Failed to save source state: {e}")
|
|
99
|
+
|
|
100
|
+
def get_all(self) -> List[Dict]:
|
|
101
|
+
return [
|
|
102
|
+
{
|
|
103
|
+
"name": s.name,
|
|
104
|
+
"description": s.description,
|
|
105
|
+
"enabled": self.is_enabled(s.name),
|
|
106
|
+
"running": s.is_running
|
|
107
|
+
}
|
|
108
|
+
for s in self.sources.values()
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
registry = SourceRegistry()
|
upii/ambient/storage.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional, Dict, List
|
|
6
|
+
from upii.core.config import config
|
|
7
|
+
from upii.core.logger import logger
|
|
8
|
+
|
|
9
|
+
class StagingDB:
|
|
10
|
+
"""Interface to the v1.0 Staging Database (Ambient)."""
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self.db_path = config.staging_db_path
|
|
14
|
+
|
|
15
|
+
def init_db(self):
|
|
16
|
+
"""Initialize staging schema."""
|
|
17
|
+
conn = sqlite3.connect(self.db_path)
|
|
18
|
+
cursor = conn.cursor()
|
|
19
|
+
|
|
20
|
+
# Events Table
|
|
21
|
+
cursor.execute("""
|
|
22
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
23
|
+
event_id TEXT PRIMARY KEY,
|
|
24
|
+
event_type TEXT NOT NULL,
|
|
25
|
+
file_path TEXT NOT NULL,
|
|
26
|
+
detected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
27
|
+
status TEXT DEFAULT 'pending'
|
|
28
|
+
)
|
|
29
|
+
""")
|
|
30
|
+
|
|
31
|
+
# Staging Docs Table
|
|
32
|
+
cursor.execute("""
|
|
33
|
+
CREATE TABLE IF NOT EXISTS staging_docs (
|
|
34
|
+
staging_id TEXT PRIMARY KEY,
|
|
35
|
+
source_event_id TEXT,
|
|
36
|
+
file_path TEXT NOT NULL,
|
|
37
|
+
content_hash TEXT,
|
|
38
|
+
parsed_content TEXT,
|
|
39
|
+
metadata JSON,
|
|
40
|
+
status TEXT DEFAULT 'review',
|
|
41
|
+
FOREIGN KEY(source_event_id) REFERENCES events(event_id)
|
|
42
|
+
)
|
|
43
|
+
""")
|
|
44
|
+
|
|
45
|
+
# Audit Log Table
|
|
46
|
+
cursor.execute("""
|
|
47
|
+
CREATE TABLE IF NOT EXISTS audit_logs (
|
|
48
|
+
audit_id TEXT PRIMARY KEY,
|
|
49
|
+
source_name TEXT NOT NULL,
|
|
50
|
+
action TEXT NOT NULL, -- 'enable', 'disable', 'capture', 'error'
|
|
51
|
+
details JSON,
|
|
52
|
+
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
53
|
+
)
|
|
54
|
+
""")
|
|
55
|
+
|
|
56
|
+
conn.commit()
|
|
57
|
+
conn.close()
|
|
58
|
+
|
|
59
|
+
def log_audit(self, source: str, action: str, details: Dict = {}):
|
|
60
|
+
"""Write an entry to the audit log."""
|
|
61
|
+
audit_id = str(uuid.uuid4())
|
|
62
|
+
conn = sqlite3.connect(self.db_path)
|
|
63
|
+
cursor = conn.cursor()
|
|
64
|
+
cursor.execute(
|
|
65
|
+
"INSERT INTO audit_logs (audit_id, source_name, action, details) VALUES (?, ?, ?, ?)",
|
|
66
|
+
(audit_id, source, action, json.dumps(details))
|
|
67
|
+
)
|
|
68
|
+
conn.commit()
|
|
69
|
+
conn.close()
|
|
70
|
+
return audit_id
|
|
71
|
+
|
|
72
|
+
def get_audit_logs(self, limit: int = 50) -> List[Dict]:
|
|
73
|
+
conn = sqlite3.connect(self.db_path)
|
|
74
|
+
conn.row_factory = sqlite3.Row
|
|
75
|
+
cursor = conn.cursor()
|
|
76
|
+
# timestamp is CURRENT_TIMESTAMP (1s resolution); break ties on the implicit
|
|
77
|
+
# insertion-order rowid so the most recent entry is always first, even when
|
|
78
|
+
# several entries land in the same second.
|
|
79
|
+
cursor.execute("SELECT * FROM audit_logs ORDER BY timestamp DESC, rowid DESC LIMIT ?", (limit,))
|
|
80
|
+
rows = cursor.fetchall()
|
|
81
|
+
conn.close()
|
|
82
|
+
return [dict(row) for row in rows]
|
|
83
|
+
|
|
84
|
+
def add_event(self, event_type: str, file_path: str) -> str:
|
|
85
|
+
"""Log a raw file system event."""
|
|
86
|
+
event_id = str(uuid.uuid4())
|
|
87
|
+
conn = sqlite3.connect(self.db_path)
|
|
88
|
+
cursor = conn.cursor()
|
|
89
|
+
cursor.execute(
|
|
90
|
+
"INSERT INTO events (event_id, event_type, file_path) VALUES (?, ?, ?)",
|
|
91
|
+
(event_id, event_type, file_path)
|
|
92
|
+
)
|
|
93
|
+
conn.commit()
|
|
94
|
+
conn.close()
|
|
95
|
+
return event_id
|
|
96
|
+
|
|
97
|
+
def add_staging_doc(self, source_event_id: str, file_path: str, content: str, content_hash: str, metadata: Dict) -> str:
|
|
98
|
+
"""Add a parsed document to staging."""
|
|
99
|
+
staging_id = str(uuid.uuid4())
|
|
100
|
+
conn = sqlite3.connect(self.db_path)
|
|
101
|
+
cursor = conn.cursor()
|
|
102
|
+
cursor.execute(
|
|
103
|
+
"""INSERT INTO staging_docs
|
|
104
|
+
(staging_id, source_event_id, file_path, content_hash, parsed_content, metadata)
|
|
105
|
+
VALUES (?, ?, ?, ?, ?, ?)""",
|
|
106
|
+
(staging_id, source_event_id, file_path, content_hash, content, json.dumps(metadata))
|
|
107
|
+
)
|
|
108
|
+
conn.commit()
|
|
109
|
+
conn.close()
|
|
110
|
+
return staging_id
|
|
111
|
+
|
|
112
|
+
def get_pending_events(self) -> List[Dict]:
|
|
113
|
+
conn = sqlite3.connect(self.db_path)
|
|
114
|
+
conn.row_factory = sqlite3.Row
|
|
115
|
+
cursor = conn.cursor()
|
|
116
|
+
cursor.execute("SELECT * FROM events WHERE status = 'pending' ORDER BY detected_at DESC")
|
|
117
|
+
rows = cursor.fetchall()
|
|
118
|
+
conn.close()
|
|
119
|
+
return [dict(row) for row in rows]
|
|
120
|
+
|
|
121
|
+
def get_all_events(self) -> List[Dict]:
|
|
122
|
+
"""Return every event regardless of status (pending/approved/rejected/acknowledged)."""
|
|
123
|
+
conn = sqlite3.connect(self.db_path)
|
|
124
|
+
conn.row_factory = sqlite3.Row
|
|
125
|
+
cursor = conn.cursor()
|
|
126
|
+
cursor.execute("SELECT * FROM events ORDER BY detected_at DESC")
|
|
127
|
+
rows = cursor.fetchall()
|
|
128
|
+
conn.close()
|
|
129
|
+
return [dict(row) for row in rows]
|
|
130
|
+
|
|
131
|
+
def get_staging_doc_by_event(self, event_id: str) -> Optional[Dict]:
|
|
132
|
+
"""Fetch the staged (reviewed) document content for a given event.
|
|
133
|
+
|
|
134
|
+
This is the content the operator approved — promotion must use this,
|
|
135
|
+
NOT a fresh disk read, so what enters LTM is exactly what was reviewed.
|
|
136
|
+
"""
|
|
137
|
+
conn = sqlite3.connect(self.db_path)
|
|
138
|
+
conn.row_factory = sqlite3.Row
|
|
139
|
+
cursor = conn.cursor()
|
|
140
|
+
cursor.execute(
|
|
141
|
+
"SELECT * FROM staging_docs WHERE source_event_id = ? LIMIT 1", (event_id,)
|
|
142
|
+
)
|
|
143
|
+
row = cursor.fetchone()
|
|
144
|
+
conn.close()
|
|
145
|
+
return dict(row) if row else None
|
|
146
|
+
|
|
147
|
+
def update_event_status(self, event_id: str, status: str):
|
|
148
|
+
conn = sqlite3.connect(self.db_path)
|
|
149
|
+
cursor = conn.cursor()
|
|
150
|
+
cursor.execute("UPDATE events SET status = ? WHERE event_id = ?", (status, event_id))
|
|
151
|
+
conn.commit()
|
|
152
|
+
conn.close()
|
|
153
|
+
|
|
154
|
+
def update_staging_status(self, staging_id: str, status: str):
|
|
155
|
+
conn = sqlite3.connect(self.db_path)
|
|
156
|
+
cursor = conn.cursor()
|
|
157
|
+
cursor.execute("UPDATE staging_docs SET status = ? WHERE staging_id = ?", (status, staging_id))
|
|
158
|
+
conn.commit()
|
|
159
|
+
conn.close()
|