email-to-calendar 20250826010803.dev0__py3-none-any.whl → 20251210163203.dev0__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.
- __init__.py +10 -12
- db.py +7 -8
- email_to_calendar-20251210163203.dev0.dist-info/METADATA +41 -0
- email_to_calendar-20251210163203.dev0.dist-info/RECORD +20 -0
- events/caldav.py +13 -3
- mail/mail_idle.py +187 -0
- main.py +120 -39
- model/email.py +61 -30
- model/event.py +63 -102
- util/ai.py +163 -0
- util/env.py +97 -0
- util/notifications.py +20 -0
- email_to_calendar-20250826010803.dev0.dist-info/METADATA +0 -25
- email_to_calendar-20250826010803.dev0.dist-info/RECORD +0 -17
- util/text.py +0 -856
- {email_to_calendar-20250826010803.dev0.dist-info → email_to_calendar-20251210163203.dev0.dist-info}/WHEEL +0 -0
- {email_to_calendar-20250826010803.dev0.dist-info → email_to_calendar-20251210163203.dev0.dist-info}/licenses/LICENSE +0 -0
- {email_to_calendar-20250826010803.dev0.dist-info → email_to_calendar-20251210163203.dev0.dist-info}/top_level.txt +0 -0
model/email.py
CHANGED
|
@@ -2,10 +2,10 @@ from datetime import datetime
|
|
|
2
2
|
import enum
|
|
3
3
|
|
|
4
4
|
import tzlocal
|
|
5
|
-
from
|
|
6
|
-
from sqlalchemy.orm import relationship
|
|
5
|
+
from sqlmodel import SQLModel, Field, select
|
|
7
6
|
|
|
8
|
-
from src.db import
|
|
7
|
+
from src.db import Session, engine
|
|
8
|
+
from src.model.event import Event
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class EMailType(enum.Enum):
|
|
@@ -13,19 +13,16 @@ class EMailType(enum.Enum):
|
|
|
13
13
|
HTML = "html"
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
class EMail(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
DateTime, nullable=False, default=lambda: datetime.now(tzlocal.get_localzone())
|
|
16
|
+
class EMail(SQLModel, table=True):
|
|
17
|
+
id: int = Field(primary_key=True)
|
|
18
|
+
subject: str = Field(nullable=False)
|
|
19
|
+
from_address: str = Field(nullable=False)
|
|
20
|
+
delivery_date: datetime = Field(nullable=False)
|
|
21
|
+
body: str = Field(nullable=False)
|
|
22
|
+
retrieved_date: datetime = Field(
|
|
23
|
+
nullable=False, default=lambda: datetime.now(tzlocal.get_localzone())
|
|
25
24
|
)
|
|
26
|
-
email_type =
|
|
27
|
-
|
|
28
|
-
events = relationship("Event", back_populates="email", cascade="all, delete-orphan")
|
|
25
|
+
email_type: EMailType = Field(nullable=False)
|
|
29
26
|
|
|
30
27
|
def __repr__(self):
|
|
31
28
|
return f"<EMail(id={self.id}, subject={self.subject}, from_address={self.from_address}, delivery_date={self.delivery_date})>"
|
|
@@ -34,7 +31,7 @@ class EMail(Base):
|
|
|
34
31
|
return f"EMail(id={self.id}, subject={self.subject}, from_address={self.from_address}, delivery_date={self.delivery_date})"
|
|
35
32
|
|
|
36
33
|
def save(self):
|
|
37
|
-
session =
|
|
34
|
+
session = Session(engine)
|
|
38
35
|
try:
|
|
39
36
|
self.retrieved_date = datetime.now(tzlocal.get_localzone())
|
|
40
37
|
session.merge(self)
|
|
@@ -43,50 +40,84 @@ class EMail(Base):
|
|
|
43
40
|
session.close()
|
|
44
41
|
|
|
45
42
|
def get(self):
|
|
46
|
-
session =
|
|
43
|
+
session = Session(engine)
|
|
47
44
|
try:
|
|
48
|
-
return session.
|
|
45
|
+
return session.exec(select(EMail).where(EMail.id == self.id)).first()
|
|
49
46
|
finally:
|
|
50
47
|
session.close()
|
|
51
48
|
|
|
52
49
|
def delete(self):
|
|
53
|
-
session =
|
|
50
|
+
session = Session(engine)
|
|
54
51
|
try:
|
|
55
|
-
session.
|
|
52
|
+
session.delete(EMail.where(EMail.id == self.id))
|
|
56
53
|
session.commit()
|
|
57
54
|
finally:
|
|
58
55
|
session.close()
|
|
59
56
|
|
|
60
57
|
@staticmethod
|
|
61
58
|
def get_by_id(email_id: int):
|
|
62
|
-
session =
|
|
59
|
+
session = Session(engine)
|
|
63
60
|
try:
|
|
64
|
-
return session.
|
|
61
|
+
return session.exec(select(EMail).where(EMail.id == email_id)).first()
|
|
65
62
|
finally:
|
|
66
63
|
session.close()
|
|
67
64
|
|
|
68
65
|
@staticmethod
|
|
69
66
|
def get_all():
|
|
70
|
-
session =
|
|
67
|
+
session = Session(engine)
|
|
71
68
|
try:
|
|
72
|
-
return session.
|
|
69
|
+
return session.exec(select(EMail)).all()
|
|
73
70
|
finally:
|
|
74
71
|
session.close()
|
|
75
72
|
|
|
76
73
|
@staticmethod
|
|
77
74
|
def get_by_delivery_date(delivery_date: datetime):
|
|
78
|
-
session =
|
|
75
|
+
session = Session(engine)
|
|
79
76
|
try:
|
|
80
|
-
return (
|
|
81
|
-
|
|
82
|
-
)
|
|
77
|
+
return session.exec(
|
|
78
|
+
select(EMail).where(EMail.delivery_date == delivery_date)
|
|
79
|
+
).all()
|
|
83
80
|
finally:
|
|
84
81
|
session.close()
|
|
85
82
|
|
|
86
83
|
@staticmethod
|
|
87
84
|
def get_most_recent():
|
|
88
|
-
session =
|
|
85
|
+
session = Session(engine)
|
|
86
|
+
try:
|
|
87
|
+
return session.exec(
|
|
88
|
+
select(EMail).order_by(EMail.delivery_date.desc())
|
|
89
|
+
).first()
|
|
90
|
+
finally:
|
|
91
|
+
session.close()
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def get_most_recent_without_events():
|
|
95
|
+
session = Session(engine)
|
|
96
|
+
try:
|
|
97
|
+
email: EMail = session.exec(
|
|
98
|
+
select(EMail).order_by(EMail.delivery_date.desc())
|
|
99
|
+
).first()
|
|
100
|
+
# Check if this email has any associated events
|
|
101
|
+
if email:
|
|
102
|
+
event = session.exec(
|
|
103
|
+
select(Event).where(Event.email_id == email.id)
|
|
104
|
+
).first()
|
|
105
|
+
if event:
|
|
106
|
+
return None # The most recent email has associated events
|
|
107
|
+
return email
|
|
108
|
+
finally:
|
|
109
|
+
session.close()
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def get_without_events():
|
|
113
|
+
session = Session(engine)
|
|
89
114
|
try:
|
|
90
|
-
|
|
115
|
+
# Use NOT EXISTS to find emails with no events referencing them
|
|
116
|
+
result = session.exec(
|
|
117
|
+
select(EMail).where(
|
|
118
|
+
~select(Event).where(Event.email_id == EMail.id).exists()
|
|
119
|
+
)
|
|
120
|
+
).all()
|
|
121
|
+
return result
|
|
91
122
|
finally:
|
|
92
123
|
session.close()
|
model/event.py
CHANGED
|
@@ -1,32 +1,34 @@
|
|
|
1
1
|
from datetime import datetime
|
|
2
2
|
|
|
3
3
|
from sqlalchemy import (
|
|
4
|
-
Column,
|
|
5
|
-
Integer,
|
|
6
|
-
String,
|
|
7
|
-
DateTime,
|
|
8
|
-
Boolean,
|
|
9
4
|
UniqueConstraint,
|
|
10
|
-
ForeignKey,
|
|
11
5
|
)
|
|
12
|
-
from
|
|
6
|
+
from sqlmodel import SQLModel, Field, select
|
|
13
7
|
|
|
14
|
-
from src.db import
|
|
8
|
+
from src.db import Session, engine
|
|
15
9
|
|
|
16
10
|
|
|
17
|
-
class Event(
|
|
18
|
-
__tablename__ = "events"
|
|
11
|
+
class Event(SQLModel, table=True):
|
|
19
12
|
__table_args__ = (
|
|
20
13
|
UniqueConstraint("start", "end", "summary", name="uq_event_start_end_summary"),
|
|
21
14
|
)
|
|
22
|
-
id =
|
|
23
|
-
start =
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
15
|
+
id: int = Field(primary_key=True)
|
|
16
|
+
start: datetime = Field(
|
|
17
|
+
nullable=False,
|
|
18
|
+
description="The start date and time of the event, must be a Python datetime object, cannot be None",
|
|
19
|
+
)
|
|
20
|
+
end: datetime = Field(
|
|
21
|
+
nullable=False,
|
|
22
|
+
description="The end date and time of the event, must be a Python datetime object, cannot be None",
|
|
23
|
+
)
|
|
24
|
+
all_day: bool = Field(
|
|
25
|
+
nullable=False,
|
|
26
|
+
default=False,
|
|
27
|
+
description="Whether the event lasts all day or not",
|
|
28
|
+
)
|
|
29
|
+
summary: str = Field(nullable=False)
|
|
30
|
+
email_id: int = Field(foreign_key="email.id", nullable=True)
|
|
31
|
+
in_calendar: bool = Field(nullable=False, default=False)
|
|
30
32
|
|
|
31
33
|
def __repr__(self):
|
|
32
34
|
return f"<Event(id={self.id}, start={self.start}, end={self.end}, summary={self.summary})>"
|
|
@@ -35,98 +37,51 @@ class Event(Base):
|
|
|
35
37
|
return f"Event(id={self.id}, start={self.start}, end={self.end}, summary={self.summary})"
|
|
36
38
|
|
|
37
39
|
def save(self):
|
|
38
|
-
session =
|
|
40
|
+
session = Session(engine)
|
|
39
41
|
try:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if self.email_id is not None:
|
|
57
|
-
from src.model.email import EMail # Import here to avoid circular import
|
|
58
|
-
|
|
59
|
-
# Get all events with the same summary
|
|
60
|
-
existing_events_with_same_summary = (
|
|
61
|
-
session.query(Event)
|
|
62
|
-
.filter_by(summary=self.summary)
|
|
63
|
-
.filter(Event.email_id.isnot(None))
|
|
64
|
-
.all()
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
if existing_events_with_same_summary:
|
|
68
|
-
# Get the current email's delivery date
|
|
69
|
-
current_email = session.query(EMail).filter_by(id=self.email_id).first()
|
|
70
|
-
if current_email:
|
|
71
|
-
current_delivery_date = current_email.delivery_date
|
|
72
|
-
|
|
73
|
-
# Check if any existing events are from older emails
|
|
74
|
-
events_to_remove = []
|
|
75
|
-
for existing_event in existing_events_with_same_summary:
|
|
76
|
-
existing_email = session.query(EMail).filter_by(id=existing_event.email_id).first()
|
|
77
|
-
if existing_email and existing_email.delivery_date < current_delivery_date:
|
|
78
|
-
events_to_remove.append(existing_event)
|
|
79
|
-
|
|
80
|
-
# Remove older events with the same summary
|
|
81
|
-
for event_to_remove in events_to_remove:
|
|
82
|
-
session.delete(event_to_remove)
|
|
83
|
-
|
|
84
|
-
# Also check if there are newer events with the same summary
|
|
85
|
-
has_newer_event = False
|
|
86
|
-
for existing_event in existing_events_with_same_summary:
|
|
87
|
-
if existing_event not in events_to_remove:
|
|
88
|
-
existing_email = session.query(EMail).filter_by(id=existing_event.email_id).first()
|
|
89
|
-
if existing_email and existing_email.delivery_date > current_delivery_date:
|
|
90
|
-
has_newer_event = True
|
|
91
|
-
break
|
|
92
|
-
|
|
93
|
-
# Only save the current event if there are no newer events with the same summary
|
|
94
|
-
if not has_newer_event:
|
|
95
|
-
session.merge(self)
|
|
96
|
-
session.commit()
|
|
97
|
-
# If there is a newer event, don't save the current event
|
|
98
|
-
else:
|
|
99
|
-
# If we can't get the email, proceed with normal save
|
|
100
|
-
session.merge(self)
|
|
101
|
-
session.commit()
|
|
42
|
+
if isinstance(self.start, str):
|
|
43
|
+
self.start = datetime.fromisoformat(self.start)
|
|
44
|
+
if isinstance(self.end, str):
|
|
45
|
+
self.end = datetime.fromisoformat(self.end)
|
|
46
|
+
if self.id is not None:
|
|
47
|
+
# Update existing event
|
|
48
|
+
existing_event = session.exec(
|
|
49
|
+
select(Event).where(Event.id == self.id)
|
|
50
|
+
).first()
|
|
51
|
+
if existing_event:
|
|
52
|
+
existing_event.start = self.start
|
|
53
|
+
existing_event.end = self.end
|
|
54
|
+
existing_event.summary = self.summary
|
|
55
|
+
existing_event.email_id = self.email_id
|
|
56
|
+
existing_event.in_calendar = self.in_calendar
|
|
57
|
+
session.add(existing_event)
|
|
102
58
|
else:
|
|
103
|
-
#
|
|
104
|
-
session.
|
|
105
|
-
session.commit()
|
|
59
|
+
# If id is set but not found, treat as new
|
|
60
|
+
session.add(self)
|
|
106
61
|
else:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
62
|
+
session.add(self)
|
|
63
|
+
session.commit()
|
|
64
|
+
session.flush()
|
|
110
65
|
finally:
|
|
111
66
|
session.close()
|
|
112
67
|
|
|
113
68
|
def get(self):
|
|
114
|
-
session =
|
|
69
|
+
session = Session(engine)
|
|
115
70
|
try:
|
|
116
|
-
return session.
|
|
71
|
+
return session.exec(select(Event).where(Event.id == self.id)).first()
|
|
117
72
|
finally:
|
|
118
73
|
session.close()
|
|
119
74
|
|
|
120
75
|
def delete(self):
|
|
121
|
-
session =
|
|
76
|
+
session = Session(engine)
|
|
122
77
|
try:
|
|
123
|
-
session.
|
|
78
|
+
session.delete(Event.where(Event.id == self.id))
|
|
124
79
|
session.commit()
|
|
125
80
|
finally:
|
|
126
81
|
session.close()
|
|
127
82
|
|
|
128
83
|
def save_to_caldav(self):
|
|
129
|
-
session =
|
|
84
|
+
session = Session(engine)
|
|
130
85
|
try:
|
|
131
86
|
self.in_calendar = True
|
|
132
87
|
session.merge(self)
|
|
@@ -136,28 +91,34 @@ class Event(Base):
|
|
|
136
91
|
|
|
137
92
|
@staticmethod
|
|
138
93
|
def get_by_id(event_id: int):
|
|
139
|
-
session =
|
|
94
|
+
session = Session(engine)
|
|
140
95
|
try:
|
|
141
|
-
return session.
|
|
96
|
+
return session.exec(select(Event).where(Event.id == event_id)).first()
|
|
142
97
|
finally:
|
|
143
98
|
session.close()
|
|
144
99
|
|
|
145
100
|
@staticmethod
|
|
146
101
|
def get_all():
|
|
147
|
-
session =
|
|
102
|
+
session = Session(engine)
|
|
148
103
|
try:
|
|
149
|
-
return session.
|
|
104
|
+
return session.exec(select(Event)).all()
|
|
150
105
|
finally:
|
|
151
106
|
session.close()
|
|
152
107
|
|
|
153
108
|
@staticmethod
|
|
154
109
|
def get_by_date(date: datetime):
|
|
155
|
-
session =
|
|
110
|
+
session = Session(engine)
|
|
111
|
+
try:
|
|
112
|
+
return session.exec(
|
|
113
|
+
select(Event).where(Event.start == date or Event.end == date)
|
|
114
|
+
).all()
|
|
115
|
+
finally:
|
|
116
|
+
session.close()
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def get_not_in_calendar():
|
|
120
|
+
session = Session(engine)
|
|
156
121
|
try:
|
|
157
|
-
return (
|
|
158
|
-
session.query(Event)
|
|
159
|
-
.filter(Event.start == date or Event.end == date)
|
|
160
|
-
.all()
|
|
161
|
-
)
|
|
122
|
+
return session.exec(select(Event).where(Event.in_calendar == False)).all()
|
|
162
123
|
finally:
|
|
163
124
|
session.close()
|
util/ai.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
from pydantic_ai import Agent, RunContext
|
|
7
|
+
from pydantic_ai.models.openai import OpenAIChatModel
|
|
8
|
+
from pydantic_ai.providers.ollama import OllamaProvider
|
|
9
|
+
from pydantic_ai.providers.openai import OpenAIProvider
|
|
10
|
+
from sqlmodel import select
|
|
11
|
+
|
|
12
|
+
from src import logger
|
|
13
|
+
from src.db import Session, engine
|
|
14
|
+
from src.model.email import EMail
|
|
15
|
+
from src.model.event import Event
|
|
16
|
+
from src.util.env import AIProvider, get_settings
|
|
17
|
+
|
|
18
|
+
DEFAULT_SYSTEM_PROMPT = """You are an assistant that extracts calendar events from an email body. Produce only a single JSON object that strictly conforms to the schema below. Do not include any explanatory text, code fences, or comments—only the final JSON.
|
|
19
|
+
Context you will receive via earlier messages:
|
|
20
|
+
Current year to assume if no year is present
|
|
21
|
+
The email ID to assign to the email_id field
|
|
22
|
+
A list of existing events from the database
|
|
23
|
+
Output schema
|
|
24
|
+
Top-level object: { "events": [Event, ...] }
|
|
25
|
+
Event object fields and types:
|
|
26
|
+
id: integer or null
|
|
27
|
+
If you can confidently match an existing DB event by start, end, and summary (case-insensitive, trimmed), set to that event’s id; otherwise null.
|
|
28
|
+
start: string, ISO 8601 datetime (e.g., 2025-11-16T09:00:00 or 2025-11-16T09:00:00-05:00)
|
|
29
|
+
end: string, ISO 8601 datetime
|
|
30
|
+
all_day: boolean
|
|
31
|
+
summary: string
|
|
32
|
+
A concise title/description with all date/time tokens removed.
|
|
33
|
+
email_id: integer (must be the provided email ID)
|
|
34
|
+
in_calendar: boolean (always false for new extractions)
|
|
35
|
+
Event extraction and inference rules
|
|
36
|
+
Treat the email body as lines:
|
|
37
|
+
Most non-blank lines are events; lines that are clearly headers (month or year) set context for following lines until superseded.
|
|
38
|
+
Month/year headers: use them to resolve dates for subsequent event lines.
|
|
39
|
+
Dates:
|
|
40
|
+
A date may be on the same line as an event or inherited from the last resolvable date context above.
|
|
41
|
+
Multi-day ranges (e.g., “Oct 10–12”, “10-12” under an October header) mean start is the first day at start time and end is the last day at end time.
|
|
42
|
+
If no year is present, use the provided “current year” context.
|
|
43
|
+
Times:
|
|
44
|
+
Recognize formats such as: 12:50, 6:30, 9am, 8, 820, 130, 10-12, 10:30-12:30, 9am-11am, 9-11, 9-11am, 9am-11
|
|
45
|
+
If a single time is provided for a single-day event, assume a 1-hour duration.
|
|
46
|
+
If no time is present, the event is all-day: start 00:00:00, end 23:59:00, all_day = true.
|
|
47
|
+
If a multi-day event provides only a start time, start at that time on the first day and end at 23:59:00 on the last day, all_day = false.
|
|
48
|
+
Normalize 8 → 08:00:00; 820 → 08:20:00; 130 → 01:30:00; add seconds as :00 if missing.
|
|
49
|
+
Respect am/pm; if none given and a 12-hour ambiguity exists, prefer a sensible local interpretation (e.g., 9 → 09:00).
|
|
50
|
+
Summary:
|
|
51
|
+
Remove all date/time expressions and markers from the line; keep a clear human title.
|
|
52
|
+
Trim extra punctuation and whitespace.
|
|
53
|
+
Deduplication and matching:
|
|
54
|
+
Avoid emitting duplicates within this run (same start, end, summary).
|
|
55
|
+
To fill id from DB, match by exact start, end, and normalized summary (case-insensitive, trimmed); otherwise id = null.
|
|
56
|
+
Defaults:
|
|
57
|
+
email_id must equal the provided email ID.
|
|
58
|
+
in_calendar must be false.
|
|
59
|
+
If a line cannot be reliably parsed into an event, you may skip it (do not invent events).
|
|
60
|
+
Formatting requirements
|
|
61
|
+
Emit exactly one JSON object with this shape: { "events": [ { "id": null or integer, "start": "YYYY-MM-DDTHH:MM:SS[±HH:MM]", "end": "YYYY-MM-DDTHH:MM:SS[±HH:MM]", "all_day": true|false, "summary": "string", "email_id": integer, "in_calendar": false }, ... ] }
|
|
62
|
+
Key names must be lower_snake_case exactly as above.
|
|
63
|
+
No markdown, code fences, or extra prose—only the JSON.
|
|
64
|
+
Self-check before answering
|
|
65
|
+
Validate that:
|
|
66
|
+
All events have start < end, both in ISO 8601
|
|
67
|
+
all_day is true only when start is 00:00:00 and end is 23:59:00 for that day (or the multi-day all-day case)
|
|
68
|
+
email_id equals the provided number
|
|
69
|
+
in_calendar is false for all events
|
|
70
|
+
id is integer only when a clear DB match exists; otherwise null
|
|
71
|
+
summary has no date/time remnants
|
|
72
|
+
If any item fails validation, correct the output and re-validate before responding.
|
|
73
|
+
If still invalid, correct again; keep correcting until the output matches the schema and rules."""
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class AgentDependencies:
|
|
77
|
+
email: EMail
|
|
78
|
+
max_result_retries: int = get_settings().AI_MAX_RETRIES
|
|
79
|
+
db = Session(engine)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Events(BaseModel):
|
|
83
|
+
events: list[Event] = Field(description="A list of events parsed from the email")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def parse_email(
|
|
87
|
+
email: EMail,
|
|
88
|
+
provider: AIProvider,
|
|
89
|
+
model: str = "gpt-oss:20b",
|
|
90
|
+
ollama_host: str = "localhost",
|
|
91
|
+
ollama_port: int = 11434,
|
|
92
|
+
ollama_secure: bool = False,
|
|
93
|
+
open_ai_api_key: str = None,
|
|
94
|
+
max_retries: int = 3,
|
|
95
|
+
system_prompt: str = None,
|
|
96
|
+
) -> list[Event]:
|
|
97
|
+
if provider == AIProvider.OLLAMA:
|
|
98
|
+
logger.info(
|
|
99
|
+
f"Creating events from email id {email.id}, using model: {model} at ollama host: {'https://' if ollama_secure else 'http://'}{ollama_host}:{ollama_port}"
|
|
100
|
+
)
|
|
101
|
+
ai_model = OpenAIChatModel(
|
|
102
|
+
model_name=model,
|
|
103
|
+
provider=OllamaProvider(
|
|
104
|
+
base_url=f"{'https://' if ollama_secure else 'http://'}{ollama_host}:{ollama_port}/v1"
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
elif provider == AIProvider.OPENAI:
|
|
108
|
+
logger.info(
|
|
109
|
+
f"Creating events from email id {email.id}, using OpenAI model: {model}"
|
|
110
|
+
)
|
|
111
|
+
ai_model = OpenAIChatModel(
|
|
112
|
+
model_name=model, provider=OpenAIProvider(api_key=open_ai_api_key)
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
raise ValueError(f"Unsupported AI provider: {provider}")
|
|
116
|
+
|
|
117
|
+
deps = AgentDependencies(email=email)
|
|
118
|
+
|
|
119
|
+
system_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT
|
|
120
|
+
|
|
121
|
+
agent = Agent(
|
|
122
|
+
ai_model,
|
|
123
|
+
deps_type=AgentDependencies,
|
|
124
|
+
output_type=Events,
|
|
125
|
+
system_prompt=system_prompt,
|
|
126
|
+
retries=max_retries,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
#@agent.system_prompt
|
|
130
|
+
#async def get_current_year(ctx: RunContext[AgentDependencies]):
|
|
131
|
+
# return f"If there is no year provided, use this year: {ctx.deps.email.delivery_date.year}"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@agent.system_prompt
|
|
135
|
+
async def get_email_id(ctx: RunContext[AgentDependencies]):
|
|
136
|
+
return f"The email ID is {ctx.deps.email.id}. Use this ID for the email_id attribute of the event(s)."
|
|
137
|
+
|
|
138
|
+
@agent.system_prompt
|
|
139
|
+
async def get_events(ctx: RunContext[AgentDependencies]):
|
|
140
|
+
logger.info("Checking existing events in the database...")
|
|
141
|
+
events = ctx.deps.db.exec(select(Event)).all()
|
|
142
|
+
logger.debug("Found %d existing events in the database", len(events))
|
|
143
|
+
if not events:
|
|
144
|
+
return "There are no current events in the database, assume all events are new."
|
|
145
|
+
return f"The currents events in the database are: {events}"
|
|
146
|
+
|
|
147
|
+
logger.info("Generating events...")
|
|
148
|
+
start_time = datetime.now()
|
|
149
|
+
|
|
150
|
+
task = asyncio.create_task(agent.run(email.body, deps=deps))
|
|
151
|
+
while not task.done():
|
|
152
|
+
logger.debug("Waiting for AI to finish generating events...")
|
|
153
|
+
await asyncio.sleep(10)
|
|
154
|
+
|
|
155
|
+
result = await task
|
|
156
|
+
events: Events = result.output
|
|
157
|
+
|
|
158
|
+
end_time = datetime.now()
|
|
159
|
+
elapsed = (end_time - start_time).total_seconds()
|
|
160
|
+
logger.info(
|
|
161
|
+
f"Took {elapsed:.3f} seconds to generate {len(events.events)} events from email id: {email.id}"
|
|
162
|
+
)
|
|
163
|
+
return events.events
|
util/env.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
from pydantic import (
|
|
6
|
+
Field,
|
|
7
|
+
ValidationError,
|
|
8
|
+
AnyUrl,
|
|
9
|
+
EmailStr,
|
|
10
|
+
)
|
|
11
|
+
from pydantic_settings import BaseSettings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AIProvider(str, Enum):
|
|
15
|
+
OLLAMA = "ollama"
|
|
16
|
+
OPENAI = "openai"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Settings(BaseSettings):
|
|
20
|
+
IMAP_HOST: str = Field(default=None, description="IMAP server host")
|
|
21
|
+
IMAP_PORT: int = Field(993, ge=1, le=65535, description="IMAP server port")
|
|
22
|
+
IMAP_USERNAME: str = Field(default=None, description="IMAP username")
|
|
23
|
+
IMAP_PASSWORD: str = Field(default=None, description="IMAP password")
|
|
24
|
+
IMAP_MAILBOX: str = Field("INBOX", description="IMAP mailbox to check")
|
|
25
|
+
IMAP_SSL: bool = Field(True, description="Whether to use SSL for IMAP connection")
|
|
26
|
+
|
|
27
|
+
FILTER_FROM_EMAIL: Optional[EmailStr] = Field(
|
|
28
|
+
None, description="Email address to filter messages from (optional)"
|
|
29
|
+
)
|
|
30
|
+
FILTER_SUBJECT: Optional[str] = Field(None, description="Subject filter (optional)")
|
|
31
|
+
BACKFILL: bool = Field(False, description="Whether to backfill all emails")
|
|
32
|
+
INTERVAL_MINUTES: int = Field(
|
|
33
|
+
15, ge=1, description="Interval in minutes to check for new emails"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
CALDAV_URL: AnyUrl = Field(default=None, description="CalDAV server URL")
|
|
37
|
+
CALDAV_USERNAME: str = Field(default=None, description="CalDAV username")
|
|
38
|
+
CALDAV_PASSWORD: str = Field(default=None, description="CalDAV password")
|
|
39
|
+
CALDAV_CALENDAR: str = Field(default=None, description="CalDAV calendar name")
|
|
40
|
+
|
|
41
|
+
AI_PROVIDER: AIProvider = Field(
|
|
42
|
+
default=None, description="AI provider to use (ollama, openai, none)"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
OLLAMA_SECURE: bool = Field(
|
|
46
|
+
False, description="Whether to use HTTPS for Ollama connection"
|
|
47
|
+
)
|
|
48
|
+
OLLAMA_HOST: str = Field("localhost", description="Ollama base URL")
|
|
49
|
+
OLLAMA_PORT: int = Field(11434, ge=1, le=65535, description="Ollama port")
|
|
50
|
+
|
|
51
|
+
OPEN_AI_API_KEY: Optional[str] = Field(
|
|
52
|
+
None, description="OpenAI API key (required if AI_PROVIDER is openai)"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
AI_MODEL: str = Field(default=None, description="Model to use for parsing")
|
|
56
|
+
AI_MAX_RETRIES: int = Field(3, ge=0, description="Maximum retries for AI parsing")
|
|
57
|
+
|
|
58
|
+
AI_SYSTEM_PROMPT: Optional[str] = Field(
|
|
59
|
+
None, description="Custom system prompt for the AI model (optional)"
|
|
60
|
+
)
|
|
61
|
+
AI_SYSTEM_PROMPT_FILE: Optional[str] = Field(
|
|
62
|
+
None, description="Custom system prompt for the AI model (optional)"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
DB_FILE: str = Field(f"{Path.cwd()}/data/emails.db", description="SQLite database file path")
|
|
66
|
+
|
|
67
|
+
APPRISE_URL: Optional[AnyUrl] = Field(
|
|
68
|
+
None, description="Apprise notification service URL (optional)"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
class Config:
|
|
72
|
+
env_file = ".env"
|
|
73
|
+
env_file_encoding = "utf-8"
|
|
74
|
+
case_sensitive = True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_settings() -> Settings:
|
|
78
|
+
try:
|
|
79
|
+
settings = Settings()
|
|
80
|
+
if settings.AI_PROVIDER == AIProvider.OLLAMA and not settings.AI_MODEL:
|
|
81
|
+
settings.AI_MODEL = "gpt-oss:20b"
|
|
82
|
+
assert settings.OLLAMA_HOST is not None
|
|
83
|
+
assert settings.OLLAMA_PORT is not None
|
|
84
|
+
elif settings.AI_PROVIDER == AIProvider.OPENAI and not settings.AI_MODEL:
|
|
85
|
+
settings.AI_MODEL = "gpt-5-mini"
|
|
86
|
+
assert settings.OPEN_AI_API_KEY is not None
|
|
87
|
+
|
|
88
|
+
if settings.AI_SYSTEM_PROMPT_FILE:
|
|
89
|
+
try:
|
|
90
|
+
with open(settings.AI_SYSTEM_PROMPT_FILE, "r") as f:
|
|
91
|
+
settings.AI_SYSTEM_PROMPT = f.read()
|
|
92
|
+
except Exception as e:
|
|
93
|
+
raise ValueError(f"Error reading system prompt file: {e}")
|
|
94
|
+
return settings
|
|
95
|
+
except ValidationError as exc:
|
|
96
|
+
# Fail fast with a clear error so startup doesn't proceed with bad config
|
|
97
|
+
raise SystemExit(f"Environment validation error:\n{exc}")
|
util/notifications.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import apprise
|
|
2
|
+
from pydantic import AnyUrl
|
|
3
|
+
|
|
4
|
+
from src.model.event import Event
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def send_success_notification(apprise_url: AnyUrl, events: list[Event]):
|
|
8
|
+
# Create an Apprise instance
|
|
9
|
+
apobj = apprise.Apprise()
|
|
10
|
+
|
|
11
|
+
# Add all the notification services by their server url.
|
|
12
|
+
# A sample email notification:
|
|
13
|
+
apobj.add(apprise_url.encoded_string())
|
|
14
|
+
|
|
15
|
+
# Then notify these services any time you desire. The below would
|
|
16
|
+
# notify all the services loaded into our Apprise object.
|
|
17
|
+
apobj.notify(
|
|
18
|
+
body=f"The following new events were added to your calendar: {events}",
|
|
19
|
+
title="New Events Added to Calendar",
|
|
20
|
+
)
|