raindrop-ai 0.0.19__tar.gz → 0.0.21__tar.gz

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,41 @@
1
+ Metadata-Version: 2.1
2
+ Name: raindrop-ai
3
+ Version: 0.0.21
4
+ Summary: Raindrop AI (Python SDK)
5
+ License: MIT
6
+ Author: Raindrop AI
7
+ Author-email: sdk@raindrop.ai
8
+ Requires-Python: >=3.11, !=2.7.*, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*, !=3.7.*, !=3.8.*, !=3.9.*, !=3.10.*
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Dist: pydantic (>=2.11,<3)
14
+ Requires-Dist: requests (>=2.32.3,<3.0.0)
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Raindrop Python SDK
18
+
19
+ ## Installation dependencies
20
+
21
+
22
+ ```bash
23
+ pip install poetry
24
+ ```
25
+
26
+ ```bash
27
+ poetry install
28
+ ```
29
+
30
+
31
+ ## Run tests
32
+
33
+ ```bash
34
+ poetry run green -vv
35
+ ```
36
+
37
+
38
+
39
+
40
+
41
+
@@ -0,0 +1,24 @@
1
+ # Raindrop Python SDK
2
+
3
+ ## Installation dependencies
4
+
5
+
6
+ ```bash
7
+ pip install poetry
8
+ ```
9
+
10
+ ```bash
11
+ poetry install
12
+ ```
13
+
14
+
15
+ ## Run tests
16
+
17
+ ```bash
18
+ poetry run green -vv
19
+ ```
20
+
21
+
22
+
23
+
24
+
@@ -0,0 +1,21 @@
1
+ [tool.poetry]
2
+ name = "raindrop-ai"
3
+ version = "0.0.21"
4
+ description = "Raindrop AI (Python SDK)"
5
+ authors = ["Raindrop AI <sdk@raindrop.ai>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ packages = [{include = "raindrop"}]
9
+
10
+ [tool.poetry.dependencies]
11
+ python = ">=3.11,<3.12.1 || >3.12.1,<4.0"
12
+ pydantic = ">=2.11,<3"
13
+ requests = "^2.32.3"
14
+
15
+
16
+ [tool.poetry.group.dev.dependencies]
17
+ green = "^4.0.2"
18
+
19
+ [build-system]
20
+ requires = ["poetry-core"]
21
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,405 @@
1
+ import sys
2
+ import time
3
+ import threading
4
+ from typing import Union, List, Dict, Optional, Literal, Any
5
+ import requests
6
+ from datetime import datetime, timezone
7
+ import logging
8
+ import json
9
+ import uuid
10
+ import atexit
11
+ from pydantic import ValidationError
12
+ from threading import Timer
13
+ from raindrop.version import VERSION
14
+ from raindrop.models import TrackAIEvent, Attachment, SignalEvent, DefaultSignal, FeedbackSignal, EditSignal, PartialTrackAIEvent, PartialAIData
15
+ from raindrop.interaction import Interaction
16
+
17
+
18
+
19
+ # Configure logging
20
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
21
+ logger = logging.getLogger(__name__)
22
+
23
+ write_key = None
24
+ api_url = "https://api.raindrop.ai/v1/"
25
+ max_queue_size = 10000
26
+ upload_size = 10
27
+ upload_interval = 1.0
28
+ buffer = []
29
+ flush_lock = threading.Lock()
30
+ debug_logs = False
31
+ flush_thread = None
32
+ shutdown_event = threading.Event()
33
+ max_ingest_size_bytes = 1 * 1024 * 1024 # 1 MB
34
+
35
+ _partial_buffers: dict[str, PartialTrackAIEvent] = {}
36
+ _partial_timers: dict[str, Timer] = {}
37
+ _PARTIAL_TIMEOUT = 2 # 2 seconds
38
+
39
+ def set_debug_logs(value: bool):
40
+ global debug_logs
41
+ debug_logs = value
42
+ if debug_logs:
43
+ logger.setLevel(logging.DEBUG)
44
+ else:
45
+ logger.setLevel(logging.INFO)
46
+
47
+ def start_flush_thread():
48
+ logger.debug("Opening flush thread")
49
+ global flush_thread
50
+ if flush_thread is None:
51
+ flush_thread = threading.Thread(target=flush_loop)
52
+ flush_thread.daemon = True
53
+ flush_thread.start()
54
+
55
+ def flush_loop():
56
+ while not shutdown_event.is_set():
57
+ try:
58
+ flush()
59
+ except Exception as e:
60
+ logger.error(f"Error in flush loop: {e}")
61
+ time.sleep(upload_interval)
62
+
63
+ def flush() -> None:
64
+ global buffer
65
+
66
+ if buffer is None:
67
+ logger.error("No buffer available")
68
+ return
69
+
70
+ logger.debug("Starting flush")
71
+
72
+ with flush_lock:
73
+ current_buffer = buffer
74
+ buffer = []
75
+
76
+ logger.debug(f"Flushing buffer size: {len(current_buffer)}")
77
+
78
+ grouped_events = {}
79
+ for event in current_buffer:
80
+ endpoint = event["type"]
81
+ data = event["data"]
82
+ if endpoint not in grouped_events:
83
+ grouped_events[endpoint] = []
84
+ grouped_events[endpoint].append(data)
85
+
86
+ for endpoint, events_data in grouped_events.items():
87
+ for i in range(0, len(events_data), upload_size):
88
+ batch = events_data[i:i+upload_size]
89
+ logger.debug(f"Sending {len(batch)} events to {endpoint}")
90
+ send_request(endpoint, batch)
91
+
92
+ logger.debug("Flush complete")
93
+
94
+ def send_request(endpoint: str, data_entries: List[Dict[str, Union[str, Dict]]]) -> None:
95
+
96
+ url = f"{api_url}{endpoint}"
97
+ headers = {
98
+ "Content-Type": "application/json",
99
+ "Authorization": f"Bearer {write_key}",
100
+ }
101
+
102
+ max_retries = 3
103
+ for attempt in range(max_retries):
104
+ try:
105
+ response = requests.post(url, json=data_entries, headers=headers)
106
+ response.raise_for_status()
107
+ logger.debug(f"Request successful: {response.status_code}")
108
+ break
109
+ except requests.exceptions.RequestException as e:
110
+ logger.error(f"Error sending request (attempt {attempt + 1}/{max_retries}): {e}")
111
+ if attempt == max_retries - 1:
112
+ logger.error(f"Failed to send request after {max_retries} attempts")
113
+
114
+ def save_to_buffer(event: Dict[str, Union[str, Dict]]) -> None:
115
+ global buffer
116
+
117
+ if len(buffer) >= max_queue_size * 0.8:
118
+ logger.warning(f"Buffer is at {len(buffer) / max_queue_size * 100:.2f}% capacity")
119
+
120
+ if len(buffer) >= max_queue_size:
121
+ logger.error("Buffer is full. Discarding event.")
122
+ return
123
+
124
+ logger.debug(f"Adding event to buffer: {event}")
125
+
126
+ with flush_lock:
127
+ buffer.append(event)
128
+
129
+ start_flush_thread()
130
+
131
+ def identify(user_id: str, traits: Dict[str, Union[str, int, bool, float]]) -> None:
132
+ if not _check_write_key():
133
+ return
134
+ data = {"user_id": user_id, "traits": traits}
135
+ save_to_buffer({"type": "users/identify", "data": data})
136
+
137
+
138
+ def track_ai(
139
+ user_id: str,
140
+ event: str,
141
+ event_id: Optional[str] = None,
142
+ model: Optional[str] = None,
143
+ input: Optional[str] = None,
144
+ output: Optional[str] = None,
145
+ convo_id: Optional[str] = None,
146
+ properties: Optional[Dict[str, Union[str, int, bool, float]]] = None,
147
+ timestamp: Optional[str] = None,
148
+ attachments: Optional[List[Attachment]] = None,
149
+ ) -> str:
150
+ if not _check_write_key():
151
+ return
152
+
153
+ event_id = event_id or str(uuid.uuid4())
154
+
155
+ try:
156
+ payload = TrackAIEvent(
157
+ event_id=event_id,
158
+ user_id=user_id,
159
+ event=event,
160
+ timestamp=timestamp or _get_timestamp(),
161
+ properties=properties or {},
162
+ ai_data=dict( # Pydantic will coerce to AIData
163
+ model=model,
164
+ input=input,
165
+ output=output,
166
+ convo_id=convo_id,
167
+ ),
168
+ attachments=attachments,
169
+ )
170
+ except ValidationError as err:
171
+ logger.error(f"[raindrop] Invalid data passed to track_ai: {err}")
172
+ return None
173
+
174
+
175
+ if payload.properties is None:
176
+ payload.properties = {}
177
+ payload.properties["$context"] = _get_context()
178
+
179
+ data = payload.model_dump()
180
+
181
+ size = _get_size(data)
182
+ if size > max_ingest_size_bytes:
183
+ logger.warning(
184
+ f"[raindrop] Events larger than {max_ingest_size_bytes / (1024 * 1024)} MB may have properties truncated - "
185
+ f"an event of size {size / (1024 * 1024):.2f} MB was logged"
186
+ )
187
+ return None # Skip adding oversized events to buffer
188
+
189
+ save_to_buffer({"type": "events/track", "data": data})
190
+ return event_id
191
+
192
+ def shutdown():
193
+ logger.info("Shutting down raindrop analytics")
194
+ for eid in list(_partial_timers.keys()):
195
+ _flush_partial_event(eid)
196
+
197
+ shutdown_event.set()
198
+ if flush_thread:
199
+ flush_thread.join(timeout=10)
200
+ flush() # Final flush to ensure all events are sent
201
+
202
+ def _check_write_key():
203
+ if write_key is None:
204
+ logger.warning("write_key is not set. Please set it before using raindrop analytics.")
205
+ return False
206
+ return True
207
+
208
+ def _get_context():
209
+ return {
210
+ "library": {
211
+ "name": "python-sdk",
212
+ "version": VERSION,
213
+ },
214
+ "metadata": {
215
+ "pyVersion": f"v{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
216
+ },
217
+ }
218
+
219
+ def _get_timestamp():
220
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
221
+
222
+
223
+ def _get_size(event: dict[str, any]) -> int:
224
+ try:
225
+ # Add default=str to handle types like datetime
226
+ data = json.dumps(event, default=str)
227
+ return len(data.encode('utf-8'))
228
+ except (TypeError, OverflowError) as e:
229
+ logger.error(f"Error serializing event for size calculation: {e}")
230
+ return 0
231
+
232
+ # Signal types - This is now defined in models.py
233
+ # SignalType = Literal["default", "feedback", "edit"]
234
+
235
+ def track_signal(
236
+ event_id: str,
237
+ name: str,
238
+ signal_type: Literal["default", "feedback", "edit"] = "default",
239
+ timestamp: Optional[str] = None,
240
+ properties: Optional[Dict[str, Any]] = None,
241
+ attachment_id: Optional[str] = None,
242
+ comment: Optional[str] = None,
243
+ after: Optional[str] = None
244
+ ) -> None:
245
+ """
246
+ Track a signal event.
247
+
248
+ Args:
249
+ event_id: The ID of the event to attach the signal to
250
+ name: Name of the signal (e.g. "thumbs_up", "thumbs_down")
251
+ signal_type: Type of signal ("default", "feedback", or "edit")
252
+ timestamp: Optional timestamp for the signal (ISO 8601 format)
253
+ properties: Optional dictionary of additional properties.
254
+ attachment_id: Optional ID of an attachment
255
+ comment: Optional comment string (required and used only if signal_type is 'feedback').
256
+ after: Optional after content string (required and used only if signal_type is 'edit').
257
+ """
258
+ if not _check_write_key():
259
+ return
260
+
261
+ # Prepare the final properties dictionary
262
+ final_properties = properties.copy() if properties else {}
263
+ if signal_type == "feedback" and comment is not None:
264
+ if "comment" in final_properties:
265
+ logger.warning("'comment' provided as both argument and in properties; argument value used.")
266
+ final_properties["comment"] = comment
267
+ elif signal_type == "edit" and after is not None:
268
+ if "after" in final_properties:
269
+ logger.warning("'after' provided as both argument and in properties; argument value used.")
270
+ final_properties["after"] = after
271
+
272
+ # Prepare base arguments for all signal types
273
+ base_args = {
274
+ "event_id": event_id,
275
+ "signal_name": name,
276
+ "timestamp": timestamp or _get_timestamp(),
277
+ "properties": final_properties,
278
+ "attachment_id": attachment_id,
279
+ }
280
+
281
+ try:
282
+ # Construct the specific signal model based on signal_type
283
+ if signal_type == "feedback":
284
+ payload = FeedbackSignal(**base_args, signal_type=signal_type)
285
+ elif signal_type == "edit":
286
+ payload = EditSignal(**base_args, signal_type=signal_type)
287
+ else: # signal_type == "default"
288
+ if comment is not None:
289
+ logger.warning("'comment' argument provided for non-feedback signal type; ignored.")
290
+ if after is not None:
291
+ logger.warning("'after' argument provided for non-edit signal type; ignored.")
292
+ payload = DefaultSignal(**base_args, signal_type=signal_type)
293
+
294
+ except ValidationError as err:
295
+ logger.error(f"[raindrop] Invalid data passed to track_signal: {err}")
296
+ return None
297
+
298
+ # model_dump handles the timestamp correctly
299
+ data = payload.model_dump(mode='json')
300
+
301
+ size = _get_size(data)
302
+ if size > max_ingest_size_bytes:
303
+ logger.warning(
304
+ f"[raindrop] Events larger than {max_ingest_size_bytes / (1024 * 1024)} MB may have properties truncated - "
305
+ f"an event of size {size / (1024 * 1024):.2f} MB was logged"
306
+ )
307
+ return # Skip adding oversized events to buffer
308
+
309
+ save_to_buffer({"type": "signals/track", "data": data})
310
+
311
+ def begin(
312
+ user_id: str,
313
+ event: str,
314
+ event_id: str | None = None,
315
+ properties: Optional[Dict[str, Any]] = None,
316
+ input: Optional[str] = None,
317
+ attachments: Optional[List[Attachment]] = None,
318
+ ) -> Interaction:
319
+ """
320
+ Starts (or resumes) an interaction and returns a helper object.
321
+ """
322
+ eid = event_id or str(uuid.uuid4())
323
+
324
+ # Prepare ai_data if input is provided
325
+ ai_data_partial = None
326
+ if input:
327
+ ai_data_partial = PartialAIData(input=input)
328
+
329
+ # Combine properties with initial_fields, giving precedence to initial_fields if keys clash
330
+ final_properties = (properties or {}).copy()
331
+
332
+ partial_event = PartialTrackAIEvent(
333
+ event_id=eid,
334
+ user_id=user_id,
335
+ event=event,
336
+ ai_data=ai_data_partial,
337
+ properties=final_properties or None, # Pass None if empty, matching PartialTrackAIEvent defaults
338
+ attachments=attachments
339
+ )
340
+
341
+ _track_ai_partial(partial_event)
342
+ return Interaction(eid)
343
+
344
+ def resume_interaction(event_id: str) -> Interaction:
345
+ """Return an Interaction wrapper for an existing eventId."""
346
+ return Interaction(event_id)
347
+
348
+ def _track_ai_partial(event: PartialTrackAIEvent) -> None:
349
+ """
350
+ Merge the incoming patch into an in-memory doc and flush to backend:
351
+ • on `.finish()` (is_pending == False)
352
+ • or after 20 s of inactivity
353
+ """
354
+ eid = event.event_id
355
+
356
+ # 1. merge
357
+ existing = _partial_buffers.get(eid, PartialTrackAIEvent(event_id=eid))
358
+ existing.is_pending = existing.is_pending if existing.is_pending is not None else True
359
+ merged_dict = existing.model_dump(exclude_none=True)
360
+ incoming = event.model_dump(exclude_none=True)
361
+
362
+ # deep merge ai_data / properties
363
+ def _deep(d: dict, u: dict):
364
+ for k, v in u.items():
365
+ d[k] = _deep(d.get(k, {}) if isinstance(v, dict) else v, v) if isinstance(v, dict) else v
366
+ return d
367
+ merged = _deep(merged_dict, incoming)
368
+ merged_obj = PartialTrackAIEvent(**merged)
369
+
370
+ _partial_buffers[eid] = merged_obj
371
+
372
+ # 2. timer handling
373
+ if t := _partial_timers.get(eid):
374
+ t.cancel()
375
+ if merged_obj.is_pending is False:
376
+ _flush_partial_event(eid)
377
+ else:
378
+ _partial_timers[eid] = Timer(_PARTIAL_TIMEOUT, _flush_partial_event, args=[eid])
379
+ _partial_timers[eid].daemon = True
380
+ _partial_timers[eid].start()
381
+
382
+ if debug_logs:
383
+ logger.debug(f"[raindrop] updated partial {eid}: {merged_obj.model_dump(exclude_none=True)}")
384
+
385
+ def _flush_partial_event(event_id: str) -> None:
386
+ """
387
+ Send the accumulated patch as a single object to `events/track_partial`.
388
+ """
389
+ if t := _partial_timers.pop(event_id, None):
390
+ t.cancel()
391
+
392
+ evt = _partial_buffers.pop(event_id, None)
393
+ if not evt:
394
+ return
395
+
396
+ # convert to ordinary TrackAIEvent-ish dict before send
397
+ data = evt.model_dump(mode="json", exclude_none=True)
398
+ size = _get_size(data)
399
+ if size > max_ingest_size_bytes:
400
+ logger.warning(f"[raindrop] partial event {event_id} > 1 MB; skipping")
401
+ return
402
+
403
+ send_request("events/track_partial", data)
404
+
405
+ atexit.register(shutdown)
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, List, Optional, Callable
3
+ from uuid import uuid4
4
+
5
+ from .models import Attachment, PartialTrackAIEvent
6
+ from . import analytics as _core
7
+
8
+
9
+ class Interaction:
10
+ """
11
+ Thin helper returned by analytics.begin().
12
+ Each mutator just relays a partial update back to Analytics.
13
+ """
14
+ __slots__ = ("_event_id", "_analytics")
15
+
16
+ def __init__(self, event_id: Optional[str] = None):
17
+ self._event_id = event_id or str(uuid4())
18
+ self._analytics = _core
19
+
20
+
21
+ # -- mutators ----------------------------------------------------------- #
22
+ def set_input(self, text: str) -> None:
23
+ self._analytics._track_ai_partial(
24
+ PartialTrackAIEvent(event_id=self._event_id, ai_data={"input": text})
25
+ )
26
+
27
+ def add_attachments(self, attachments: List[Attachment]) -> None:
28
+ self._analytics._track_ai_partial(
29
+ PartialTrackAIEvent(event_id=self._event_id, attachments=attachments)
30
+ )
31
+
32
+ def set_properties(self, props: Dict[str, Any]) -> None:
33
+ self._analytics._track_ai_partial(
34
+ PartialTrackAIEvent(event_id=self._event_id, properties=props)
35
+ )
36
+
37
+ def finish(self, *, output: str | None = None, **extra) -> None:
38
+ payload = PartialTrackAIEvent(
39
+ event_id=self._event_id,
40
+ ai_data={"output": output} if output is not None else None,
41
+ is_pending=False,
42
+ **extra,
43
+ )
44
+ self._analytics._track_ai_partial(payload)
45
+
46
+ # convenience
47
+ @property
48
+ def id(self) -> str:
49
+ return self._event_id
@@ -0,0 +1,133 @@
1
+ from pydantic import BaseModel, Field, ValidationError, model_validator, field_validator
2
+ from typing import Any, Optional, Dict, Literal, List, Union
3
+ from datetime import datetime, timezone
4
+
5
+ class _Base(BaseModel):
6
+ model_config = dict(extra="forbid", validate_default=True)
7
+
8
+
9
+ class Attachment(BaseModel):
10
+ type: Literal["code", "text", "image", "iframe"]
11
+ value: str # URL, raw code, etc.
12
+ name: Optional[str] = None # e.g. "Generated SQL"
13
+ role: Optional[Literal["input", "output", "context"]] = None
14
+ language: Optional[str] = None # for code snippets
15
+
16
+ @model_validator(mode="after")
17
+ def _require_value(cls, values):
18
+ if not values.value:
19
+ raise ValueError("value must be non-empty.")
20
+ return values
21
+
22
+ class AIData(_Base):
23
+ model: Optional[str]
24
+ input: Optional[str]
25
+ output: Optional[str]
26
+ convo_id: Optional[str]
27
+
28
+ @model_validator(mode="after")
29
+ def _require_input_or_output(cls, values):
30
+ if not (values.input or values.output):
31
+ raise ValueError("Either 'input' or 'output' must be non-empty.")
32
+ return values
33
+
34
+ class TrackAIEvent(_Base):
35
+ event_id: Optional[str] = None
36
+ user_id: str
37
+ event: str
38
+ ai_data: AIData
39
+ properties: Dict[str, Any] = Field(default_factory=dict)
40
+ timestamp: datetime = Field(
41
+ default_factory=lambda: datetime.now(timezone.utc).replace(microsecond=0).isoformat()
42
+ )
43
+ attachments: Optional[List[Attachment]] = None
44
+
45
+ # Ensure user_id and event are non-empty strings
46
+ @field_validator("user_id", "event")
47
+ def _non_empty(cls, v, info):
48
+ if v is None or (isinstance(v, str) and v.strip() == ""):
49
+ raise ValueError(f"'{info.field_name}' must be a non-empty string.")
50
+ return v
51
+
52
+ # No need to duplicate input/output check here; AIData already enforces it
53
+ # but keep method to return values unchanged so that pydantic doesn't complain about unused return
54
+
55
+
56
+ # --- Signal Tracking Models --- #
57
+
58
+ class BaseSignal(_Base):
59
+ """Base model for signal events, containing common fields."""
60
+ event_id: str
61
+ signal_name: str
62
+ timestamp: datetime = Field(
63
+ # Return a datetime object; Pydantic's model_dump will handle serialization to string
64
+ default_factory=lambda: datetime.now(timezone.utc).replace(microsecond=0)
65
+ )
66
+ properties: Dict[str, Any] = Field(default_factory=dict)
67
+ attachment_id: Optional[str] = None
68
+
69
+ @field_validator("event_id", "signal_name")
70
+ def _non_empty_strings(cls, v, info):
71
+ if not isinstance(v, str) or not v.strip():
72
+ raise ValueError(f"'{info.field_name}' must be a non-empty string.")
73
+ return v
74
+
75
+ class DefaultSignal(BaseSignal):
76
+ """Model for default signal events."""
77
+ signal_type: Literal["default"] = "default"
78
+
79
+ class FeedbackSignal(BaseSignal):
80
+ """Model for feedback signal events, requiring a comment."""
81
+ signal_type: Literal["feedback"]
82
+
83
+ @model_validator(mode="after")
84
+ def _check_comment_in_properties(cls, values):
85
+ # Check properties safely after potential initialization
86
+ # Use getattr to safely access properties, returning None if not present
87
+ props = getattr(values, 'properties', None)
88
+ if not isinstance(props, dict):
89
+ raise ValueError("'properties' must be a dictionary for feedback signals.")
90
+ comment = props.get("comment")
91
+ if not comment or not isinstance(comment, str) or not comment.strip():
92
+ raise ValueError("'properties' must contain a non-empty string 'comment' for feedback signals.")
93
+ return values
94
+
95
+ class EditSignal(BaseSignal):
96
+ """Model for edit signal events, requiring after content."""
97
+ signal_type: Literal["edit"]
98
+
99
+ @model_validator(mode="after")
100
+ def _check_after_in_properties(cls, values):
101
+ # Check properties safely after potential initialization
102
+ props = getattr(values, 'properties', None)
103
+ if not isinstance(props, dict):
104
+ raise ValueError("'properties' must be a dictionary for edit signals.")
105
+ after = props.get("after")
106
+ if not after or not isinstance(after, str) or not after.strip():
107
+ raise ValueError("'properties' must contain a non-empty string 'after' for edit signals.")
108
+ return values
109
+
110
+ # Discriminated Union for Signal Events
111
+ # Pydantic will automatically use the 'signal_type' field to determine which model to use.
112
+ SignalEvent = Union[DefaultSignal, FeedbackSignal, EditSignal]
113
+
114
+ # --- End Signal Tracking Models --- #
115
+
116
+
117
+ class PartialAIData(_Base):
118
+ """Looser version for incremental updates."""
119
+ model: Optional[str] = None
120
+ input: Optional[str] = None
121
+ output: Optional[str] = None
122
+ convo_id: Optional[str] = None
123
+
124
+ class PartialTrackAIEvent(_Base):
125
+ """Accepts *any subset* of TrackAIEvent fields."""
126
+ event_id: str # always required for merge-key
127
+ user_id: Optional[str] = None
128
+ event: Optional[str] = None
129
+ ai_data: Optional[PartialAIData] = None
130
+ timestamp: Optional[datetime] = None
131
+ properties: Optional[Dict[str, Any]] = None
132
+ attachments: Optional[List[Attachment]] = None
133
+ is_pending: Optional[bool] = True
@@ -1,28 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: raindrop-ai
3
- Version: 0.0.19
4
- Summary: Raindrop AI (Python SDK)
5
- Home-page: https://raindrop.ai
6
- Author: Raindrop AI
7
- Author-email: sdk@raindrop.ai
8
- Classifier: Development Status :: 3 - Alpha
9
- Classifier: Intended Audience :: Developers
10
- Classifier: Programming Language :: Python
11
- Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.6
13
- Classifier: Programming Language :: Python :: 3.7
14
- Classifier: Programming Language :: Python :: 3.8
15
- Classifier: Programming Language :: Python :: 3.9
16
- Classifier: Programming Language :: Python :: 3.10
17
- Description-Content-Type: text/markdown
18
- Requires-Dist: requests
19
- Dynamic: author
20
- Dynamic: author-email
21
- Dynamic: classifier
22
- Dynamic: description
23
- Dynamic: description-content-type
24
- Dynamic: home-page
25
- Dynamic: requires-dist
26
- Dynamic: summary
27
-
28
- For questions, email us at sdk@raindrop.ai
@@ -1,3 +0,0 @@
1
- # Python SDK for dawnai.com
2
-
3
- to run tests: python3 -m unittest discover tests
@@ -1,283 +0,0 @@
1
- import sys
2
- import time
3
- import threading
4
- from typing import Union, List, Dict, Optional, Literal
5
- import requests
6
- from datetime import datetime, timezone
7
- import logging
8
- import json
9
- import uuid
10
- import atexit
11
- from raindrop.version import VERSION
12
-
13
-
14
- # Configure logging
15
- logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
16
- logger = logging.getLogger(__name__)
17
-
18
- write_key = None
19
- api_url = "https://api.raindrop.ai/v1/"
20
- max_queue_size = 10000
21
- upload_size = 10
22
- upload_interval = 1.0
23
- buffer = []
24
- flush_lock = threading.Lock()
25
- debug_logs = False
26
- flush_thread = None
27
- shutdown_event = threading.Event()
28
- max_ingest_size_bytes = 1 * 1024 * 1024 # 1 MB
29
-
30
- def set_debug_logs(value: bool):
31
- global debug_logs
32
- debug_logs = value
33
- if debug_logs:
34
- logger.setLevel(logging.DEBUG)
35
- else:
36
- logger.setLevel(logging.INFO)
37
-
38
- def start_flush_thread():
39
- logger.debug("Opening flush thread")
40
- global flush_thread
41
- if flush_thread is None:
42
- flush_thread = threading.Thread(target=flush_loop)
43
- flush_thread.daemon = True
44
- flush_thread.start()
45
-
46
- def flush_loop():
47
- while not shutdown_event.is_set():
48
- try:
49
- flush()
50
- except Exception as e:
51
- logger.error(f"Error in flush loop: {e}")
52
- time.sleep(upload_interval)
53
-
54
- def flush() -> None:
55
- global buffer
56
-
57
- if buffer is None:
58
- logger.error("No buffer available")
59
- return
60
-
61
- logger.debug("Starting flush")
62
-
63
- with flush_lock:
64
- current_buffer = buffer
65
- buffer = []
66
-
67
- logger.debug(f"Flushing buffer size: {len(current_buffer)}")
68
-
69
- grouped_events = {}
70
- for event in current_buffer:
71
- endpoint = event["type"]
72
- data = event["data"]
73
- if endpoint not in grouped_events:
74
- grouped_events[endpoint] = []
75
- grouped_events[endpoint].append(data)
76
-
77
- for endpoint, events_data in grouped_events.items():
78
- for i in range(0, len(events_data), upload_size):
79
- batch = events_data[i:i+upload_size]
80
- logger.debug(f"Sending {len(batch)} events to {endpoint}")
81
- send_request(endpoint, batch)
82
-
83
- logger.debug("Flush complete")
84
-
85
- def send_request(endpoint: str, data_entries: List[Dict[str, Union[str, Dict]]]) -> None:
86
-
87
- url = f"{api_url}{endpoint}"
88
- headers = {
89
- "Content-Type": "application/json",
90
- "Authorization": f"Bearer {write_key}",
91
- }
92
-
93
- max_retries = 3
94
- for attempt in range(max_retries):
95
- try:
96
- response = requests.post(url, json=data_entries, headers=headers)
97
- response.raise_for_status()
98
- logger.debug(f"Request successful: {response.status_code}")
99
- break
100
- except requests.exceptions.RequestException as e:
101
- logger.error(f"Error sending request (attempt {attempt + 1}/{max_retries}): {e}")
102
- if attempt == max_retries - 1:
103
- logger.error(f"Failed to send request after {max_retries} attempts")
104
-
105
- def save_to_buffer(event: Dict[str, Union[str, Dict]]) -> None:
106
- global buffer
107
-
108
- if len(buffer) >= max_queue_size * 0.8:
109
- logger.warning(f"Buffer is at {len(buffer) / max_queue_size * 100:.2f}% capacity")
110
-
111
- if len(buffer) >= max_queue_size:
112
- logger.error("Buffer is full. Discarding event.")
113
- return
114
-
115
- logger.debug(f"Adding event to buffer: {event}")
116
-
117
- with flush_lock:
118
- buffer.append(event)
119
-
120
- start_flush_thread()
121
-
122
- def identify(user_id: str, traits: Dict[str, Union[str, int, bool, float]]) -> None:
123
- if not _check_write_key():
124
- return
125
- data = {"user_id": user_id, "traits": traits}
126
- save_to_buffer({"type": "users/identify", "data": data})
127
-
128
- def track(
129
- user_id: str,
130
- event: str,
131
- properties: Optional[Dict[str, Union[str, int, bool, float]]] = None,
132
- timestamp: Optional[str] = None,
133
- ) -> None:
134
- if not _check_write_key():
135
- return
136
-
137
- data = {
138
- "event_id": str(uuid.uuid4()),
139
- "user_id": user_id,
140
- "event": event,
141
- "properties": properties,
142
- "timestamp": timestamp if timestamp else _get_timestamp(),
143
- }
144
- data.setdefault("properties", {})["$context"] = _get_context()
145
-
146
- save_to_buffer({"type": "events/track", "data": data})
147
-
148
- def track_ai(
149
- user_id: str,
150
- event: str,
151
- model: Optional[str] = None,
152
- user_input: Optional[str] = None,
153
- output: Optional[str] = None,
154
- convo_id: Optional[str] = None,
155
- properties: Optional[Dict[str, Union[str, int, bool, float]]] = None,
156
- timestamp: Optional[str] = None,
157
- ) -> None:
158
- if not _check_write_key():
159
- return
160
-
161
- if not user_input and not output:
162
- raise ValueError("One of user_input or output must be provided and not empty.")
163
-
164
- event_id = str(uuid.uuid4())
165
-
166
- data = {
167
- "event_id": event_id,
168
- "user_id": user_id,
169
- "event": event,
170
- "properties": properties or {},
171
- "timestamp": timestamp if timestamp else _get_timestamp(),
172
- "ai_data": {
173
- "model": model,
174
- "input": user_input,
175
- "output": output,
176
- "convo_id": convo_id,
177
- },
178
- }
179
- data.setdefault("properties", {})["$context"] = _get_context()
180
-
181
- size = _get_size(data)
182
- if size > max_ingest_size_bytes:
183
- logger.warning(
184
- f"[raindrop] Events larger than {max_ingest_size_bytes / (1024 * 1024)} MB may have properties truncated - "
185
- f"an event of size {size / (1024 * 1024):.2f} MB was logged"
186
- )
187
- return None # Skip adding oversized events to buffer
188
-
189
- save_to_buffer({"type": "events/track", "data": data})
190
- return event_id
191
-
192
- def shutdown():
193
- logger.info("Shutting down raindrop analytics")
194
- shutdown_event.set()
195
- if flush_thread:
196
- flush_thread.join(timeout=10)
197
- flush() # Final flush to ensure all events are sent
198
-
199
- def _check_write_key():
200
- if write_key is None:
201
- logger.warning("write_key is not set. Please set it before using raindrop analytics.")
202
- return False
203
- return True
204
-
205
- def _get_context():
206
- return {
207
- "library": {
208
- "name": "python-sdk",
209
- "version": VERSION,
210
- },
211
- "metadata": {
212
- "pyVersion": f"v{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
213
- },
214
- }
215
-
216
- def _get_timestamp():
217
- return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
218
-
219
-
220
- def _get_size(event: dict[str, any]) -> int:
221
- try:
222
- data = json.dumps(event)
223
- return len(data.encode('utf-8'))
224
- except (TypeError, OverflowError) as e:
225
- logger.error(f"Error serializing event for size calculation: {e}")
226
- return 0
227
-
228
- # Signal types
229
- SignalType = Literal["default", "feedback", "edit"]
230
-
231
- def track_signal(
232
- event_id: str,
233
- name: str,
234
- signal_type: Optional[SignalType] = "default",
235
- timestamp: Optional[str] = None,
236
- properties: Optional[Dict[str, any]] = None,
237
- attachment_id: Optional[str] = None,
238
- comment: Optional[str] = None,
239
- after: Optional[str] = None,
240
- ) -> None:
241
- """
242
- Track a signal event.
243
-
244
- Args:
245
- event_id: The ID of the event to attach the signal to
246
- name: Name of the signal (e.g. "thumbs_up", "thumbs_down")
247
- signal_type: Type of signal ("default", "feedback", or "edit")
248
- timestamp: Optional timestamp for the signal
249
- properties: Optional properties for the signal
250
- attachment_id: Optional ID of an attachment
251
- comment: Optional comment (only for feedback signals)
252
- after: Optional after content (only for edit signals)
253
- """
254
- if not _check_write_key():
255
- return
256
-
257
- # Prepare properties with optional comment and after fields
258
- signal_properties = properties or {}
259
- if comment is not None:
260
- signal_properties["comment"] = comment
261
- if after is not None:
262
- signal_properties["after"] = after
263
-
264
- data = {
265
- "event_id": event_id,
266
- "signal_name": name,
267
- "signal_type": signal_type,
268
- "timestamp": timestamp if timestamp else _get_timestamp(),
269
- "properties": signal_properties,
270
- "attachment_id": attachment_id,
271
- }
272
-
273
- size = _get_size(data)
274
- if size > max_ingest_size_bytes:
275
- logger.warning(
276
- f"[raindrop] Events larger than {max_ingest_size_bytes / (1024 * 1024)} MB may have properties truncated - "
277
- f"an event of size {size / (1024 * 1024):.2f} MB was logged"
278
- )
279
- return # Skip adding oversized events to buffer
280
-
281
- save_to_buffer({"type": "signals/track", "data": data})
282
-
283
- atexit.register(shutdown)
@@ -1,28 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: raindrop-ai
3
- Version: 0.0.19
4
- Summary: Raindrop AI (Python SDK)
5
- Home-page: https://raindrop.ai
6
- Author: Raindrop AI
7
- Author-email: sdk@raindrop.ai
8
- Classifier: Development Status :: 3 - Alpha
9
- Classifier: Intended Audience :: Developers
10
- Classifier: Programming Language :: Python
11
- Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.6
13
- Classifier: Programming Language :: Python :: 3.7
14
- Classifier: Programming Language :: Python :: 3.8
15
- Classifier: Programming Language :: Python :: 3.9
16
- Classifier: Programming Language :: Python :: 3.10
17
- Description-Content-Type: text/markdown
18
- Requires-Dist: requests
19
- Dynamic: author
20
- Dynamic: author-email
21
- Dynamic: classifier
22
- Dynamic: description
23
- Dynamic: description-content-type
24
- Dynamic: home-page
25
- Dynamic: requires-dist
26
- Dynamic: summary
27
-
28
- For questions, email us at sdk@raindrop.ai
@@ -1,11 +0,0 @@
1
- README.md
2
- setup.py
3
- raindrop/__init__.py
4
- raindrop/analytics.py
5
- raindrop/version.py
6
- raindrop_ai.egg-info/PKG-INFO
7
- raindrop_ai.egg-info/SOURCES.txt
8
- raindrop_ai.egg-info/dependency_links.txt
9
- raindrop_ai.egg-info/requires.txt
10
- raindrop_ai.egg-info/top_level.txt
11
- tests/test_analytics.py
@@ -1 +0,0 @@
1
- requests
@@ -1 +0,0 @@
1
- raindrop
@@ -1,4 +0,0 @@
1
- [egg_info]
2
- tag_build =
3
- tag_date = 0
4
-
@@ -1,34 +0,0 @@
1
- import sys
2
- import os
3
-
4
- from setuptools import setup, find_packages
5
-
6
- # Don't import raindrop-ai module here, since deps may not be installed
7
- sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'raindrop'))
8
- from version import VERSION
9
-
10
- setup(
11
- name="raindrop-ai",
12
- version=VERSION,
13
- description="Raindrop AI (Python SDK)",
14
- author="Raindrop AI",
15
- author_email="sdk@raindrop.ai",
16
- long_description="For questions, email us at sdk@raindrop.ai",
17
- long_description_content_type="text/markdown",
18
- url="https://raindrop.ai",
19
- packages=find_packages(include=["raindrop", "README.md"]),
20
- install_requires=[
21
- "requests",
22
- ],
23
- classifiers=[
24
- "Development Status :: 3 - Alpha",
25
- "Intended Audience :: Developers",
26
- "Programming Language :: Python",
27
- "Programming Language :: Python :: 3",
28
- "Programming Language :: Python :: 3.6",
29
- "Programming Language :: Python :: 3.7",
30
- "Programming Language :: Python :: 3.8",
31
- "Programming Language :: Python :: 3.9",
32
- "Programming Language :: Python :: 3.10",
33
- ],
34
- )
@@ -1,254 +0,0 @@
1
- import time
2
- import unittest
3
- from unittest.mock import patch
4
- import raindrop.analytics as analytics
5
- from raindrop.version import VERSION
6
- import sys
7
-
8
-
9
- class TestAnalytics(unittest.TestCase):
10
- def setUp(self):
11
- # Set up any necessary test data or configurations
12
- analytics.write_key = "0000"
13
- analytics.api_url = "http://localhost:3000/"
14
-
15
- def tearDown(self):
16
- # Clean up any resources or reset any state after each test
17
- analytics.flush()
18
- pass
19
-
20
- def test_identify(self):
21
- with patch('requests.post') as mock_post:
22
- user_id = "user123"
23
- traits = {"email": "john@example.com", "name": "John"}
24
-
25
- analytics.identify(user_id, traits)
26
- analytics.flush() # Force flush to trigger request
27
-
28
- # Verify the POST request was made
29
- mock_post.assert_called_once()
30
-
31
- # Get the data that was sent
32
- call_args = mock_post.call_args
33
- url = call_args[0][0]
34
- data = call_args[1]['json'][0] # First event in the batch
35
-
36
- # Verify URL and data
37
- self.assertEqual(url, "http://localhost:3000/users/identify")
38
- self.assertEqual(data['user_id'], user_id)
39
- self.assertEqual(data['traits'], traits)
40
-
41
- @patch('requests.post')
42
- def test_track(self, mock_post):
43
- # Test data
44
- user_id = "user123"
45
- event = "signed_up"
46
- properties = {"plan": "Premium"}
47
-
48
- # Track the event
49
- analytics.track(user_id, event, properties)
50
-
51
- # Force a flush to trigger the HTTP request
52
- analytics.flush()
53
-
54
- # Verify the POST request was made
55
- mock_post.assert_called_once()
56
-
57
- # Get the data that was sent
58
- call_args = mock_post.call_args
59
- url = call_args[0][0]
60
- data = call_args[1]['json'][0] # First event in the batch
61
-
62
- # Verify URL
63
- self.assertEqual(url, "http://localhost:3000/events/track")
64
-
65
- # Verify request structure
66
- self.assertEqual(data['user_id'], user_id)
67
- self.assertEqual(data['event'], event)
68
- self.assertEqual(data['properties']['plan'], "Premium")
69
-
70
- # Verify context data
71
- self.assertEqual(data['properties']['$context']['library']['name'], "python-sdk")
72
- self.assertEqual(data['properties']['$context']['library']['version'], VERSION)
73
- self.assertEqual(
74
- data['properties']['$context']['metadata']['pyVersion'],
75
- f"v{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
76
- )
77
-
78
- # Verify other required fields
79
- self.assertIn('event_id', data)
80
- self.assertIn('timestamp', data)
81
-
82
- @patch('requests.post')
83
- def test_track_ai(self, mock_post):
84
- # Test data
85
- user_id = "user123"
86
- event = "ai_completion"
87
- model = "gpt-3.5"
88
- user_input = "Hello"
89
- output = "Hi there!"
90
- convo_id = "conv123"
91
- properties = {"temperature": 0.7}
92
-
93
- # Track the AI event
94
- analytics.track_ai(
95
- user_id=user_id,
96
- event=event,
97
- model=model,
98
- user_input=user_input,
99
- output=output,
100
- convo_id=convo_id,
101
- properties=properties
102
- )
103
-
104
- # Force a flush
105
- analytics.flush()
106
-
107
- # Verify the POST request was made
108
- mock_post.assert_called_once()
109
-
110
- # Get the data that was sent
111
- call_args = mock_post.call_args
112
- data = call_args[1]['json'][0] # First event in the batch
113
-
114
- # Verify AI-specific fields
115
- self.assertEqual(data['ai_data']['model'], model)
116
- self.assertEqual(data['ai_data']['input'], user_input)
117
- self.assertEqual(data['ai_data']['output'], output)
118
- self.assertEqual(data['ai_data']['convo_id'], convo_id)
119
-
120
- # Verify common fields
121
- self.assertEqual(data['user_id'], user_id)
122
- self.assertEqual(data['event'], event)
123
- self.assertEqual(data['properties']['temperature'], 0.7)
124
- self.assertIn('event_id', data)
125
- self.assertIn('timestamp', data)
126
-
127
- def test_flush(self):
128
- with patch('requests.post') as mock_post:
129
- user_id = "user123"
130
- event = "ai_chat"
131
- model = "GPT-3"
132
- input_text = "Hello"
133
- output_text = "Hi there!"
134
-
135
- analytics.track_ai(
136
- user_id, event, model=model, user_input=input_text, output=output_text
137
- )
138
-
139
- analytics.flush() # Force flush
140
-
141
- # Verify the buffer is empty after flush
142
- self.assertEqual(len(analytics.buffer), 0)
143
-
144
- # Verify the POST request was made
145
- mock_post.assert_called_once()
146
-
147
- def test_track_ai_with_size_limit(self):
148
- with patch('requests.post') as mock_post:
149
- user_id = "user123"
150
- event = "ai_chat_test"
151
- model = "GPT-3"
152
- input_text = "Hello"
153
- output_text = "Hi there!"
154
- properties = {
155
- "key": "v" * 10000,
156
- "key2": "v" * 10000,
157
- "key3": "v" * 1048576, # 1 MB of data (1024 * 1024 bytes)
158
- }
159
-
160
- # Capture logged output
161
- with self.assertLogs('dawnai.analytics', level='WARNING') as log_capture:
162
- analytics.track_ai(
163
- user_id, event, model=model, user_input=input_text, output=output_text, properties=properties
164
- )
165
- analytics.flush() # Force flush
166
-
167
- # Check the logged output
168
- self.assertTrue(any("[dawn] Events larger than" in message for message in log_capture.output),
169
- "Expected size warning is not logged")
170
-
171
- # Verify no request was made since event was too large
172
- mock_post.assert_not_called()
173
-
174
- @patch('requests.post')
175
- def test_track_signal(self, mock_post):
176
- # Test basic signal tracking
177
- event_id = "event123"
178
- name = "thumbs_up"
179
- signal_type = "feedback"
180
- properties = {"rating": 5}
181
- comment = "Great response!"
182
- attachment_id = "attach123"
183
- after = "Updated content"
184
-
185
- # Track signal with all fields
186
- analytics.track_signal(
187
- event_id=event_id,
188
- name=name,
189
- signal_type=signal_type,
190
- properties=properties,
191
- comment=comment,
192
- attachment_id=attachment_id,
193
- after=after
194
- )
195
-
196
- # Force a flush
197
- analytics.flush()
198
-
199
- # Verify the POST request was made
200
- mock_post.assert_called_once()
201
-
202
- # Get the data that was sent
203
- call_args = mock_post.call_args
204
- url = call_args[0][0]
205
- data = call_args[1]['json'][0] # First event in the batch
206
-
207
- # Verify URL
208
- self.assertEqual(url, "http://localhost:3000/signals/track")
209
-
210
- # Verify signal data
211
- self.assertEqual(data['event_id'], event_id)
212
- self.assertEqual(data['signal_name'], name)
213
- self.assertEqual(data['signal_type'], signal_type)
214
- self.assertEqual(data['attachment_id'], attachment_id)
215
-
216
- # Verify properties including comment and after
217
- self.assertEqual(data['properties']['rating'], 5)
218
- self.assertEqual(data['properties']['comment'], comment)
219
- self.assertEqual(data['properties']['after'], after)
220
-
221
- # Test size limit handling
222
- mock_post.reset_mock()
223
- large_properties = {
224
- "key": "v" * 10000,
225
- "key2": "v" * 10000,
226
- "key3": "v" * 1048576, # 1 MB of data
227
- }
228
-
229
- # Capture logged output for oversized event
230
- with self.assertLogs('dawnai.analytics', level='WARNING') as log_capture:
231
- analytics.track_signal(
232
- event_id=event_id,
233
- name=name,
234
- properties=large_properties
235
- )
236
- analytics.flush()
237
-
238
- # Check the logged output
239
- self.assertTrue(any("[dawn] Events larger than" in message for message in log_capture.output),
240
- "Expected size warning is not logged")
241
-
242
- # Test different signal types
243
- mock_post.reset_mock()
244
- for signal_type in ["default", "feedback", "edit"]:
245
- analytics.track_signal(
246
- event_id=event_id,
247
- name=name,
248
- signal_type=signal_type
249
- )
250
- analytics.flush()
251
-
252
- data = mock_post.call_args[1]['json'][0]
253
- self.assertEqual(data['signal_type'], signal_type)
254
- mock_post.reset_mock()