hindsight-api 0.2.1__py3-none-any.whl → 0.3.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.
Files changed (46) hide show
  1. hindsight_api/admin/__init__.py +1 -0
  2. hindsight_api/admin/cli.py +252 -0
  3. hindsight_api/alembic/versions/f1a2b3c4d5e6_add_memory_links_composite_index.py +44 -0
  4. hindsight_api/alembic/versions/g2a3b4c5d6e7_add_tags_column.py +48 -0
  5. hindsight_api/api/http.py +282 -20
  6. hindsight_api/api/mcp.py +47 -52
  7. hindsight_api/config.py +238 -6
  8. hindsight_api/engine/cross_encoder.py +599 -86
  9. hindsight_api/engine/db_budget.py +284 -0
  10. hindsight_api/engine/db_utils.py +11 -0
  11. hindsight_api/engine/embeddings.py +453 -26
  12. hindsight_api/engine/entity_resolver.py +8 -5
  13. hindsight_api/engine/interface.py +8 -4
  14. hindsight_api/engine/llm_wrapper.py +241 -27
  15. hindsight_api/engine/memory_engine.py +609 -122
  16. hindsight_api/engine/query_analyzer.py +4 -3
  17. hindsight_api/engine/response_models.py +38 -0
  18. hindsight_api/engine/retain/fact_extraction.py +388 -192
  19. hindsight_api/engine/retain/fact_storage.py +34 -8
  20. hindsight_api/engine/retain/link_utils.py +24 -16
  21. hindsight_api/engine/retain/orchestrator.py +52 -17
  22. hindsight_api/engine/retain/types.py +9 -0
  23. hindsight_api/engine/search/graph_retrieval.py +42 -13
  24. hindsight_api/engine/search/link_expansion_retrieval.py +256 -0
  25. hindsight_api/engine/search/mpfp_retrieval.py +362 -117
  26. hindsight_api/engine/search/reranking.py +2 -2
  27. hindsight_api/engine/search/retrieval.py +847 -200
  28. hindsight_api/engine/search/tags.py +172 -0
  29. hindsight_api/engine/search/think_utils.py +1 -1
  30. hindsight_api/engine/search/trace.py +12 -0
  31. hindsight_api/engine/search/tracer.py +24 -1
  32. hindsight_api/engine/search/types.py +21 -0
  33. hindsight_api/engine/task_backend.py +109 -18
  34. hindsight_api/engine/utils.py +1 -1
  35. hindsight_api/extensions/context.py +10 -1
  36. hindsight_api/main.py +56 -4
  37. hindsight_api/metrics.py +433 -48
  38. hindsight_api/migrations.py +141 -1
  39. hindsight_api/models.py +3 -1
  40. hindsight_api/pg0.py +53 -0
  41. hindsight_api/server.py +39 -2
  42. {hindsight_api-0.2.1.dist-info → hindsight_api-0.3.0.dist-info}/METADATA +5 -1
  43. hindsight_api-0.3.0.dist-info/RECORD +82 -0
  44. {hindsight_api-0.2.1.dist-info → hindsight_api-0.3.0.dist-info}/entry_points.txt +1 -0
  45. hindsight_api-0.2.1.dist-info/RECORD +0 -75
  46. {hindsight_api-0.2.1.dist-info → hindsight_api-0.3.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,284 @@
1
+ """
2
+ Database connection budget management.
3
+
4
+ Limits concurrent database connections per operation to prevent
5
+ a single operation (e.g., recall with parallel queries) from
6
+ exhausting the connection pool.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ import uuid
12
+ from contextlib import asynccontextmanager
13
+ from dataclasses import dataclass, field
14
+ from typing import TYPE_CHECKING, AsyncIterator
15
+
16
+ if TYPE_CHECKING:
17
+ import asyncpg
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclass
23
+ class OperationBudget:
24
+ """
25
+ Tracks connection budget for a single operation.
26
+
27
+ Each operation gets a semaphore limiting its concurrent connections.
28
+ """
29
+
30
+ operation_id: str
31
+ max_connections: int
32
+ semaphore: asyncio.Semaphore = field(init=False)
33
+ active_count: int = field(default=0, init=False)
34
+
35
+ def __post_init__(self):
36
+ self.semaphore = asyncio.Semaphore(self.max_connections)
37
+
38
+
39
+ class ConnectionBudgetManager:
40
+ """
41
+ Manages per-operation connection budgets.
42
+
43
+ Usage:
44
+ manager = ConnectionBudgetManager(default_budget=4)
45
+
46
+ # Start an operation
47
+ async with manager.operation(max_connections=2) as op:
48
+ # Acquire connections within the budget
49
+ async with op.acquire(pool) as conn:
50
+ await conn.fetch(...)
51
+
52
+ # Multiple connections respect the budget
53
+ async with op.acquire(pool) as conn1, op.acquire(pool) as conn2:
54
+ # At most 2 concurrent connections for this operation
55
+ ...
56
+ """
57
+
58
+ def __init__(self, default_budget: int = 4):
59
+ """
60
+ Initialize the budget manager.
61
+
62
+ Args:
63
+ default_budget: Default max connections per operation
64
+ """
65
+ self.default_budget = default_budget
66
+ self._operations: dict[str, OperationBudget] = {}
67
+ self._lock = asyncio.Lock()
68
+
69
+ @asynccontextmanager
70
+ async def operation(
71
+ self,
72
+ max_connections: int | None = None,
73
+ operation_id: str | None = None,
74
+ ) -> AsyncIterator["BudgetedOperation"]:
75
+ """
76
+ Create a budgeted operation context.
77
+
78
+ Args:
79
+ max_connections: Max concurrent connections for this operation.
80
+ Defaults to manager's default_budget.
81
+ operation_id: Optional custom operation ID. Auto-generated if not provided.
82
+
83
+ Yields:
84
+ BudgetedOperation context for acquiring connections
85
+ """
86
+ op_id = operation_id or f"op-{uuid.uuid4().hex[:12]}"
87
+ budget = max_connections or self.default_budget
88
+
89
+ async with self._lock:
90
+ if op_id in self._operations:
91
+ raise ValueError(f"Operation {op_id} already exists")
92
+ self._operations[op_id] = OperationBudget(op_id, budget)
93
+
94
+ try:
95
+ yield BudgetedOperation(self, op_id)
96
+ finally:
97
+ async with self._lock:
98
+ self._operations.pop(op_id, None)
99
+
100
+ def _get_budget(self, operation_id: str) -> OperationBudget:
101
+ """Get budget for an operation (internal use)."""
102
+ budget = self._operations.get(operation_id)
103
+ if not budget:
104
+ raise ValueError(f"Operation {operation_id} not found")
105
+ return budget
106
+
107
+
108
+ class BudgetedOperation:
109
+ """
110
+ A single operation with connection budget.
111
+
112
+ Provides methods to acquire connections within the budget.
113
+ """
114
+
115
+ def __init__(self, manager: ConnectionBudgetManager, operation_id: str):
116
+ self._manager = manager
117
+ self.operation_id = operation_id
118
+
119
+ @property
120
+ def budget(self) -> OperationBudget:
121
+ """Get the budget for this operation."""
122
+ return self._manager._get_budget(self.operation_id)
123
+
124
+ @asynccontextmanager
125
+ async def acquire(self, pool: "asyncpg.Pool") -> AsyncIterator["asyncpg.Connection"]:
126
+ """
127
+ Acquire a connection within the operation's budget.
128
+
129
+ Blocks if the operation has reached its connection limit.
130
+
131
+ Args:
132
+ pool: asyncpg connection pool
133
+
134
+ Yields:
135
+ Database connection
136
+ """
137
+ budget = self.budget
138
+ async with budget.semaphore:
139
+ budget.active_count += 1
140
+ conn = await pool.acquire()
141
+ try:
142
+ yield conn
143
+ finally:
144
+ budget.active_count -= 1
145
+ await pool.release(conn)
146
+
147
+ def wrap_pool(self, pool: "asyncpg.Pool") -> "BudgetedPool":
148
+ """
149
+ Wrap a pool with this operation's budget.
150
+
151
+ The returned BudgetedPool can be passed to functions expecting a pool,
152
+ and all acquire() calls will be limited by this operation's budget.
153
+
154
+ Args:
155
+ pool: asyncpg connection pool to wrap
156
+
157
+ Returns:
158
+ BudgetedPool that limits connections to this operation's budget
159
+ """
160
+ return BudgetedPool(pool, self)
161
+
162
+ async def acquire_many(
163
+ self,
164
+ pool: "asyncpg.Pool",
165
+ count: int,
166
+ ) -> AsyncIterator[list["asyncpg.Connection"]]:
167
+ """
168
+ Acquire multiple connections within the budget.
169
+
170
+ Note: This acquires connections sequentially to respect the budget.
171
+ For parallel acquisition, use multiple acquire() calls with asyncio.gather().
172
+
173
+ Args:
174
+ pool: asyncpg connection pool
175
+ count: Number of connections to acquire
176
+
177
+ Yields:
178
+ List of database connections
179
+ """
180
+ connections = []
181
+ try:
182
+ for _ in range(count):
183
+ conn = await pool.acquire()
184
+ connections.append(conn)
185
+ yield connections
186
+ finally:
187
+ for conn in connections:
188
+ await pool.release(conn)
189
+
190
+
191
+ # Global default manager instance
192
+ _default_manager: ConnectionBudgetManager | None = None
193
+
194
+
195
+ def get_budget_manager(default_budget: int = 4) -> ConnectionBudgetManager:
196
+ """
197
+ Get or create the global budget manager.
198
+
199
+ Args:
200
+ default_budget: Default max connections per operation
201
+
202
+ Returns:
203
+ Global ConnectionBudgetManager instance
204
+ """
205
+ global _default_manager
206
+ if _default_manager is None:
207
+ _default_manager = ConnectionBudgetManager(default_budget=default_budget)
208
+ return _default_manager
209
+
210
+
211
+ @asynccontextmanager
212
+ async def budgeted_operation(
213
+ max_connections: int | None = None,
214
+ operation_id: str | None = None,
215
+ default_budget: int = 4,
216
+ ) -> AsyncIterator[BudgetedOperation]:
217
+ """
218
+ Convenience function to create a budgeted operation.
219
+
220
+ Args:
221
+ max_connections: Max concurrent connections for this operation
222
+ operation_id: Optional custom operation ID
223
+ default_budget: Default budget if manager not yet created
224
+
225
+ Yields:
226
+ BudgetedOperation context
227
+
228
+ Example:
229
+ async with budgeted_operation(max_connections=2) as op:
230
+ async with op.acquire(pool) as conn:
231
+ await conn.fetch(...)
232
+ """
233
+ manager = get_budget_manager(default_budget)
234
+ async with manager.operation(max_connections, operation_id) as op:
235
+ yield op
236
+
237
+
238
+ class BudgetedPool:
239
+ """
240
+ A pool wrapper that limits concurrent connection acquisitions.
241
+
242
+ This can be passed to functions expecting a pool, and acquire()
243
+ calls will be limited by the budget semaphore.
244
+
245
+ Usage:
246
+ async with budgeted_operation(max_connections=4) as op:
247
+ budgeted_pool = op.wrap_pool(pool)
248
+ # Pass budgeted_pool to functions that expect a pool
249
+ await some_function(budgeted_pool, ...)
250
+ """
251
+
252
+ def __init__(self, pool: "asyncpg.Pool", operation: BudgetedOperation):
253
+ self._pool = pool
254
+ self._operation = operation
255
+
256
+ async def acquire(self) -> "asyncpg.Connection":
257
+ """
258
+ Acquire a connection within the budget.
259
+
260
+ Note: Caller must release the connection when done.
261
+ Prefer using as context manager via acquire_with_retry or op.acquire().
262
+ """
263
+ budget = self._operation.budget
264
+ await budget.semaphore.acquire()
265
+ budget.active_count += 1
266
+ try:
267
+ return await self._pool.acquire()
268
+ except Exception:
269
+ budget.active_count -= 1
270
+ budget.semaphore.release()
271
+ raise
272
+
273
+ async def release(self, conn: "asyncpg.Connection") -> None:
274
+ """Release a connection back to the pool."""
275
+ budget = self._operation.budget
276
+ try:
277
+ await self._pool.release(conn)
278
+ finally:
279
+ budget.active_count -= 1
280
+ budget.semaphore.release()
281
+
282
+ def __getattr__(self, name):
283
+ """Proxy other attributes to the underlying pool."""
284
+ return getattr(self._pool, name)
@@ -83,11 +83,22 @@ async def acquire_with_retry(pool: asyncpg.Pool, max_retries: int = DEFAULT_MAX_
83
83
  Yields:
84
84
  An asyncpg connection
85
85
  """
86
+ import time
87
+
88
+ start = time.time()
86
89
 
87
90
  async def acquire():
88
91
  return await pool.acquire()
89
92
 
90
93
  conn = await retry_with_backoff(acquire, max_retries=max_retries)
94
+ acquire_time = time.time() - start
95
+
96
+ # Log slow connection acquisitions (indicates pool contention)
97
+ if acquire_time > 0.05: # 50ms threshold
98
+ pool_size = pool.get_size()
99
+ pool_free = pool.get_idle_size()
100
+ logger.warning(f"[DB POOL] Slow acquire: {acquire_time:.3f}s | size={pool_size}, idle={pool_free}")
101
+
91
102
  try:
92
103
  yield conn
93
104
  finally: