1+ import asyncio
2+ import logging
3+ from datetime import datetime , timezone
4+ from typing import List , Optional , Dict , Any
5+ from pathlib import Path
6+ import uuid
7+ from persistqueue import Queue
8+
9+ class ObservationBatcher :
10+ """A persistent queue for batching observations to reduce API calls."""
11+
12+ def __init__ (self , data_directory : str , min_batch_size : int = 5 , max_batch_size : int = 50 ):
13+ self .data_directory = Path (data_directory )
14+ self .min_batch_size = min_batch_size
15+ self .max_batch_size = max_batch_size
16+
17+ # Create persistent queue backed by SQLite
18+ queue_dir = self .data_directory / "batches"
19+ queue_dir .mkdir (parents = True , exist_ok = True )
20+ self ._queue = Queue (path = str (queue_dir / "queue" ))
21+
22+ self ._batch_ready_event = asyncio .Event ()
23+ self .logger = logging .getLogger ("gum.batcher" )
24+
25+ async def start (self ):
26+ """Start the batching system."""
27+ self .logger .info (f"Started batcher with { self ._queue .qsize ()} items in queue" )
28+
29+ if self .should_process_batch ():
30+ self ._batch_ready_event .set ()
31+
32+ async def stop (self ):
33+ """Stop the batching system."""
34+ self .logger .info ("Stopped batcher" )
35+
36+ def push (self , observer_name : str , content : str , content_type : str ) -> str :
37+ """Push an observation onto the queue.
38+
39+ Args:
40+ observer_name: Name of the observer
41+ content: Observation content
42+ content_type: Type of content
43+
44+ Returns:
45+ str: Observation ID
46+ """
47+ observation_id = str (uuid .uuid4 ())
48+ observation_dict = {
49+ 'id' : observation_id ,
50+ 'observer_name' : observer_name ,
51+ 'content' : content ,
52+ 'content_type' : content_type ,
53+ 'timestamp' : datetime .now (timezone .utc ).isoformat ()
54+ }
55+
56+ # Add to queue - automatically persisted by persist-queue
57+ self ._queue .put (observation_dict )
58+ self .logger .debug (f"Pushed observation { observation_id } to queue (size: { self ._queue .qsize ()} )" )
59+
60+ # Signal that a batch is ready if we've reached minimum size
61+ if self .should_process_batch ():
62+ self ._batch_ready_event .set ()
63+
64+ return observation_id
65+
66+ def size (self ) -> int :
67+ """Get the current size of the queue."""
68+ return self ._queue .qsize ()
69+
70+ def should_process_batch (self ) -> bool :
71+ """Check if the batch should be processed based on minimum batch size."""
72+ return self ._queue .qsize () >= self .min_batch_size
73+
74+ def pop_batch (self , batch_size : Optional [int ] = None ) -> List [Dict [str , Any ]]:
75+ """Pop a batch of observations from the front of the queue (FIFO).
76+
77+ Args:
78+ batch_size: Number of items to pop. Defaults to max_batch_size
79+
80+ Returns:
81+ List of observation dictionaries popped from queue
82+ """
83+ batch_size = batch_size or self .max_batch_size
84+
85+ batch = []
86+ for _ in range (min (batch_size , self ._queue .qsize ())):
87+ batch .append (self ._queue .get_nowait ())
88+
89+ if batch :
90+ self .logger .debug (f"Popped batch of { len (batch )} observations (queue size: { self ._queue .qsize ()} )" )
91+
92+ if not self .should_process_batch ():
93+ self ._batch_ready_event .clear ()
94+
95+ return batch
96+
97+ async def wait_for_batch_ready (self ):
98+ """Wait for a batch to be ready for processing."""
99+ await self ._batch_ready_event .wait ()
0 commit comments