mcal-ai 0.1.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.
mcal/core/streaming.py ADDED
@@ -0,0 +1,254 @@
1
+ """
2
+ Streaming Response API for MCAL
3
+
4
+ Provides streaming support for the add() operation, yielding partial results
5
+ as they become available. This improves perceived latency by showing progress
6
+ immediately rather than waiting for complete extraction.
7
+
8
+ Issue #10: Streaming Response API
9
+
10
+ Key benefits:
11
+ - User sees progress immediately (not 22s blank screen)
12
+ - Partial results usable before completion
13
+ - Better UX for demo and production use
14
+
15
+ Usage:
16
+ async for event in mcal.add_stream(messages, user_id):
17
+ if event.type == StreamEventType.INTENT_EXTRACTED:
18
+ print(f"Found intent: {event.data.content}")
19
+ elif event.type == StreamEventType.DECISION_EXTRACTED:
20
+ print(f"Found decision: {event.data.decision}")
21
+ elif event.type == StreamEventType.COMPLETE:
22
+ print(f"Total time: {event.data.timing.total_ms}ms")
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass, field
28
+ from datetime import datetime
29
+ from enum import Enum
30
+ from typing import Any, Optional, Union
31
+
32
+ from .models import IntentNode, IntentGraph, DecisionTrail
33
+
34
+
35
+ class StreamEventType(str, Enum):
36
+ """Types of events emitted during streaming extraction."""
37
+
38
+ # Progress events
39
+ STARTED = "started" # Extraction started
40
+ PHASE_STARTED = "phase_started" # New phase (facts/intents/decisions)
41
+ PHASE_COMPLETE = "phase_complete" # Phase finished
42
+
43
+ # Partial result events
44
+ FACT_EXTRACTED = "fact_extracted" # Single fact from Mem0
45
+ INTENT_EXTRACTED = "intent_extracted" # Single intent node extracted
46
+ INTENT_UPDATED = "intent_updated" # Existing intent updated
47
+ DECISION_EXTRACTED = "decision_extracted" # Single decision extracted
48
+
49
+ # Status events
50
+ CACHE_HIT = "cache_hit" # Results from cache
51
+ ERROR = "error" # Non-fatal error
52
+
53
+ # Completion events
54
+ COMPLETE = "complete" # All extraction finished
55
+
56
+
57
+ class ExtractionPhase(str, Enum):
58
+ """Phases of the extraction process."""
59
+ FACTS = "facts"
60
+ INTENTS = "intents"
61
+ DECISIONS = "decisions"
62
+
63
+
64
+ @dataclass
65
+ class StreamProgress:
66
+ """Progress information for streaming updates."""
67
+ phase: ExtractionPhase
68
+ current: int = 0
69
+ total: Optional[int] = None
70
+ elapsed_ms: int = 0
71
+ message: str = ""
72
+
73
+
74
+ @dataclass
75
+ class CacheHitInfo:
76
+ """Information about a cache hit."""
77
+ hit_type: str # "full" or "partial"
78
+ messages_cached: int
79
+ messages_to_process: int
80
+ saved_time_ms: int
81
+
82
+
83
+ @dataclass
84
+ class PhaseResult:
85
+ """Result from a completed phase."""
86
+ phase: ExtractionPhase
87
+ items_count: int
88
+ duration_ms: int
89
+
90
+
91
+ @dataclass
92
+ class StreamEvent:
93
+ """
94
+ A single event in the streaming extraction process.
95
+
96
+ Events are yielded as extraction progresses, allowing clients to:
97
+ - Show real-time progress
98
+ - Display partial results immediately
99
+ - Track timing per phase
100
+
101
+ Attributes:
102
+ type: The type of event
103
+ timestamp: When the event occurred
104
+ data: Event-specific payload (varies by type)
105
+ progress: Optional progress information
106
+ """
107
+ type: StreamEventType
108
+ timestamp: datetime = field(default_factory=datetime.now)
109
+ data: Optional[Union[
110
+ IntentNode,
111
+ DecisionTrail,
112
+ dict, # For facts (MemoryEntry dict)
113
+ PhaseResult,
114
+ CacheHitInfo,
115
+ Any, # For COMPLETE event (AddResult from mcal module)
116
+ str, # For errors
117
+ ]] = None
118
+ progress: Optional[StreamProgress] = None
119
+
120
+ def to_dict(self) -> dict:
121
+ """Convert to dictionary for JSON serialization."""
122
+ result = {
123
+ "type": self.type.value,
124
+ "timestamp": self.timestamp.isoformat(),
125
+ }
126
+
127
+ if self.data is not None:
128
+ if hasattr(self.data, "model_dump"):
129
+ result["data"] = self.data.model_dump()
130
+ elif hasattr(self.data, "__dict__"):
131
+ result["data"] = self.data.__dict__
132
+ elif isinstance(self.data, dict):
133
+ result["data"] = self.data
134
+ else:
135
+ result["data"] = str(self.data)
136
+
137
+ if self.progress is not None:
138
+ result["progress"] = {
139
+ "phase": self.progress.phase.value,
140
+ "current": self.progress.current,
141
+ "total": self.progress.total,
142
+ "elapsed_ms": self.progress.elapsed_ms,
143
+ "message": self.progress.message,
144
+ }
145
+
146
+ return result
147
+
148
+
149
+ # =============================================================================
150
+ # Stream Builder Helpers
151
+ # =============================================================================
152
+
153
+ def event_started() -> StreamEvent:
154
+ """Create a STARTED event."""
155
+ return StreamEvent(type=StreamEventType.STARTED)
156
+
157
+
158
+ def event_phase_started(phase: ExtractionPhase, message: str = "") -> StreamEvent:
159
+ """Create a PHASE_STARTED event."""
160
+ return StreamEvent(
161
+ type=StreamEventType.PHASE_STARTED,
162
+ progress=StreamProgress(phase=phase, message=message)
163
+ )
164
+
165
+
166
+ def event_phase_complete(
167
+ phase: ExtractionPhase,
168
+ items_count: int,
169
+ duration_ms: int
170
+ ) -> StreamEvent:
171
+ """Create a PHASE_COMPLETE event."""
172
+ return StreamEvent(
173
+ type=StreamEventType.PHASE_COMPLETE,
174
+ data=PhaseResult(phase=phase, items_count=items_count, duration_ms=duration_ms)
175
+ )
176
+
177
+
178
+ def event_fact_extracted(fact: dict, progress: Optional[StreamProgress] = None) -> StreamEvent:
179
+ """Create a FACT_EXTRACTED event."""
180
+ return StreamEvent(
181
+ type=StreamEventType.FACT_EXTRACTED,
182
+ data=fact,
183
+ progress=progress
184
+ )
185
+
186
+
187
+ def event_intent_extracted(
188
+ intent: IntentNode,
189
+ progress: Optional[StreamProgress] = None
190
+ ) -> StreamEvent:
191
+ """Create an INTENT_EXTRACTED event."""
192
+ return StreamEvent(
193
+ type=StreamEventType.INTENT_EXTRACTED,
194
+ data=intent,
195
+ progress=progress
196
+ )
197
+
198
+
199
+ def event_intent_updated(
200
+ intent: IntentNode,
201
+ progress: Optional[StreamProgress] = None
202
+ ) -> StreamEvent:
203
+ """Create an INTENT_UPDATED event."""
204
+ return StreamEvent(
205
+ type=StreamEventType.INTENT_UPDATED,
206
+ data=intent,
207
+ progress=progress
208
+ )
209
+
210
+
211
+ def event_decision_extracted(
212
+ decision: DecisionTrail,
213
+ progress: Optional[StreamProgress] = None
214
+ ) -> StreamEvent:
215
+ """Create a DECISION_EXTRACTED event."""
216
+ return StreamEvent(
217
+ type=StreamEventType.DECISION_EXTRACTED,
218
+ data=decision,
219
+ progress=progress
220
+ )
221
+
222
+
223
+ def event_cache_hit(
224
+ hit_type: str,
225
+ messages_cached: int,
226
+ messages_to_process: int,
227
+ saved_time_ms: int
228
+ ) -> StreamEvent:
229
+ """Create a CACHE_HIT event."""
230
+ return StreamEvent(
231
+ type=StreamEventType.CACHE_HIT,
232
+ data=CacheHitInfo(
233
+ hit_type=hit_type,
234
+ messages_cached=messages_cached,
235
+ messages_to_process=messages_to_process,
236
+ saved_time_ms=saved_time_ms
237
+ )
238
+ )
239
+
240
+
241
+ def event_error(message: str) -> StreamEvent:
242
+ """Create an ERROR event."""
243
+ return StreamEvent(
244
+ type=StreamEventType.ERROR,
245
+ data=message
246
+ )
247
+
248
+
249
+ def event_complete(result: Any) -> StreamEvent:
250
+ """Create a COMPLETE event with final results."""
251
+ return StreamEvent(
252
+ type=StreamEventType.COMPLETE,
253
+ data=result
254
+ )