Skip to content

Commit 3b0214a

Browse files
committed
2 parents 65d6800 + 3d0bc4a commit 3b0214a

9 files changed

Lines changed: 264 additions & 115 deletions

File tree

‎gum/batcher.py‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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()

‎gum/cli.py‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import os
55
import argparse
66
import asyncio
7-
import shutil # Add this import for deleting directories
7+
import shutil
88
from gum import gum
99
from gum.observers import Screen
1010

@@ -29,6 +29,10 @@ def parse_args():
2929
parser.add_argument('--limit', '-l', type=int, help='Limit the number of results', default=10)
3030
parser.add_argument('--model', '-m', type=str, help='Model to use')
3131
parser.add_argument('--reset-cache', action='store_true', help='Reset the GUM cache and exit') # Add this line
32+
33+
# Batching configuration arguments
34+
parser.add_argument('--min-batch-size', type=int, help='Minimum number of observations to trigger batch processing')
35+
parser.add_argument('--max-batch-size', type=int, help='Maximum number of observations per batch')
3236

3337
args = parser.parse_args()
3438

@@ -53,7 +57,11 @@ async def main():
5357
model = args.model or os.getenv('MODEL_NAME') or 'gpt-4o-mini'
5458
user_name = args.user_name or os.getenv('USER_NAME')
5559

56-
# you need one or the other-
60+
# Batching configuration - follow same pattern as other args
61+
min_batch_size = args.min_batch_size or int(os.getenv('MIN_BATCH_SIZE', '5'))
62+
max_batch_size = args.max_batch_size or int(os.getenv('MAX_BATCH_SIZE', '15'))
63+
64+
# you need one or the other
5765
if user_name is None and args.query is None:
5866
print("Please provide a user name (as an argument, -u, or as an env variable) or a query (as an argument, -q)")
5967
return
@@ -63,7 +71,7 @@ async def main():
6371
await gum_instance.connect_db()
6472
result = await gum_instance.query(args.query, limit=args.limit)
6573

66-
# pretty print confidences / propositions / number of items returned
74+
# confidences / propositions / number of items returned
6775
print(f"\nFound {len(result)} results:")
6876
for prop, score in result:
6977
print(f"\nProposition: {prop.text}")
@@ -75,7 +83,14 @@ async def main():
7583
print("-" * 80)
7684
else:
7785
print(f"Listening to {user_name} with model {model}")
78-
async with gum(user_name, model, Screen(model)) as gum_instance:
86+
87+
async with gum(
88+
user_name,
89+
model,
90+
Screen(model),
91+
min_batch_size=min_batch_size,
92+
max_batch_size=max_batch_size
93+
) as gum_instance:
7994
await asyncio.Future() # run forever (Ctrl-C to stop)
8095

8196
def cli():

‎gum/db_utils.py‎

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
from .models import (
2727
Observation,
2828
Proposition,
29-
proposition_parent,
3029
observation_proposition,
3130
)
3231

@@ -45,13 +44,7 @@ def build_fts_query(raw: str, mode: str = "OR") -> str:
4544
else: # implicit AND
4645
return " ".join(tokens)
4746

48-
def _has_child_subquery() -> select:
49-
return (
50-
select(literal_column("1"))
51-
.select_from(proposition_parent)
52-
.where(proposition_parent.c.parent_id == Proposition.id)
53-
.exists()
54-
)
47+
5548

5649

5750
async def search_propositions_bm25(
@@ -74,7 +67,6 @@ async def search_propositions_bm25(
7467
# 1 Build candidate list
7568
# --------------------------------------------------------
7669
candidate_pool = limit * 10 if enable_mmr else limit
77-
has_child = _has_child_subquery()
7870

7971
if has_query:
8072
fts_prop = Table("propositions_fts", MetaData())
@@ -143,14 +135,12 @@ async def search_propositions_bm25(
143135
stmt = (
144136
select(Proposition, best_scores.c.bm25)
145137
.join(best_scores, best_scores.c.pid == Proposition.id)
146-
.where(~has_child)
147138
.order_by(best_scores.c.bm25.asc()) # smallest→best
148139
)
149140
else:
150141
# --- 1-b No user query ------------------------------
151142
stmt = (
152143
select(Proposition, literal_column("0.0").label("bm25"))
153-
.where(~has_child)
154144
.order_by(Proposition.created_at.desc())
155145
)
156146

0 commit comments

Comments
 (0)