slide-narrator 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of slide-narrator might be problematic. Click here for more details.

@@ -0,0 +1,469 @@
1
+ from typing import List, Dict, Optional, Literal, Any
2
+ from datetime import datetime, UTC
3
+ from pydantic import BaseModel, Field, field_validator
4
+ from narrator.models.message import Message
5
+ from narrator.storage.file_store import FileStore
6
+ import uuid
7
+ from narrator.utils.logging import get_logger
8
+
9
+ logger = get_logger(__name__)
10
+
11
+ class Thread(BaseModel):
12
+ """Represents a thread containing multiple messages"""
13
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()))
14
+ title: Optional[str] = Field(default="Untitled Thread")
15
+ messages: List[Message] = Field(default_factory=list)
16
+ created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
17
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
18
+ attributes: Dict = Field(default_factory=dict)
19
+ platforms: Dict[str, Dict[str, str]] = Field(
20
+ default_factory=dict,
21
+ description="References to where this thread exists on external platforms. Maps platform name to platform-specific identifiers."
22
+ )
23
+
24
+ model_config = {
25
+ "json_schema_extra": {
26
+ "examples": [
27
+ {
28
+ "id": "thread-123",
29
+ "title": "Example Thread",
30
+ "messages": [],
31
+ "created_at": "2024-02-07T00:00:00+00:00",
32
+ "updated_at": "2024-02-07T00:00:00+00:00",
33
+ "attributes": {},
34
+ "platforms": {
35
+ "slack": {
36
+ "channel": "C123",
37
+ "thread_ts": "1234567890.123"
38
+ }
39
+ }
40
+ }
41
+ ]
42
+ }
43
+ }
44
+
45
+ @field_validator("created_at", "updated_at", mode="before")
46
+ def ensure_timezone(cls, value: datetime) -> datetime:
47
+ """Ensure all datetime fields are timezone-aware UTC"""
48
+ if value.tzinfo is None:
49
+ return value.replace(tzinfo=UTC)
50
+ return value
51
+
52
+ def model_dump(self, mode: str = "json") -> Dict[str, Any]:
53
+ """Convert thread to a dictionary suitable for JSON serialization
54
+
55
+ Args:
56
+ mode: Serialization mode, either "json" or "python".
57
+ "json" converts datetimes to ISO strings (default).
58
+ "python" keeps datetimes as datetime objects.
59
+ """
60
+ return {
61
+ "id": self.id,
62
+ "title": self.title,
63
+ "messages": [msg.model_dump(mode=mode) for msg in self.messages],
64
+ "created_at": self.created_at.isoformat() if mode == "json" else self.created_at,
65
+ "updated_at": self.updated_at.isoformat() if mode == "json" else self.updated_at,
66
+ "attributes": self.attributes,
67
+ "platforms": self.platforms
68
+ }
69
+
70
+ def add_message(self, message: Message, same_turn: bool = False) -> None:
71
+ """Add a new message to the thread and update analytics
72
+
73
+ Args:
74
+ message: The message to add
75
+ same_turn: If True, assign the same turn as the last message (for grouping related messages)
76
+ """
77
+ # Set message sequence - system messages always get 0, others get next available number starting at 1
78
+ if message.role == "system":
79
+ message.sequence = 0
80
+ message.turn = 0 # System messages always get turn 0
81
+ # Insert at beginning to maintain system message first
82
+ self.messages.insert(0, message)
83
+ else:
84
+ # Find highest sequence number and increment
85
+ max_sequence = max((m.sequence for m in self.messages if m.role != "system"), default=0)
86
+ message.sequence = max_sequence + 1
87
+ self.messages.append(message)
88
+
89
+ # Handle turn assignment for non-system messages
90
+ if message.role != "system":
91
+ if same_turn and self.messages and len(self.messages) > 1:
92
+ # Use same turn as last message (excluding the one we just added)
93
+ last_message = self.messages[-2] # Get second-to-last since we just appended
94
+ if last_message.role != "system":
95
+ message.turn = last_message.turn
96
+ else:
97
+ # If last message was system, get next turn
98
+ max_turn = max((m.turn for m in self.messages if m.turn is not None and m.role != "system"), default=0)
99
+ message.turn = max_turn + 1
100
+ else:
101
+ # Get next turn number
102
+ max_turn = max((m.turn for m in self.messages if m.turn is not None and m.role != "system"), default=0)
103
+ message.turn = max_turn + 1
104
+
105
+ self.updated_at = datetime.now(UTC)
106
+
107
+ def add_messages_batch(self, messages: List[Message]) -> None:
108
+ """Add multiple messages as a batch (all get the same turn number)
109
+
110
+ Args:
111
+ messages: List of messages to add as a group
112
+ """
113
+ if not messages:
114
+ return
115
+
116
+ # Get the next turn number for all messages in the batch
117
+ max_turn = max((m.turn for m in self.messages if m.turn is not None and m.role != "system"), default=0)
118
+ batch_turn = max_turn + 1
119
+
120
+ # Add each message with the same turn number
121
+ for i, message in enumerate(messages):
122
+ if message.role == "system":
123
+ # System messages handled separately
124
+ message.sequence = 0
125
+ message.turn = 0
126
+ self.messages.insert(0, message)
127
+ else:
128
+ # Set sequence and turn for non-system messages
129
+ max_sequence = max((m.sequence for m in self.messages if m.role != "system"), default=0)
130
+ message.sequence = max_sequence + 1
131
+ message.turn = batch_turn
132
+ self.messages.append(message)
133
+
134
+ self.updated_at = datetime.now(UTC)
135
+
136
+ async def get_messages_for_chat_completion(self, file_store: Optional[FileStore] = None) -> List[Dict[str, Any]]:
137
+ """Return messages in the format expected by chat completion APIs
138
+
139
+ Note: This excludes system messages as they are injected by agents at completion time.
140
+
141
+ Args:
142
+ file_store: Optional FileStore instance to pass to messages for file URL access
143
+ """
144
+ # Only include non-system messages from the thread - system messages are injected by agents
145
+ return [msg.to_chat_completion_message(file_store=file_store) for msg in self.messages if msg.role != "system"]
146
+
147
+ def clear_messages(self) -> None:
148
+ """Clear all messages from the thread"""
149
+ self.messages = []
150
+ self.updated_at = datetime.now(UTC)
151
+
152
+ def get_last_message_by_role(self, role: Literal["user", "assistant", "system", "tool"]) -> Optional[Message]:
153
+ """Return the last message with the specified role, or None if no messages exist with that role"""
154
+ messages = [m for m in self.messages if m.role == role]
155
+ return messages[-1] if messages else None
156
+
157
+ def generate_title(self) -> str:
158
+ """Generate a simple title for the thread based on first message"""
159
+ if not self.messages:
160
+ return "Empty Thread"
161
+
162
+ # Use first user message content for title
163
+ user_messages = [msg for msg in self.messages if msg.role == "user"]
164
+ if user_messages:
165
+ first_content = user_messages[0].content
166
+ if isinstance(first_content, str):
167
+ # Take first 50 characters and add ellipsis if needed
168
+ title = first_content[:50]
169
+ if len(first_content) > 50:
170
+ title += "..."
171
+ self.title = title
172
+ self.updated_at = datetime.now(UTC)
173
+ return title
174
+
175
+ return "Untitled Thread"
176
+
177
+ def get_total_tokens(self) -> Dict[str, Any]:
178
+ """Get total token usage across all messages in the thread
179
+
180
+ Returns:
181
+ Dictionary containing:
182
+ - overall: Total token counts across all models
183
+ - by_model: Token counts broken down by model
184
+ """
185
+ overall = {
186
+ "completion_tokens": 0,
187
+ "prompt_tokens": 0,
188
+ "total_tokens": 0
189
+ }
190
+
191
+ by_model = {}
192
+
193
+ for message in self.messages:
194
+ metrics = message.metrics
195
+ if not metrics:
196
+ continue
197
+
198
+ # Update overall counts
199
+ if "usage" in metrics:
200
+ overall["completion_tokens"] += metrics["usage"].get("completion_tokens", 0)
201
+ overall["prompt_tokens"] += metrics["usage"].get("prompt_tokens", 0)
202
+ overall["total_tokens"] += metrics["usage"].get("total_tokens", 0)
203
+
204
+ # Update per-model counts
205
+ model = metrics.get("model")
206
+ if model:
207
+ if model not in by_model:
208
+ by_model[model] = {
209
+ "completion_tokens": 0,
210
+ "prompt_tokens": 0,
211
+ "total_tokens": 0
212
+ }
213
+
214
+ if "usage" in metrics:
215
+ by_model[model]["completion_tokens"] += metrics["usage"].get("completion_tokens", 0)
216
+ by_model[model]["prompt_tokens"] += metrics["usage"].get("prompt_tokens", 0)
217
+ by_model[model]["total_tokens"] += metrics["usage"].get("total_tokens", 0)
218
+
219
+ return {
220
+ "overall": overall,
221
+ "by_model": by_model
222
+ }
223
+
224
+ def get_model_usage(self, model_name: Optional[str] = None) -> Dict[str, Any]:
225
+ """Get usage statistics for a specific model or all models
226
+
227
+ Args:
228
+ model_name: Optional name of model to get stats for. If None, returns all models.
229
+
230
+ Returns:
231
+ Dictionary containing model usage statistics
232
+ """
233
+ model_usage = {}
234
+
235
+ for message in self.messages:
236
+ metrics = message.metrics
237
+ if not metrics or not metrics.get("model"):
238
+ continue
239
+
240
+ model = metrics["model"]
241
+ if model not in model_usage:
242
+ model_usage[model] = {
243
+ "calls": 0,
244
+ "completion_tokens": 0,
245
+ "prompt_tokens": 0,
246
+ "total_tokens": 0
247
+ }
248
+
249
+ model_usage[model]["calls"] += 1
250
+ if "usage" in metrics:
251
+ model_usage[model]["completion_tokens"] += metrics["usage"].get("completion_tokens", 0)
252
+ model_usage[model]["prompt_tokens"] += metrics["usage"].get("prompt_tokens", 0)
253
+ model_usage[model]["total_tokens"] += metrics["usage"].get("total_tokens", 0)
254
+
255
+ if model_name:
256
+ return model_usage.get(model_name, {
257
+ "calls": 0,
258
+ "completion_tokens": 0,
259
+ "prompt_tokens": 0,
260
+ "total_tokens": 0
261
+ })
262
+
263
+ return model_usage
264
+
265
+ def get_message_timing_stats(self) -> Dict[str, Any]:
266
+ """Calculate timing statistics across all messages
267
+
268
+ Returns:
269
+ Dictionary containing:
270
+ - total_latency: Total processing time across all messages (in milliseconds)
271
+ - average_latency: Average processing time per message (in milliseconds)
272
+ - message_count: Total number of messages with timing data
273
+ """
274
+ total_latency = 0
275
+ message_count = 0
276
+
277
+ for message in self.messages:
278
+ if message.metrics and message.metrics.get("timing", {}).get("latency"):
279
+ total_latency += message.metrics["timing"]["latency"]
280
+ message_count += 1
281
+
282
+ return {
283
+ "total_latency": total_latency,
284
+ "average_latency": total_latency / message_count if message_count > 0 else 0,
285
+ "message_count": message_count
286
+ }
287
+
288
+ def get_message_counts(self) -> Dict[str, int]:
289
+ """Get count of messages by role
290
+
291
+ Returns:
292
+ Dictionary with counts for each role (system, user, assistant, tool)
293
+ """
294
+ counts = {
295
+ "system": 0,
296
+ "user": 0,
297
+ "assistant": 0,
298
+ "tool": 0
299
+ }
300
+
301
+ for message in self.messages:
302
+ counts[message.role] += 1
303
+
304
+ return counts
305
+
306
+ def get_tool_usage(self) -> Dict[str, Any]:
307
+ """Get count of tool function calls in the thread
308
+
309
+ Returns:
310
+ Dictionary containing:
311
+ - tools: Dictionary of tool names and their call counts
312
+ - total_calls: Total number of tool calls made
313
+ """
314
+ tool_counts = {} # {"tool_name": count}
315
+
316
+ for message in self.messages:
317
+ if message.role == "assistant" and message.tool_calls:
318
+ for call in message.tool_calls:
319
+ if isinstance(call, dict):
320
+ tool_name = call.get("function", {}).get("name")
321
+ else:
322
+ # Handle OpenAI tool call objects
323
+ tool_name = getattr(call.function, "name", None)
324
+
325
+ if tool_name:
326
+ tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1
327
+
328
+ return {
329
+ "tools": tool_counts,
330
+ "total_calls": sum(tool_counts.values())
331
+ }
332
+
333
+ def get_system_message(self) -> Optional[Message]:
334
+ """Get the system message from the thread if it exists"""
335
+ for message in self.messages:
336
+ if message.role == "system":
337
+ return message
338
+ return None
339
+
340
+ def get_messages_in_sequence(self) -> List[Message]:
341
+ """Get messages sorted by sequence number"""
342
+ return sorted(self.messages, key=lambda m: m.sequence if m.sequence is not None else float('inf'))
343
+
344
+ def get_messages_by_turn(self, turn: int) -> List[Message]:
345
+ """Get all messages in a specific turn
346
+
347
+ Args:
348
+ turn: The turn number to retrieve messages for
349
+
350
+ Returns:
351
+ List of messages in the specified turn, sorted by sequence
352
+ """
353
+ turn_messages = [m for m in self.messages if m.turn == turn]
354
+ return sorted(turn_messages, key=lambda m: m.sequence if m.sequence is not None else float('inf'))
355
+
356
+ def get_current_turn(self) -> int:
357
+ """Get the current turn number (highest turn number in the thread)
358
+
359
+ Returns:
360
+ Current turn number, or 0 if no messages
361
+ """
362
+ if not self.messages:
363
+ return 0
364
+
365
+ # Exclude system messages when determining current turn
366
+ non_system_messages = [m for m in self.messages if m.role != "system"]
367
+ if not non_system_messages:
368
+ return 0
369
+
370
+ return max(m.turn for m in non_system_messages if m.turn is not None)
371
+
372
+ def get_turns_summary(self) -> Dict[int, Dict[str, Any]]:
373
+ """Get a summary of all turns in the thread
374
+
375
+ Returns:
376
+ Dictionary mapping turn numbers to turn information
377
+ """
378
+ turns = {}
379
+
380
+ for message in self.messages:
381
+ if message.turn is None or message.role == "system":
382
+ continue
383
+
384
+ turn = message.turn
385
+ if turn not in turns:
386
+ turns[turn] = {
387
+ "turn": turn,
388
+ "message_count": 0,
389
+ "roles": {},
390
+ "first_message_time": message.timestamp,
391
+ "last_message_time": message.timestamp
392
+ }
393
+
394
+ turns[turn]["message_count"] += 1
395
+ turns[turn]["roles"][message.role] = turns[turn]["roles"].get(message.role, 0) + 1
396
+
397
+ # Update timestamps
398
+ if message.timestamp < turns[turn]["first_message_time"]:
399
+ turns[turn]["first_message_time"] = message.timestamp
400
+ if message.timestamp > turns[turn]["last_message_time"]:
401
+ turns[turn]["last_message_time"] = message.timestamp
402
+
403
+ return turns
404
+
405
+ def get_message_by_id(self, message_id: str) -> Optional[Message]:
406
+ """Return the message with the specified ID, or None if no message exists with that ID"""
407
+ for message in self.messages:
408
+ if message.id == message_id:
409
+ return message
410
+ return None
411
+
412
+ def add_reaction(self, message_id: str, emoji: str, user_id: str) -> bool:
413
+ """Add a reaction to a message in the thread
414
+
415
+ Args:
416
+ message_id: ID of the message to react to
417
+ emoji: Emoji shortcode (e.g., ":thumbsup:")
418
+ user_id: ID of the user adding the reaction
419
+
420
+ Returns:
421
+ True if reaction was added, False if it wasn't (message not found or already reacted)
422
+ """
423
+ message = self.get_message_by_id(message_id)
424
+ if not message:
425
+ logger.warning(f"Thread.add_reaction (thread_id={self.id}): Message with ID '{message_id}' not found.")
426
+ return False
427
+
428
+ result = message.add_reaction(emoji, user_id)
429
+ if result:
430
+ self.updated_at = datetime.now(UTC) # Ensure thread update time is changed
431
+ logger.info(f"Thread.add_reaction (thread_id={self.id}): Message '{message_id}' reactions updated. Thread updated_at: {self.updated_at}")
432
+ return result
433
+
434
+ def remove_reaction(self, message_id: str, emoji: str, user_id: str) -> bool:
435
+ """Remove a reaction from a message in the thread
436
+
437
+ Args:
438
+ message_id: ID of the message to remove reaction from
439
+ emoji: Emoji shortcode (e.g., ":thumbsup:")
440
+ user_id: ID of the user removing the reaction
441
+
442
+ Returns:
443
+ True if reaction was removed, False if it wasn't (message or reaction not found)
444
+ """
445
+ message = self.get_message_by_id(message_id)
446
+ if not message:
447
+ logger.warning(f"Thread.remove_reaction (thread_id={self.id}): Message with ID '{message_id}' not found.")
448
+ return False
449
+
450
+ result = message.remove_reaction(emoji, user_id)
451
+ if result:
452
+ self.updated_at = datetime.now(UTC) # Ensure thread update time is changed
453
+ logger.info(f"Thread.remove_reaction (thread_id={self.id}): Message '{message_id}' reactions updated. Thread updated_at: {self.updated_at}")
454
+ return result
455
+
456
+ def get_reactions(self, message_id: str) -> Dict[str, List[str]]:
457
+ """Get all reactions for a message in the thread
458
+
459
+ Args:
460
+ message_id: ID of the message to get reactions for
461
+
462
+ Returns:
463
+ Dictionary mapping emoji to list of user IDs, or empty dict if message not found
464
+ """
465
+ message = self.get_message_by_id(message_id)
466
+ if not message:
467
+ return {}
468
+
469
+ return message.get_reactions()
@@ -0,0 +1,7 @@
1
+ """
2
+ Storage package for Tyler Stores
3
+ """
4
+
5
+ from .file_store import FileStore
6
+
7
+ __all__ = ["FileStore"]