hindsight-api 0.2.1__py3-none-any.whl → 0.4.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.
- hindsight_api/admin/__init__.py +1 -0
- hindsight_api/admin/cli.py +311 -0
- hindsight_api/alembic/versions/f1a2b3c4d5e6_add_memory_links_composite_index.py +44 -0
- hindsight_api/alembic/versions/g2a3b4c5d6e7_add_tags_column.py +48 -0
- hindsight_api/alembic/versions/h3c4d5e6f7g8_mental_models_v4.py +112 -0
- hindsight_api/alembic/versions/i4d5e6f7g8h9_delete_opinions.py +41 -0
- hindsight_api/alembic/versions/j5e6f7g8h9i0_mental_model_versions.py +95 -0
- hindsight_api/alembic/versions/k6f7g8h9i0j1_add_directive_subtype.py +58 -0
- hindsight_api/alembic/versions/l7g8h9i0j1k2_add_worker_columns.py +109 -0
- hindsight_api/alembic/versions/m8h9i0j1k2l3_mental_model_id_to_text.py +41 -0
- hindsight_api/alembic/versions/n9i0j1k2l3m4_learnings_and_pinned_reflections.py +134 -0
- hindsight_api/alembic/versions/o0j1k2l3m4n5_migrate_mental_models_data.py +113 -0
- hindsight_api/alembic/versions/p1k2l3m4n5o6_new_knowledge_architecture.py +194 -0
- hindsight_api/alembic/versions/q2l3m4n5o6p7_fix_mental_model_fact_type.py +50 -0
- hindsight_api/alembic/versions/r3m4n5o6p7q8_add_reflect_response_to_reflections.py +47 -0
- hindsight_api/alembic/versions/s4n5o6p7q8r9_add_consolidated_at_to_memory_units.py +53 -0
- hindsight_api/alembic/versions/t5o6p7q8r9s0_rename_mental_models_to_observations.py +134 -0
- hindsight_api/alembic/versions/u6p7q8r9s0t1_mental_models_text_id.py +41 -0
- hindsight_api/alembic/versions/v7q8r9s0t1u2_add_max_tokens_to_mental_models.py +50 -0
- hindsight_api/api/http.py +1406 -118
- hindsight_api/api/mcp.py +11 -196
- hindsight_api/config.py +359 -27
- hindsight_api/engine/consolidation/__init__.py +5 -0
- hindsight_api/engine/consolidation/consolidator.py +859 -0
- hindsight_api/engine/consolidation/prompts.py +69 -0
- hindsight_api/engine/cross_encoder.py +706 -88
- hindsight_api/engine/db_budget.py +284 -0
- hindsight_api/engine/db_utils.py +11 -0
- hindsight_api/engine/directives/__init__.py +5 -0
- hindsight_api/engine/directives/models.py +37 -0
- hindsight_api/engine/embeddings.py +553 -29
- hindsight_api/engine/entity_resolver.py +8 -5
- hindsight_api/engine/interface.py +40 -17
- hindsight_api/engine/llm_wrapper.py +744 -68
- hindsight_api/engine/memory_engine.py +2505 -1017
- hindsight_api/engine/mental_models/__init__.py +14 -0
- hindsight_api/engine/mental_models/models.py +53 -0
- hindsight_api/engine/query_analyzer.py +4 -3
- hindsight_api/engine/reflect/__init__.py +18 -0
- hindsight_api/engine/reflect/agent.py +933 -0
- hindsight_api/engine/reflect/models.py +109 -0
- hindsight_api/engine/reflect/observations.py +186 -0
- hindsight_api/engine/reflect/prompts.py +483 -0
- hindsight_api/engine/reflect/tools.py +437 -0
- hindsight_api/engine/reflect/tools_schema.py +250 -0
- hindsight_api/engine/response_models.py +168 -4
- hindsight_api/engine/retain/bank_utils.py +79 -201
- hindsight_api/engine/retain/fact_extraction.py +424 -195
- hindsight_api/engine/retain/fact_storage.py +35 -12
- hindsight_api/engine/retain/link_utils.py +29 -24
- hindsight_api/engine/retain/orchestrator.py +24 -43
- hindsight_api/engine/retain/types.py +11 -2
- hindsight_api/engine/search/graph_retrieval.py +43 -14
- hindsight_api/engine/search/link_expansion_retrieval.py +391 -0
- hindsight_api/engine/search/mpfp_retrieval.py +362 -117
- hindsight_api/engine/search/reranking.py +2 -2
- hindsight_api/engine/search/retrieval.py +848 -201
- hindsight_api/engine/search/tags.py +172 -0
- hindsight_api/engine/search/think_utils.py +42 -141
- hindsight_api/engine/search/trace.py +12 -1
- hindsight_api/engine/search/tracer.py +26 -6
- hindsight_api/engine/search/types.py +21 -3
- hindsight_api/engine/task_backend.py +113 -106
- hindsight_api/engine/utils.py +1 -152
- hindsight_api/extensions/__init__.py +10 -1
- hindsight_api/extensions/builtin/tenant.py +5 -1
- hindsight_api/extensions/context.py +10 -1
- hindsight_api/extensions/operation_validator.py +81 -4
- hindsight_api/extensions/tenant.py +26 -0
- hindsight_api/main.py +69 -6
- hindsight_api/mcp_local.py +12 -53
- hindsight_api/mcp_tools.py +494 -0
- hindsight_api/metrics.py +433 -48
- hindsight_api/migrations.py +141 -1
- hindsight_api/models.py +3 -3
- hindsight_api/pg0.py +53 -0
- hindsight_api/server.py +39 -2
- hindsight_api/worker/__init__.py +11 -0
- hindsight_api/worker/main.py +296 -0
- hindsight_api/worker/poller.py +486 -0
- {hindsight_api-0.2.1.dist-info → hindsight_api-0.4.0.dist-info}/METADATA +16 -6
- hindsight_api-0.4.0.dist-info/RECORD +112 -0
- {hindsight_api-0.2.1.dist-info → hindsight_api-0.4.0.dist-info}/entry_points.txt +2 -0
- hindsight_api/engine/retain/observation_regeneration.py +0 -254
- hindsight_api/engine/search/observation_utils.py +0 -125
- hindsight_api/engine/search/scoring.py +0 -159
- hindsight_api-0.2.1.dist-info/RECORD +0 -75
- {hindsight_api-0.2.1.dist-info → hindsight_api-0.4.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)
|
hindsight_api/engine/db_utils.py
CHANGED
|
@@ -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:
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Pydantic models for directives."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Directive(BaseModel):
|
|
10
|
+
"""A directive is a hard rule injected into prompts.
|
|
11
|
+
|
|
12
|
+
Directives are user-defined rules that guide agent behavior. Unlike mental models
|
|
13
|
+
which are automatically consolidated from memories, directives are explicit
|
|
14
|
+
instructions that are always included in relevant prompts.
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
- "Always respond in formal English"
|
|
18
|
+
- "Never share personal data with third parties"
|
|
19
|
+
- "Prefer conservative investment recommendations"
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
id: UUID = Field(description="Unique identifier")
|
|
23
|
+
bank_id: str = Field(description="Bank this directive belongs to")
|
|
24
|
+
name: str = Field(description="Human-readable name")
|
|
25
|
+
content: str = Field(description="The directive text to inject into prompts")
|
|
26
|
+
priority: int = Field(default=0, description="Higher priority directives are injected first")
|
|
27
|
+
is_active: bool = Field(default=True, description="Whether this directive is currently active")
|
|
28
|
+
tags: list[str] = Field(default_factory=list, description="Tags for filtering")
|
|
29
|
+
created_at: datetime = Field(
|
|
30
|
+
default_factory=lambda: datetime.now(timezone.utc), description="When this directive was created"
|
|
31
|
+
)
|
|
32
|
+
updated_at: datetime = Field(
|
|
33
|
+
default_factory=lambda: datetime.now(timezone.utc), description="When this directive was last updated"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
class Config:
|
|
37
|
+
from_attributes = True
|