nornweave 0.1.2__py3-none-any.whl → 0.1.4__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.
- nornweave/adapters/resend.py +57 -7
- nornweave/core/config.py +50 -1
- nornweave/core/interfaces.py +63 -1
- nornweave/huginn/client.py +137 -8
- nornweave/huginn/resources.py +9 -0
- nornweave/huginn/server.py +148 -9
- nornweave/models/thread.py +2 -0
- nornweave/muninn/tools.py +267 -19
- nornweave/storage/database.py +1 -1
- nornweave/urdr/adapters/base.py +173 -3
- nornweave/urdr/migrations/versions/20260205_0003_llm_thread_summary.py +54 -0
- nornweave/urdr/orm.py +27 -1
- nornweave/verdandi/llm/__init__.py +52 -0
- nornweave/verdandi/llm/anthropic.py +63 -0
- nornweave/verdandi/llm/base.py +35 -0
- nornweave/verdandi/llm/gemini.py +78 -0
- nornweave/verdandi/llm/openai.py +60 -0
- nornweave/verdandi/parser.py +2 -1
- nornweave/verdandi/summarize.py +231 -0
- nornweave/yggdrasil/app.py +2 -1
- nornweave/yggdrasil/dependencies.py +11 -6
- nornweave/yggdrasil/routes/v1/attachments.py +317 -0
- nornweave/yggdrasil/routes/v1/messages.py +180 -20
- nornweave/yggdrasil/routes/v1/threads.py +4 -0
- nornweave/yggdrasil/routes/webhooks/mailgun.py +4 -0
- nornweave/yggdrasil/routes/webhooks/resend.py +76 -16
- {nornweave-0.1.2.dist-info → nornweave-0.1.4.dist-info}/METADATA +19 -1
- {nornweave-0.1.2.dist-info → nornweave-0.1.4.dist-info}/RECORD +31 -23
- {nornweave-0.1.2.dist-info → nornweave-0.1.4.dist-info}/WHEEL +0 -0
- {nornweave-0.1.2.dist-info → nornweave-0.1.4.dist-info}/entry_points.txt +0 -0
- {nornweave-0.1.2.dist-info → nornweave-0.1.4.dist-info}/licenses/LICENSE +0 -0
nornweave/muninn/tools.py
CHANGED
|
@@ -95,55 +95,133 @@ async def send_email(
|
|
|
95
95
|
async def search_email(
|
|
96
96
|
client: NornWeaveClient,
|
|
97
97
|
query: str,
|
|
98
|
-
inbox_id: str,
|
|
98
|
+
inbox_id: str | None = None,
|
|
99
|
+
thread_id: str | None = None,
|
|
99
100
|
limit: int = 10,
|
|
101
|
+
offset: int = 0,
|
|
100
102
|
) -> dict[str, Any]:
|
|
101
|
-
"""Search for emails.
|
|
103
|
+
"""Search for emails with flexible filters.
|
|
102
104
|
|
|
103
|
-
Find relevant messages
|
|
105
|
+
Find relevant messages by query text. At least one of inbox_id or thread_id must be provided.
|
|
104
106
|
|
|
105
107
|
Args:
|
|
106
108
|
client: NornWeave API client.
|
|
107
|
-
query: Search query.
|
|
108
|
-
inbox_id:
|
|
109
|
+
query: Search query (searches subject, body, sender, attachment filenames).
|
|
110
|
+
inbox_id: Filter by inbox ID (optional).
|
|
111
|
+
thread_id: Filter by thread ID (optional).
|
|
109
112
|
limit: Maximum number of results (default: 10).
|
|
113
|
+
offset: Pagination offset (default: 0).
|
|
110
114
|
|
|
111
115
|
Returns:
|
|
112
|
-
Search results with matching messages.
|
|
116
|
+
Search results with matching messages including expanded fields.
|
|
113
117
|
|
|
114
118
|
Raises:
|
|
115
|
-
Exception: If search fails.
|
|
119
|
+
Exception: If search fails or no filter provided.
|
|
116
120
|
"""
|
|
121
|
+
if inbox_id is None and thread_id is None:
|
|
122
|
+
raise Exception("At least one filter (inbox_id or thread_id) is required")
|
|
123
|
+
|
|
117
124
|
try:
|
|
118
|
-
result = await client.
|
|
119
|
-
query=query,
|
|
125
|
+
result = await client.list_messages(
|
|
120
126
|
inbox_id=inbox_id,
|
|
127
|
+
thread_id=thread_id,
|
|
128
|
+
q=query,
|
|
121
129
|
limit=limit,
|
|
130
|
+
offset=offset,
|
|
122
131
|
)
|
|
123
132
|
|
|
124
|
-
# Format results for MCP
|
|
133
|
+
# Format results for MCP with expanded fields
|
|
125
134
|
messages = []
|
|
126
135
|
for item in result.get("items", []):
|
|
127
|
-
messages.append(
|
|
128
|
-
{
|
|
129
|
-
"id": item["id"],
|
|
130
|
-
"thread_id": item["thread_id"],
|
|
131
|
-
"content": item.get("content_clean", ""),
|
|
132
|
-
"created_at": item.get("created_at"),
|
|
133
|
-
}
|
|
134
|
-
)
|
|
136
|
+
messages.append(_format_message_for_mcp(item))
|
|
135
137
|
|
|
136
138
|
return {
|
|
137
139
|
"query": query,
|
|
138
140
|
"count": len(messages),
|
|
141
|
+
"total": result.get("total", len(messages)),
|
|
139
142
|
"messages": messages,
|
|
140
143
|
}
|
|
141
144
|
except httpx.HTTPStatusError as e:
|
|
142
145
|
if e.response.status_code == 404:
|
|
143
|
-
raise Exception(
|
|
146
|
+
raise Exception("Inbox or thread not found") from e
|
|
147
|
+
if e.response.status_code == 422:
|
|
148
|
+
raise Exception("At least one filter (inbox_id or thread_id) is required") from e
|
|
144
149
|
raise Exception(f"Search failed: {e.response.status_code}") from e
|
|
145
150
|
|
|
146
151
|
|
|
152
|
+
async def list_messages(
|
|
153
|
+
client: NornWeaveClient,
|
|
154
|
+
inbox_id: str | None = None,
|
|
155
|
+
thread_id: str | None = None,
|
|
156
|
+
limit: int = 50,
|
|
157
|
+
offset: int = 0,
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
|
+
"""List messages with flexible filters.
|
|
160
|
+
|
|
161
|
+
List messages from an inbox or thread. At least one of inbox_id or thread_id must be provided.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
client: NornWeave API client.
|
|
165
|
+
inbox_id: Filter by inbox ID (optional).
|
|
166
|
+
thread_id: Filter by thread ID (optional).
|
|
167
|
+
limit: Maximum number of results (default: 50).
|
|
168
|
+
offset: Pagination offset (default: 0).
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
List of messages with expanded fields.
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
Exception: If listing fails or no filter provided.
|
|
175
|
+
"""
|
|
176
|
+
if inbox_id is None and thread_id is None:
|
|
177
|
+
raise Exception("At least one filter (inbox_id or thread_id) is required")
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
result = await client.list_messages(
|
|
181
|
+
inbox_id=inbox_id,
|
|
182
|
+
thread_id=thread_id,
|
|
183
|
+
limit=limit,
|
|
184
|
+
offset=offset,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Format results for MCP with expanded fields
|
|
188
|
+
messages = []
|
|
189
|
+
for item in result.get("items", []):
|
|
190
|
+
messages.append(_format_message_for_mcp(item))
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
"count": len(messages),
|
|
194
|
+
"total": result.get("total", len(messages)),
|
|
195
|
+
"messages": messages,
|
|
196
|
+
}
|
|
197
|
+
except httpx.HTTPStatusError as e:
|
|
198
|
+
if e.response.status_code == 404:
|
|
199
|
+
raise Exception("Inbox or thread not found") from e
|
|
200
|
+
if e.response.status_code == 422:
|
|
201
|
+
raise Exception("At least one filter (inbox_id or thread_id) is required") from e
|
|
202
|
+
raise Exception(f"List messages failed: {e.response.status_code}") from e
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _format_message_for_mcp(item: dict[str, Any]) -> dict[str, Any]:
|
|
206
|
+
"""Format a message response for MCP tools with all expanded fields."""
|
|
207
|
+
return {
|
|
208
|
+
"id": item["id"],
|
|
209
|
+
"thread_id": item["thread_id"],
|
|
210
|
+
"inbox_id": item["inbox_id"],
|
|
211
|
+
"direction": item.get("direction"),
|
|
212
|
+
"subject": item.get("subject"),
|
|
213
|
+
"from_address": item.get("from_address"),
|
|
214
|
+
"to_addresses": item.get("to_addresses", []),
|
|
215
|
+
"cc_addresses": item.get("cc_addresses"),
|
|
216
|
+
"text": item.get("text"),
|
|
217
|
+
"content_clean": item.get("content_clean", ""),
|
|
218
|
+
"timestamp": item.get("timestamp"),
|
|
219
|
+
"preview": item.get("preview"),
|
|
220
|
+
"labels": item.get("labels", []),
|
|
221
|
+
"created_at": item.get("created_at"),
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
147
225
|
async def wait_for_reply(
|
|
148
226
|
client: NornWeaveClient,
|
|
149
227
|
thread_id: str,
|
|
@@ -205,3 +283,173 @@ async def wait_for_reply(
|
|
|
205
283
|
"timeout": True,
|
|
206
284
|
"waited_seconds": timeout_seconds,
|
|
207
285
|
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
async def list_attachments(
|
|
289
|
+
client: NornWeaveClient,
|
|
290
|
+
message_id: str | None = None,
|
|
291
|
+
thread_id: str | None = None,
|
|
292
|
+
inbox_id: str | None = None,
|
|
293
|
+
limit: int = 100,
|
|
294
|
+
) -> dict[str, Any]:
|
|
295
|
+
"""List attachments for a message, thread, or inbox.
|
|
296
|
+
|
|
297
|
+
Retrieve metadata for attachments. Exactly one filter must be provided.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
client: NornWeave API client.
|
|
301
|
+
message_id: Filter by message ID.
|
|
302
|
+
thread_id: Filter by thread ID (all messages in thread).
|
|
303
|
+
inbox_id: Filter by inbox ID (all messages in inbox).
|
|
304
|
+
limit: Maximum number of results (default: 100).
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
List of attachment metadata objects.
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
Exception: If filter is missing or invalid.
|
|
311
|
+
"""
|
|
312
|
+
# Validate exactly one filter
|
|
313
|
+
filters = [f for f in [message_id, thread_id, inbox_id] if f is not None]
|
|
314
|
+
if len(filters) == 0:
|
|
315
|
+
raise Exception("Exactly one filter required: message_id, thread_id, or inbox_id")
|
|
316
|
+
if len(filters) > 1:
|
|
317
|
+
raise Exception("Only one filter allowed: message_id, thread_id, or inbox_id")
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
result = await client.list_attachments(
|
|
321
|
+
message_id=message_id,
|
|
322
|
+
thread_id=thread_id,
|
|
323
|
+
inbox_id=inbox_id,
|
|
324
|
+
limit=limit,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Format results for MCP
|
|
328
|
+
attachments = []
|
|
329
|
+
for item in result.get("items", []):
|
|
330
|
+
attachments.append(
|
|
331
|
+
{
|
|
332
|
+
"id": item["id"],
|
|
333
|
+
"message_id": item["message_id"],
|
|
334
|
+
"filename": item["filename"],
|
|
335
|
+
"content_type": item["content_type"],
|
|
336
|
+
"size": item["size"],
|
|
337
|
+
}
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
"count": len(attachments),
|
|
342
|
+
"attachments": attachments,
|
|
343
|
+
}
|
|
344
|
+
except httpx.HTTPStatusError as e:
|
|
345
|
+
if e.response.status_code == 400:
|
|
346
|
+
raise Exception(
|
|
347
|
+
"Invalid filter: exactly one of message_id, thread_id, or inbox_id required"
|
|
348
|
+
) from e
|
|
349
|
+
if e.response.status_code == 404:
|
|
350
|
+
raise Exception("Resource not found") from e
|
|
351
|
+
raise Exception(f"Failed to list attachments: {e.response.status_code}") from e
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
async def get_attachment_content(
|
|
355
|
+
client: NornWeaveClient,
|
|
356
|
+
attachment_id: str,
|
|
357
|
+
) -> dict[str, Any]:
|
|
358
|
+
"""Retrieve attachment content as base64.
|
|
359
|
+
|
|
360
|
+
Download the binary content of an attachment, encoded as base64.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
client: NornWeave API client.
|
|
364
|
+
attachment_id: The attachment ID to retrieve.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
Attachment content with base64-encoded data, content_type, and filename.
|
|
368
|
+
|
|
369
|
+
Raises:
|
|
370
|
+
Exception: If attachment not found.
|
|
371
|
+
"""
|
|
372
|
+
try:
|
|
373
|
+
result = await client.get_attachment_content(
|
|
374
|
+
attachment_id=attachment_id,
|
|
375
|
+
response_format="base64",
|
|
376
|
+
)
|
|
377
|
+
return {
|
|
378
|
+
"content": result["content"],
|
|
379
|
+
"content_type": result["content_type"],
|
|
380
|
+
"filename": result["filename"],
|
|
381
|
+
}
|
|
382
|
+
except httpx.HTTPStatusError as e:
|
|
383
|
+
if e.response.status_code == 404:
|
|
384
|
+
raise Exception(f"Attachment '{attachment_id}' not found") from e
|
|
385
|
+
if e.response.status_code == 401:
|
|
386
|
+
raise Exception("Attachment download URL expired or invalid") from e
|
|
387
|
+
raise Exception(f"Failed to get attachment content: {e.response.status_code}") from e
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
async def send_email_with_attachments(
|
|
391
|
+
client: NornWeaveClient,
|
|
392
|
+
inbox_id: str,
|
|
393
|
+
recipient: str,
|
|
394
|
+
subject: str,
|
|
395
|
+
body: str,
|
|
396
|
+
attachments: list[dict[str, str]],
|
|
397
|
+
thread_id: str | None = None,
|
|
398
|
+
) -> dict[str, Any]:
|
|
399
|
+
"""Send an email with attachments.
|
|
400
|
+
|
|
401
|
+
Send an email with one or more attachments. Each attachment should include
|
|
402
|
+
filename, content_type, and base64-encoded content.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
client: NornWeave API client.
|
|
406
|
+
inbox_id: The inbox to send from.
|
|
407
|
+
recipient: Email address to send to.
|
|
408
|
+
subject: Email subject.
|
|
409
|
+
body: Markdown content for the email body.
|
|
410
|
+
attachments: List of attachment dicts with keys:
|
|
411
|
+
- filename: Name of the file
|
|
412
|
+
- content_type: MIME type (e.g., "application/pdf")
|
|
413
|
+
- content: Base64-encoded file content
|
|
414
|
+
thread_id: Optional thread ID for replies.
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
Send response with message_id, thread_id, status.
|
|
418
|
+
|
|
419
|
+
Raises:
|
|
420
|
+
Exception: If sending fails or attachments are invalid.
|
|
421
|
+
"""
|
|
422
|
+
# Validate attachments
|
|
423
|
+
if not attachments:
|
|
424
|
+
raise Exception(
|
|
425
|
+
"At least one attachment is required. Use send_email for messages without attachments."
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
for i, att in enumerate(attachments):
|
|
429
|
+
if not att.get("filename"):
|
|
430
|
+
raise Exception(f"Attachment {i} missing filename")
|
|
431
|
+
if not att.get("content_type"):
|
|
432
|
+
raise Exception(f"Attachment {i} missing content_type")
|
|
433
|
+
if not att.get("content"):
|
|
434
|
+
raise Exception(f"Attachment {i} missing content (base64)")
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
result = await client.send_message_with_attachments(
|
|
438
|
+
inbox_id=inbox_id,
|
|
439
|
+
to=[recipient],
|
|
440
|
+
subject=subject,
|
|
441
|
+
body=body,
|
|
442
|
+
attachments=attachments,
|
|
443
|
+
reply_to_thread_id=thread_id,
|
|
444
|
+
)
|
|
445
|
+
return {
|
|
446
|
+
"message_id": result["id"],
|
|
447
|
+
"thread_id": result["thread_id"],
|
|
448
|
+
"status": result.get("status", "sent"),
|
|
449
|
+
}
|
|
450
|
+
except httpx.HTTPStatusError as e:
|
|
451
|
+
if e.response.status_code == 404:
|
|
452
|
+
raise Exception(f"Inbox '{inbox_id}' not found") from e
|
|
453
|
+
if e.response.status_code == 400:
|
|
454
|
+
raise Exception("Invalid attachment data (check base64 encoding)") from e
|
|
455
|
+
raise Exception(f"Failed to send email: {e.response.status_code}") from e
|
nornweave/storage/database.py
CHANGED
|
@@ -54,7 +54,7 @@ class DatabaseBlobStorage(AttachmentStorageBackend):
|
|
|
54
54
|
self,
|
|
55
55
|
attachment_id: str,
|
|
56
56
|
content: bytes,
|
|
57
|
-
|
|
57
|
+
metadata: AttachmentMetadata, # noqa: ARG002 - required by interface
|
|
58
58
|
) -> StorageResult:
|
|
59
59
|
"""
|
|
60
60
|
Store attachment in database.
|
nornweave/urdr/adapters/base.py
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
"""Base storage adapter with shared SQLAlchemy functionality."""
|
|
2
2
|
|
|
3
3
|
import uuid
|
|
4
|
-
from datetime import UTC, datetime, timedelta
|
|
4
|
+
from datetime import UTC, date, datetime, timedelta
|
|
5
5
|
from typing import TYPE_CHECKING, Any
|
|
6
6
|
|
|
7
|
-
from sqlalchemy import or_, select
|
|
7
|
+
from sqlalchemy import func, or_, select
|
|
8
8
|
|
|
9
9
|
from nornweave.core.interfaces import StorageInterface
|
|
10
|
-
from nornweave.urdr.orm import
|
|
10
|
+
from nornweave.urdr.orm import (
|
|
11
|
+
AttachmentORM,
|
|
12
|
+
EventORM,
|
|
13
|
+
InboxORM,
|
|
14
|
+
LlmTokenUsageORM,
|
|
15
|
+
MessageORM,
|
|
16
|
+
ThreadORM,
|
|
17
|
+
)
|
|
11
18
|
|
|
12
19
|
if TYPE_CHECKING:
|
|
13
20
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
@@ -115,6 +122,13 @@ class BaseSQLAlchemyAdapter(StorageInterface):
|
|
|
115
122
|
orm_thread.subject = thread.subject
|
|
116
123
|
orm_thread.last_message_at = thread.last_message_at
|
|
117
124
|
orm_thread.participant_hash = thread.participant_hash
|
|
125
|
+
if thread.timestamp is not None:
|
|
126
|
+
orm_thread.timestamp = thread.timestamp
|
|
127
|
+
orm_thread.message_count = thread.message_count
|
|
128
|
+
orm_thread.preview = thread.preview
|
|
129
|
+
orm_thread.senders = thread.senders
|
|
130
|
+
orm_thread.recipients = thread.recipients
|
|
131
|
+
orm_thread.summary = thread.summary
|
|
118
132
|
await self._session.flush()
|
|
119
133
|
await self._session.refresh(orm_thread)
|
|
120
134
|
return orm_thread.to_pydantic()
|
|
@@ -221,6 +235,63 @@ class BaseSQLAlchemyAdapter(StorageInterface):
|
|
|
221
235
|
result = await self._session.execute(stmt)
|
|
222
236
|
return [row.to_pydantic() for row in result.scalars().all()]
|
|
223
237
|
|
|
238
|
+
async def search_messages_advanced(
|
|
239
|
+
self,
|
|
240
|
+
*,
|
|
241
|
+
inbox_id: str | None = None,
|
|
242
|
+
thread_id: str | None = None,
|
|
243
|
+
query: str | None = None,
|
|
244
|
+
limit: int = 50,
|
|
245
|
+
offset: int = 0,
|
|
246
|
+
) -> tuple[list[Message], int]:
|
|
247
|
+
"""
|
|
248
|
+
Search messages with flexible filters.
|
|
249
|
+
|
|
250
|
+
Returns tuple of (messages, total_count).
|
|
251
|
+
Override in subclass for dialect-specific search (ILIKE for Postgres).
|
|
252
|
+
"""
|
|
253
|
+
# Build base query with filters
|
|
254
|
+
base_conditions = []
|
|
255
|
+
if inbox_id:
|
|
256
|
+
base_conditions.append(MessageORM.inbox_id == inbox_id)
|
|
257
|
+
if thread_id:
|
|
258
|
+
base_conditions.append(MessageORM.thread_id == thread_id)
|
|
259
|
+
|
|
260
|
+
# Text search condition
|
|
261
|
+
if query:
|
|
262
|
+
pattern = f"%{query}%"
|
|
263
|
+
# Subquery for attachment filename search
|
|
264
|
+
attachment_subquery = (
|
|
265
|
+
select(AttachmentORM.message_id)
|
|
266
|
+
.where(AttachmentORM.filename.like(pattern))
|
|
267
|
+
.distinct()
|
|
268
|
+
.scalar_subquery()
|
|
269
|
+
)
|
|
270
|
+
text_condition = or_(
|
|
271
|
+
MessageORM.subject.like(pattern),
|
|
272
|
+
MessageORM.content_raw.like(pattern),
|
|
273
|
+
MessageORM.from_address.like(pattern),
|
|
274
|
+
MessageORM.id.in_(attachment_subquery),
|
|
275
|
+
)
|
|
276
|
+
base_conditions.append(text_condition)
|
|
277
|
+
|
|
278
|
+
# Count query
|
|
279
|
+
count_stmt = select(func.count(MessageORM.id))
|
|
280
|
+
if base_conditions:
|
|
281
|
+
count_stmt = count_stmt.where(*base_conditions)
|
|
282
|
+
count_result = await self._session.execute(count_stmt)
|
|
283
|
+
total = count_result.scalar() or 0
|
|
284
|
+
|
|
285
|
+
# Data query with pagination
|
|
286
|
+
data_stmt = select(MessageORM)
|
|
287
|
+
if base_conditions:
|
|
288
|
+
data_stmt = data_stmt.where(*base_conditions)
|
|
289
|
+
data_stmt = data_stmt.order_by(MessageORM.created_at.desc()).limit(limit).offset(offset)
|
|
290
|
+
data_result = await self._session.execute(data_stmt)
|
|
291
|
+
messages = [row.to_pydantic() for row in data_result.scalars().all()]
|
|
292
|
+
|
|
293
|
+
return messages, total
|
|
294
|
+
|
|
224
295
|
# -------------------------------------------------------------------------
|
|
225
296
|
# Event methods
|
|
226
297
|
# -------------------------------------------------------------------------
|
|
@@ -334,6 +405,76 @@ class BaseSQLAlchemyAdapter(StorageInterface):
|
|
|
334
405
|
for row in result.scalars().all()
|
|
335
406
|
]
|
|
336
407
|
|
|
408
|
+
async def list_attachments_for_thread(
|
|
409
|
+
self,
|
|
410
|
+
thread_id: str,
|
|
411
|
+
*,
|
|
412
|
+
limit: int = 100,
|
|
413
|
+
offset: int = 0,
|
|
414
|
+
) -> list[dict[str, Any]]:
|
|
415
|
+
"""List attachments for all messages in a thread."""
|
|
416
|
+
# Join attachments with messages to filter by thread_id
|
|
417
|
+
stmt = (
|
|
418
|
+
select(AttachmentORM)
|
|
419
|
+
.join(MessageORM, AttachmentORM.message_id == MessageORM.id)
|
|
420
|
+
.where(MessageORM.thread_id == thread_id)
|
|
421
|
+
.order_by(AttachmentORM.created_at)
|
|
422
|
+
.limit(limit)
|
|
423
|
+
.offset(offset)
|
|
424
|
+
)
|
|
425
|
+
result = await self._session.execute(stmt)
|
|
426
|
+
return [
|
|
427
|
+
{
|
|
428
|
+
"id": row.id,
|
|
429
|
+
"message_id": row.message_id,
|
|
430
|
+
"filename": row.filename,
|
|
431
|
+
"content_type": row.content_type,
|
|
432
|
+
"size_bytes": row.size_bytes,
|
|
433
|
+
"disposition": row.disposition,
|
|
434
|
+
"content_id": row.content_id,
|
|
435
|
+
"storage_path": row.storage_path,
|
|
436
|
+
"storage_backend": row.storage_backend,
|
|
437
|
+
"content_hash": row.content_hash,
|
|
438
|
+
"created_at": row.created_at,
|
|
439
|
+
}
|
|
440
|
+
for row in result.scalars().all()
|
|
441
|
+
]
|
|
442
|
+
|
|
443
|
+
async def list_attachments_for_inbox(
|
|
444
|
+
self,
|
|
445
|
+
inbox_id: str,
|
|
446
|
+
*,
|
|
447
|
+
limit: int = 100,
|
|
448
|
+
offset: int = 0,
|
|
449
|
+
) -> list[dict[str, Any]]:
|
|
450
|
+
"""List attachments for all messages in an inbox."""
|
|
451
|
+
# Join attachments with messages to filter by inbox_id
|
|
452
|
+
stmt = (
|
|
453
|
+
select(AttachmentORM)
|
|
454
|
+
.join(MessageORM, AttachmentORM.message_id == MessageORM.id)
|
|
455
|
+
.where(MessageORM.inbox_id == inbox_id)
|
|
456
|
+
.order_by(AttachmentORM.created_at.desc())
|
|
457
|
+
.limit(limit)
|
|
458
|
+
.offset(offset)
|
|
459
|
+
)
|
|
460
|
+
result = await self._session.execute(stmt)
|
|
461
|
+
return [
|
|
462
|
+
{
|
|
463
|
+
"id": row.id,
|
|
464
|
+
"message_id": row.message_id,
|
|
465
|
+
"filename": row.filename,
|
|
466
|
+
"content_type": row.content_type,
|
|
467
|
+
"size_bytes": row.size_bytes,
|
|
468
|
+
"disposition": row.disposition,
|
|
469
|
+
"content_id": row.content_id,
|
|
470
|
+
"storage_path": row.storage_path,
|
|
471
|
+
"storage_backend": row.storage_backend,
|
|
472
|
+
"content_hash": row.content_hash,
|
|
473
|
+
"created_at": row.created_at,
|
|
474
|
+
}
|
|
475
|
+
for row in result.scalars().all()
|
|
476
|
+
]
|
|
477
|
+
|
|
337
478
|
async def delete_attachment(self, attachment_id: str) -> bool:
|
|
338
479
|
"""Delete an attachment."""
|
|
339
480
|
orm_attachment = await self._session.get(AttachmentORM, attachment_id)
|
|
@@ -383,3 +524,32 @@ class BaseSQLAlchemyAdapter(StorageInterface):
|
|
|
383
524
|
result = await self._session.execute(stmt)
|
|
384
525
|
orm_thread = result.scalar_one_or_none()
|
|
385
526
|
return orm_thread.to_pydantic() if orm_thread else None
|
|
527
|
+
|
|
528
|
+
# -------------------------------------------------------------------------
|
|
529
|
+
# LLM Token Usage methods
|
|
530
|
+
# -------------------------------------------------------------------------
|
|
531
|
+
async def get_token_usage(self, usage_date: date) -> int:
|
|
532
|
+
"""Get total tokens used for a given date."""
|
|
533
|
+
stmt = select(LlmTokenUsageORM.tokens_used).where(
|
|
534
|
+
LlmTokenUsageORM.date == usage_date,
|
|
535
|
+
)
|
|
536
|
+
result = await self._session.execute(stmt)
|
|
537
|
+
row = result.scalar_one_or_none()
|
|
538
|
+
return row if row is not None else 0
|
|
539
|
+
|
|
540
|
+
async def record_token_usage(self, usage_date: date, tokens: int) -> None:
|
|
541
|
+
"""Record token usage for a given date (upsert: create or increment)."""
|
|
542
|
+
stmt = select(LlmTokenUsageORM).where(LlmTokenUsageORM.date == usage_date)
|
|
543
|
+
result = await self._session.execute(stmt)
|
|
544
|
+
usage_row = result.scalar_one_or_none()
|
|
545
|
+
|
|
546
|
+
if usage_row is None:
|
|
547
|
+
usage_row = LlmTokenUsageORM(
|
|
548
|
+
date=usage_date,
|
|
549
|
+
tokens_used=tokens,
|
|
550
|
+
)
|
|
551
|
+
self._session.add(usage_row)
|
|
552
|
+
else:
|
|
553
|
+
usage_row.tokens_used = usage_row.tokens_used + tokens
|
|
554
|
+
|
|
555
|
+
await self._session.flush()
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Add LLM thread summary: summary column on threads, llm_token_usage table.
|
|
2
|
+
|
|
3
|
+
Revision ID: 0003
|
|
4
|
+
Revises: 0002
|
|
5
|
+
Create Date: 2026-02-05
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
from alembic import op
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
# revision identifiers, used by Alembic.
|
|
18
|
+
revision: str = "0003"
|
|
19
|
+
down_revision: str | None = "0002"
|
|
20
|
+
branch_labels: str | Sequence[str] | None = None
|
|
21
|
+
depends_on: str | Sequence[str] | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def upgrade() -> None:
|
|
25
|
+
"""Add summary column to threads and create llm_token_usage table."""
|
|
26
|
+
# ==========================================================================
|
|
27
|
+
# Add summary column to threads table
|
|
28
|
+
# ==========================================================================
|
|
29
|
+
op.add_column(
|
|
30
|
+
"threads",
|
|
31
|
+
sa.Column("summary", sa.Text(), nullable=True),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# ==========================================================================
|
|
35
|
+
# Create llm_token_usage table
|
|
36
|
+
# ==========================================================================
|
|
37
|
+
op.create_table(
|
|
38
|
+
"llm_token_usage",
|
|
39
|
+
sa.Column("date", sa.Date(), nullable=False),
|
|
40
|
+
sa.Column("tokens_used", sa.Integer(), nullable=False, server_default="0"),
|
|
41
|
+
sa.Column(
|
|
42
|
+
"updated_at",
|
|
43
|
+
sa.DateTime(timezone=True),
|
|
44
|
+
nullable=False,
|
|
45
|
+
server_default=sa.func.now(),
|
|
46
|
+
),
|
|
47
|
+
sa.PrimaryKeyConstraint("date"),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def downgrade() -> None:
|
|
52
|
+
"""Remove summary column from threads and drop llm_token_usage table."""
|
|
53
|
+
op.drop_table("llm_token_usage")
|
|
54
|
+
op.drop_column("threads", "summary")
|
nornweave/urdr/orm.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"""SQLAlchemy ORM models for Urðr storage layer."""
|
|
2
2
|
|
|
3
3
|
import uuid
|
|
4
|
-
from datetime import datetime
|
|
4
|
+
from datetime import date, datetime
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
from sqlalchemy import (
|
|
8
8
|
JSON,
|
|
9
|
+
Date,
|
|
9
10
|
DateTime,
|
|
10
11
|
ForeignKey,
|
|
11
12
|
Index,
|
|
@@ -171,6 +172,7 @@ class ThreadORM(Base):
|
|
|
171
172
|
index=True,
|
|
172
173
|
)
|
|
173
174
|
preview: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
175
|
+
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
174
176
|
|
|
175
177
|
# Stats
|
|
176
178
|
last_message_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
|
@@ -211,6 +213,7 @@ class ThreadORM(Base):
|
|
|
211
213
|
recipients=self.recipients or [],
|
|
212
214
|
subject=self.subject,
|
|
213
215
|
preview=self.preview,
|
|
216
|
+
summary=self.summary,
|
|
214
217
|
attachments=None, # Load separately if needed
|
|
215
218
|
last_message_id=self.last_message_id,
|
|
216
219
|
message_count=self.message_count,
|
|
@@ -236,6 +239,7 @@ class ThreadORM(Base):
|
|
|
236
239
|
subject=thread.subject,
|
|
237
240
|
normalized_subject=thread.normalized_subject,
|
|
238
241
|
preview=thread.preview,
|
|
242
|
+
summary=thread.summary,
|
|
239
243
|
last_message_id=thread.last_message_id,
|
|
240
244
|
message_count=thread.message_count,
|
|
241
245
|
size=thread.size,
|
|
@@ -639,3 +643,25 @@ class EventORM(Base):
|
|
|
639
643
|
thread_id=event.thread_id,
|
|
640
644
|
message_id=event.message_id,
|
|
641
645
|
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
class LlmTokenUsageORM(Base):
|
|
649
|
+
"""Daily LLM token usage tracking."""
|
|
650
|
+
|
|
651
|
+
__tablename__ = "llm_token_usage"
|
|
652
|
+
|
|
653
|
+
date: Mapped[date] = mapped_column(
|
|
654
|
+
Date,
|
|
655
|
+
primary_key=True,
|
|
656
|
+
)
|
|
657
|
+
tokens_used: Mapped[int] = mapped_column(
|
|
658
|
+
Integer,
|
|
659
|
+
nullable=False,
|
|
660
|
+
default=0,
|
|
661
|
+
)
|
|
662
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
663
|
+
DateTime(timezone=True),
|
|
664
|
+
nullable=False,
|
|
665
|
+
server_default=func.now(),
|
|
666
|
+
onupdate=func.now(),
|
|
667
|
+
)
|