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 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
+ }
@@ -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()
@@ -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()