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