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,512 @@
1
+ from typing import Dict, Optional, Literal, Any, Union, List
2
+ from typing_extensions import TypedDict
3
+ from datetime import datetime, timezone
4
+ from pydantic import BaseModel, Field, field_validator, model_validator
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ import base64
9
+ # Direct imports
10
+ from narrator.models.attachment import Attachment
11
+ from narrator.storage.file_store import FileStore
12
+
13
+ class ImageUrl(TypedDict):
14
+ url: str
15
+
16
+ class ImageContent(TypedDict):
17
+ type: Literal["image_url"]
18
+ image_url: ImageUrl
19
+
20
+ class TextContent(TypedDict):
21
+ type: Literal["text"]
22
+ text: str
23
+
24
+ class EntitySource(TypedDict, total=False):
25
+ id: str # Unique identifier for the entity
26
+ name: str # Human-readable name of the entity
27
+ type: Literal["user", "agent", "tool"] # Type of entity
28
+ attributes: Optional[Dict[str, Any]] # All other entity-specific attributes
29
+
30
+ class Message(BaseModel):
31
+ """Represents a single message in a thread"""
32
+ id: str = None # Will be set in __init__
33
+ role: Literal["system", "user", "assistant", "tool"]
34
+ sequence: Optional[int] = Field(
35
+ default=None,
36
+ description="Message sequence number within thread. System messages get lowest sequences."
37
+ )
38
+ turn: Optional[int] = Field(
39
+ default=None,
40
+ description="Turn number grouping related messages in the same conversational step."
41
+ )
42
+ content: Optional[Union[str, List[Union[TextContent, ImageContent]]]] = None
43
+ reasoning_content: Optional[str] = Field(
44
+ default=None,
45
+ description="Model's reasoning/thinking process for models that support it (e.g., OpenAI o1, Anthropic Claude)"
46
+ )
47
+ name: Optional[str] = None
48
+ tool_call_id: Optional[str] = None # Required for tool messages
49
+ tool_calls: Optional[list] = None # For assistant messages
50
+ attributes: Dict = Field(default_factory=dict)
51
+ timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
52
+ source: Optional[EntitySource] = None # Creator information (who created this message)
53
+ platforms: Dict[str, Dict[str, str]] = Field(
54
+ default_factory=dict,
55
+ description="References to where this message exists on external platforms. Maps platform name to platform-specific identifiers."
56
+ )
57
+ attachments: List[Attachment] = Field(default_factory=list)
58
+ reactions: Dict[str, List[str]] = Field(
59
+ default_factory=dict,
60
+ description="Map of emoji to list of user IDs who reacted with that emoji"
61
+ )
62
+
63
+ # Simple metrics structure
64
+ metrics: Dict[str, Any] = Field(
65
+ default_factory=lambda: {
66
+ "model": None,
67
+ "timing": {
68
+ "started_at": None,
69
+ "ended_at": None,
70
+ "latency": 0 # in milliseconds
71
+ },
72
+ "usage": {
73
+ "completion_tokens": 0,
74
+ "prompt_tokens": 0,
75
+ "total_tokens": 0
76
+ },
77
+ "weave_call": {
78
+ "id": "",
79
+ "ui_url": ""
80
+ }
81
+ }
82
+ )
83
+
84
+ @field_validator("timestamp", mode="before")
85
+ def ensure_timezone(cls, value: datetime) -> datetime:
86
+ """Ensure timestamp is timezone-aware UTC"""
87
+ if value.tzinfo is None:
88
+ return value.replace(tzinfo=timezone.utc)
89
+ return value
90
+
91
+ @field_validator("role")
92
+ def validate_role(cls, v):
93
+ """Validate role field"""
94
+ if v not in ["system", "user", "assistant", "tool"]:
95
+ raise ValueError("Invalid role. Must be one of: system, user, assistant, tool")
96
+ return v
97
+
98
+ @model_validator(mode='after')
99
+ def validate_tool_message(self):
100
+ """Validate tool message requirements"""
101
+ if self.role == "tool" and not self.tool_call_id:
102
+ raise ValueError("tool_call_id is required for tool messages")
103
+ return self
104
+
105
+ @field_validator("tool_calls")
106
+ def validate_tool_calls(cls, v, info):
107
+ """Validate tool_calls field"""
108
+ if v is not None:
109
+ for tool_call in v:
110
+ if not isinstance(tool_call, dict):
111
+ raise ValueError("Each tool call must be a dictionary")
112
+ if "id" not in tool_call or "type" not in tool_call or "function" not in tool_call:
113
+ raise ValueError("Tool calls must have id, type, and function fields")
114
+ if not isinstance(tool_call["function"], dict):
115
+ raise ValueError("Tool call function must be a dictionary")
116
+ if "name" not in tool_call["function"] or "arguments" not in tool_call["function"]:
117
+ raise ValueError("Tool call function must have name and arguments fields")
118
+ return v
119
+
120
+ @field_validator("source")
121
+ def validate_source(cls, v):
122
+ """Validate source field structure"""
123
+ if v is not None:
124
+ # Check if type field is present and valid
125
+ if "type" in v and v["type"] not in ["user", "agent", "tool"]:
126
+ raise ValueError("source.type must be one of: user, agent, tool")
127
+
128
+ # Ensure ID is present
129
+ if "id" not in v:
130
+ raise ValueError("source.id is required when source is present")
131
+
132
+ return v
133
+
134
+ def __init__(self, **data):
135
+ # Handle file content if provided as raw bytes
136
+ if "file_content" in data and "filename" in data:
137
+ if "attachments" not in data:
138
+ data["attachments"] = []
139
+ data["attachments"].append(Attachment(
140
+ filename=data.pop("filename"),
141
+ content=data.pop("file_content")
142
+ ))
143
+
144
+ super().__init__(**data)
145
+ if not self.id:
146
+ # Create a hash of relevant properties
147
+ hash_content = {
148
+ "role": self.role,
149
+ "sequence": self.sequence, # Include sequence in hash
150
+ "turn": self.turn, # Include turn in hash
151
+ "content": self.content,
152
+ "timestamp": self.timestamp.isoformat()
153
+ }
154
+ # Include name for function messages
155
+ if self.name and self.role == "tool":
156
+ hash_content["name"] = self.name
157
+
158
+ if self.source:
159
+ hash_content["source"] = self.source
160
+
161
+ # Create deterministic JSON string for hashing
162
+ hash_str = json.dumps(hash_content, sort_keys=True)
163
+ self.id = hashlib.sha256(hash_str.encode()).hexdigest()
164
+ logging.getLogger(__name__).debug(f"Generated message ID {self.id} from hash content: {hash_str}")
165
+
166
+ def _serialize_tool_calls(self, tool_calls):
167
+ """Helper method to serialize tool calls into a JSON-friendly format"""
168
+ if not tool_calls:
169
+ return None
170
+
171
+ serialized_calls = []
172
+ for call in tool_calls:
173
+ try:
174
+ # Handle OpenAI response objects
175
+ if hasattr(call, 'model_dump'):
176
+ # For newer Pydantic models
177
+ call_dict = call.model_dump()
178
+ elif hasattr(call, 'to_dict'):
179
+ # For objects with to_dict method
180
+ call_dict = call.to_dict()
181
+ elif hasattr(call, 'id') and hasattr(call, 'function'):
182
+ # Direct access to OpenAI tool call attributes
183
+ call_dict = {
184
+ "id": call.id,
185
+ "type": getattr(call, 'type', 'function'),
186
+ "function": {
187
+ "name": call.function.name,
188
+ "arguments": call.function.arguments
189
+ }
190
+ }
191
+ elif isinstance(call, dict):
192
+ # If it's already a dict, ensure it has the required structure
193
+ call_dict = {
194
+ "id": call.get("id"),
195
+ "type": call.get("type", "function"),
196
+ "function": {
197
+ "name": call.get("function", {}).get("name"),
198
+ "arguments": call.get("function", {}).get("arguments")
199
+ }
200
+ }
201
+ else:
202
+ logging.getLogger(__name__).warning(f"Unsupported tool call format: {type(call)}")
203
+ continue
204
+
205
+ # Validate the required fields are present
206
+ if all(key in call_dict for key in ["id", "type", "function"]):
207
+ serialized_calls.append(call_dict)
208
+ else:
209
+ logging.getLogger(__name__).warning(f"Missing required fields in tool call: {call_dict}")
210
+ except Exception as e:
211
+ logging.getLogger(__name__).error(f"Error serializing tool call: {str(e)}")
212
+ continue
213
+
214
+ return serialized_calls
215
+
216
+ def model_dump(self, mode: str = "json") -> Dict[str, Any]:
217
+ """Convert message to a dictionary suitable for JSON serialization
218
+
219
+ Args:
220
+ mode: Serialization mode, either "json" or "python".
221
+ "json" converts datetimes to ISO strings (default).
222
+ "python" keeps datetimes as datetime objects.
223
+ """
224
+ message_dict = {
225
+ "id": self.id,
226
+ "role": self.role,
227
+ "sequence": self.sequence, # Include sequence in serialization
228
+ "turn": self.turn, # Include turn in serialization
229
+ "content": self.content,
230
+ "timestamp": self.timestamp.isoformat() if mode == "json" else self.timestamp,
231
+ "source": self.source,
232
+ "platforms": self.platforms,
233
+ "metrics": self.metrics,
234
+ "reactions": self.reactions
235
+ }
236
+
237
+ if self.reasoning_content:
238
+ message_dict["reasoning_content"] = self.reasoning_content
239
+
240
+ if self.name:
241
+ message_dict["name"] = self.name
242
+
243
+ if self.tool_call_id:
244
+ message_dict["tool_call_id"] = self.tool_call_id
245
+
246
+ if self.tool_calls:
247
+ message_dict["tool_calls"] = self._serialize_tool_calls(self.tool_calls)
248
+
249
+ if self.attributes:
250
+ message_dict["attributes"] = self.attributes
251
+
252
+ if self.attachments:
253
+ message_dict["attachments"] = []
254
+ for attachment in self.attachments:
255
+ # Ensure content is properly serialized
256
+ attachment_dict = attachment.model_dump(mode=mode) if hasattr(attachment, 'model_dump') else {
257
+ "filename": attachment.filename,
258
+ "mime_type": attachment.mime_type,
259
+ "file_id": attachment.file_id,
260
+ "storage_path": attachment.storage_path,
261
+ "storage_backend": attachment.storage_backend,
262
+ "status": attachment.status
263
+ }
264
+
265
+ # Remove content field if present to avoid large data serialization
266
+ if "content" in attachment_dict:
267
+ del attachment_dict["content"]
268
+
269
+ # Add processed content if available
270
+ if attachment.attributes:
271
+ attachment_dict["attributes"] = attachment.attributes
272
+
273
+ # Add to attachments list
274
+ message_dict["attachments"].append(attachment_dict)
275
+
276
+ return message_dict
277
+
278
+ def to_chat_completion_message(self, file_store: Optional[FileStore] = None) -> Dict[str, Any]:
279
+ """Return message in the format expected by chat completion APIs
280
+
281
+ Args:
282
+ file_store: Optional FileStore instance for accessing file URLs
283
+ """
284
+ base_content = self.content if isinstance(self.content, str) else ""
285
+
286
+ message_dict = {
287
+ "role": self.role,
288
+ "content": base_content,
289
+ "sequence": self.sequence
290
+ }
291
+
292
+ if self.name:
293
+ message_dict["name"] = self.name
294
+
295
+ if self.role == "assistant" and self.tool_calls:
296
+ message_dict["tool_calls"] = self.tool_calls
297
+
298
+ if self.role == "tool" and self.tool_call_id:
299
+ message_dict["tool_call_id"] = self.tool_call_id
300
+
301
+ # Handle attachments if we have them
302
+ if self.attachments:
303
+ # Get file references for all attachments
304
+ file_references = []
305
+ for attachment in self.attachments:
306
+ if not attachment.storage_path:
307
+ continue
308
+
309
+ # Get the URL from attributes if available, otherwise construct it
310
+ file_url = attachment.attributes.get("url") if attachment.attributes else None
311
+
312
+ if not file_url and attachment.storage_path:
313
+ # Construct URL from storage path
314
+ file_url = FileStore.get_file_url(attachment.storage_path)
315
+
316
+ # Simplified file reference format
317
+ file_ref = f"[File: {file_url} ({attachment.mime_type})]"
318
+ file_references.append(file_ref)
319
+
320
+ # Add file references to content based on message role
321
+ if file_references:
322
+ if self.role == "user" or self.role == "tool":
323
+ # For user and tool messages, add file references directly
324
+ if message_dict["content"]:
325
+ message_dict["content"] += "\n\n" + "\n".join(file_references)
326
+ else:
327
+ message_dict["content"] = "\n".join(file_references)
328
+ elif self.role == "assistant":
329
+ # For assistant messages, add a header
330
+ if message_dict["content"]:
331
+ message_dict["content"] += "\n\nGenerated Files:\n" + "\n".join(file_references)
332
+ else:
333
+ message_dict["content"] = "Generated Files:\n" + "\n".join(file_references)
334
+
335
+ return message_dict
336
+
337
+ def add_attachment(self, attachment: Union[Attachment, bytes], filename: Optional[str] = None) -> None:
338
+ """Add an attachment to the message.
339
+
340
+ Args:
341
+ attachment: Either an Attachment object or raw bytes
342
+ filename: Required if attachment is bytes, ignored if attachment is Attachment
343
+
344
+ Raises:
345
+ ValueError: If attachment is bytes and filename is not provided
346
+ """
347
+ if isinstance(attachment, Attachment):
348
+ self.attachments.append(attachment)
349
+ elif isinstance(attachment, bytes):
350
+ if not filename:
351
+ raise ValueError("filename is required when adding raw bytes as attachment")
352
+ att = Attachment(
353
+ filename=filename,
354
+ content=attachment
355
+ )
356
+ self.attachments.append(att)
357
+ else:
358
+ raise ValueError("attachment must be either Attachment object or bytes")
359
+
360
+ def add_reaction(self, emoji: str, user_id: str) -> bool:
361
+ """Add a reaction to a message.
362
+
363
+ Args:
364
+ emoji: Emoji shortcode (e.g., ":thumbsup:")
365
+ user_id: ID of the user adding the reaction
366
+
367
+ Returns:
368
+ True if reaction was added, False if it already existed
369
+ """
370
+ logging.getLogger(__name__).info(f"Message.add_reaction (msg_id={self.id}): Current reactions: {self.reactions}. Adding '{emoji}' for user '{user_id}'.")
371
+ if emoji not in self.reactions:
372
+ self.reactions[emoji] = []
373
+
374
+ if user_id in self.reactions[emoji]:
375
+ logging.getLogger(__name__).warning(f"Message.add_reaction (msg_id={self.id}): User '{user_id}' already reacted with '{emoji}'.")
376
+ return False # Indicate that reaction was not newly added because it already existed
377
+
378
+ self.reactions[emoji].append(user_id)
379
+ logging.getLogger(__name__).info(f"Message.add_reaction (msg_id={self.id}): Successfully added. Reactions now: {self.reactions}")
380
+ return True
381
+
382
+ def remove_reaction(self, emoji: str, user_id: str) -> bool:
383
+ """Remove a reaction from a message.
384
+
385
+ Args:
386
+ emoji: Emoji shortcode (e.g., ":thumbsup:")
387
+ user_id: ID of the user removing the reaction
388
+
389
+ Returns:
390
+ True if reaction was removed, False if it didn't exist
391
+ """
392
+ logging.getLogger(__name__).info(f"Message.remove_reaction (msg_id={self.id}): Current reactions: {self.reactions}. Removing '{emoji}' for user '{user_id}'.")
393
+ if emoji not in self.reactions or user_id not in self.reactions[emoji]:
394
+ logging.getLogger(__name__).warning(f"Message.remove_reaction (msg_id={self.id}): Emoji '{emoji}' or user '{user_id}' not found in reactions {self.reactions}.")
395
+ return False
396
+
397
+ self.reactions[emoji].remove(user_id)
398
+
399
+ # Clean up empty reactions
400
+ if not self.reactions[emoji]:
401
+ del self.reactions[emoji]
402
+
403
+ logging.getLogger(__name__).info(f"Message.remove_reaction (msg_id={self.id}): Successfully removed. Reactions now: {self.reactions}")
404
+ return True
405
+
406
+ def get_reactions(self) -> Dict[str, List[str]]:
407
+ """Get all reactions for this message.
408
+
409
+ Returns:
410
+ Dictionary mapping emoji to list of user IDs
411
+ """
412
+ return self.reactions
413
+
414
+ def get_reaction_counts(self) -> Dict[str, int]:
415
+ """Get counts of reactions for this message.
416
+
417
+ Returns:
418
+ Dictionary mapping emoji to count of reactions
419
+ """
420
+ return {emoji: len(users) for emoji, users in self.reactions.items()}
421
+
422
+ model_config = {
423
+ "json_schema_extra": {
424
+ "examples": [
425
+ {
426
+ "id": "123e4567-e89b-12d3-a456-426614174000",
427
+ "role": "user",
428
+ "sequence": 1,
429
+ "turn": 1,
430
+ "content": "Here are some files to look at",
431
+ "name": None,
432
+ "tool_call_id": None,
433
+ "tool_calls": None,
434
+ "attributes": {},
435
+ "timestamp": "2024-02-07T00:00:00+00:00",
436
+ "source": {
437
+ "entity": {
438
+ "id": "U123456",
439
+ "name": "John Doe",
440
+ "type": "user",
441
+ "attributes": {
442
+ "email": "john.doe@example.com",
443
+ "user_id": "U123456"
444
+ }
445
+ },
446
+ "platform": {
447
+ "name": "slack",
448
+ "attributes": {
449
+ "thread_ts": "1234567890.123456",
450
+ "channel_id": "C123456",
451
+ "team_id": "T123456"
452
+ }
453
+ }
454
+ },
455
+ "attachments": [
456
+ {
457
+ "filename": "example.txt",
458
+ "mime_type": "text/plain",
459
+ "attributes": {
460
+ "type": "text",
461
+ "text": "Example content",
462
+ "url": "/files/example.txt"
463
+ },
464
+ "status": "stored"
465
+ },
466
+ {
467
+ "filename": "example.pdf",
468
+ "mime_type": "application/pdf",
469
+ "attributes": {
470
+ "type": "document",
471
+ "text": "Extracted text from PDF",
472
+ "url": "/files/example.pdf"
473
+ },
474
+ "status": "stored"
475
+ },
476
+ {
477
+ "filename": "example.jpg",
478
+ "mime_type": "image/jpeg",
479
+ "attributes": {
480
+ "type": "image",
481
+ "url": "/files/example.jpg"
482
+ },
483
+ "status": "stored"
484
+ }
485
+ ],
486
+ "metrics": {
487
+ "model": "gpt-4.1",
488
+ "timing": {
489
+ "started_at": "2024-02-07T00:00:00+00:00",
490
+ "ended_at": "2024-02-07T00:00:01+00:00",
491
+ "latency": 1.0
492
+ },
493
+ "usage": {
494
+ "completion_tokens": 100,
495
+ "prompt_tokens": 50,
496
+ "total_tokens": 150
497
+ },
498
+ "weave_call": {
499
+ "id": "call-123",
500
+ "ui_url": "https://weave.ui/call-123"
501
+ }
502
+ },
503
+ "reactions": {
504
+ ":thumbsup:": ["U123456", "U234567"],
505
+ ":heart:": ["U123456"]
506
+ }
507
+ }
508
+ ]
509
+ },
510
+ "extra": "forbid",
511
+ "validate_assignment": True
512
+ }