hindsight-api 0.4.6__py3-none-any.whl → 0.4.8__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/__init__.py +1 -1
- hindsight_api/alembic/versions/5a366d414dce_initial_schema.py +16 -2
- hindsight_api/api/http.py +83 -1
- hindsight_api/banner.py +3 -0
- hindsight_api/config.py +44 -6
- hindsight_api/daemon.py +18 -112
- hindsight_api/engine/llm_interface.py +146 -0
- hindsight_api/engine/llm_wrapper.py +304 -1327
- hindsight_api/engine/memory_engine.py +125 -41
- hindsight_api/engine/providers/__init__.py +14 -0
- hindsight_api/engine/providers/anthropic_llm.py +434 -0
- hindsight_api/engine/providers/claude_code_llm.py +352 -0
- hindsight_api/engine/providers/codex_llm.py +527 -0
- hindsight_api/engine/providers/gemini_llm.py +502 -0
- hindsight_api/engine/providers/mock_llm.py +234 -0
- hindsight_api/engine/providers/openai_compatible_llm.py +745 -0
- hindsight_api/engine/retain/fact_extraction.py +13 -9
- hindsight_api/engine/retain/fact_storage.py +5 -3
- hindsight_api/extensions/__init__.py +10 -0
- hindsight_api/extensions/builtin/tenant.py +36 -0
- hindsight_api/extensions/operation_validator.py +129 -0
- hindsight_api/main.py +6 -21
- hindsight_api/migrations.py +75 -0
- hindsight_api/worker/main.py +41 -11
- hindsight_api/worker/poller.py +26 -14
- {hindsight_api-0.4.6.dist-info → hindsight_api-0.4.8.dist-info}/METADATA +2 -1
- {hindsight_api-0.4.6.dist-info → hindsight_api-0.4.8.dist-info}/RECORD +29 -21
- {hindsight_api-0.4.6.dist-info → hindsight_api-0.4.8.dist-info}/WHEEL +0 -0
- {hindsight_api-0.4.6.dist-info → hindsight_api-0.4.8.dist-info}/entry_points.txt +0 -0
|
@@ -57,21 +57,25 @@ def _infer_temporal_date(fact_text: str, event_date: datetime) -> str | None:
|
|
|
57
57
|
return None
|
|
58
58
|
|
|
59
59
|
|
|
60
|
-
def _sanitize_text(text: str) -> str:
|
|
60
|
+
def _sanitize_text(text: str | None) -> str | None:
|
|
61
61
|
"""
|
|
62
|
-
Sanitize text by removing
|
|
62
|
+
Sanitize text by removing characters that break downstream systems.
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
Removes:
|
|
65
|
+
- Null bytes (\\x00): Invalid in PostgreSQL UTF-8 encoding
|
|
66
|
+
- Unicode surrogates (U+D800-U+DFFF): Invalid in UTF-8, break LLM APIs
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
Surrogate characters are used in UTF-16 encoding but cannot be encoded
|
|
69
|
+
in UTF-8. They can appear in Python strings from improperly decoded data
|
|
70
|
+
(e.g., from JavaScript or broken files). Null bytes commonly appear in
|
|
71
|
+
OCR output, PDF extraction, or copy-paste from binary sources.
|
|
70
72
|
"""
|
|
73
|
+
if text is None:
|
|
74
|
+
return None
|
|
71
75
|
if not text:
|
|
72
76
|
return text
|
|
73
|
-
# Remove
|
|
74
|
-
|
|
77
|
+
# Remove null bytes and surrogate characters
|
|
78
|
+
text = text.replace("\x00", "")
|
|
75
79
|
return re.sub(r"[\ud800-\udfff]", "", text)
|
|
76
80
|
|
|
77
81
|
|
|
@@ -8,6 +8,7 @@ import json
|
|
|
8
8
|
import logging
|
|
9
9
|
|
|
10
10
|
from ..memory_engine import fq_table
|
|
11
|
+
from .fact_extraction import _sanitize_text
|
|
11
12
|
from .types import ProcessedFact
|
|
12
13
|
|
|
13
14
|
logger = logging.getLogger(__name__)
|
|
@@ -47,7 +48,7 @@ async def insert_facts_batch(
|
|
|
47
48
|
tags_list = []
|
|
48
49
|
|
|
49
50
|
for fact in facts:
|
|
50
|
-
fact_texts.append(fact.fact_text)
|
|
51
|
+
fact_texts.append(_sanitize_text(fact.fact_text))
|
|
51
52
|
# Convert embedding to string for asyncpg vector type
|
|
52
53
|
embeddings.append(str(fact.embedding))
|
|
53
54
|
# event_date: Use occurred_start if available, otherwise use mentioned_at
|
|
@@ -56,7 +57,7 @@ async def insert_facts_batch(
|
|
|
56
57
|
occurred_starts.append(fact.occurred_start)
|
|
57
58
|
occurred_ends.append(fact.occurred_end)
|
|
58
59
|
mentioned_ats.append(fact.mentioned_at)
|
|
59
|
-
contexts.append(fact.context)
|
|
60
|
+
contexts.append(_sanitize_text(fact.context))
|
|
60
61
|
fact_types.append(fact.fact_type)
|
|
61
62
|
# confidence_score is only for opinion facts
|
|
62
63
|
confidence_scores.append(1.0 if fact.fact_type == "opinion" else None)
|
|
@@ -157,7 +158,8 @@ async def handle_document_tracking(
|
|
|
157
158
|
"""
|
|
158
159
|
import hashlib
|
|
159
160
|
|
|
160
|
-
#
|
|
161
|
+
# Sanitize and calculate content hash
|
|
162
|
+
combined_content = _sanitize_text(combined_content) or ""
|
|
161
163
|
content_hash = hashlib.sha256(combined_content.encode()).hexdigest()
|
|
162
164
|
|
|
163
165
|
# Always delete old document first if it exists (cascades to units and links)
|
|
@@ -24,6 +24,11 @@ from hindsight_api.extensions.operation_validator import (
|
|
|
24
24
|
# Consolidation operation
|
|
25
25
|
ConsolidateContext,
|
|
26
26
|
ConsolidateResult,
|
|
27
|
+
# Mental Model operations
|
|
28
|
+
MentalModelGetContext,
|
|
29
|
+
MentalModelGetResult,
|
|
30
|
+
MentalModelRefreshContext,
|
|
31
|
+
MentalModelRefreshResult,
|
|
27
32
|
# Core operations
|
|
28
33
|
OperationValidationError,
|
|
29
34
|
OperationValidatorExtension,
|
|
@@ -65,6 +70,11 @@ __all__ = [
|
|
|
65
70
|
# Operation Validator - Consolidation
|
|
66
71
|
"ConsolidateContext",
|
|
67
72
|
"ConsolidateResult",
|
|
73
|
+
# Operation Validator - Mental Model
|
|
74
|
+
"MentalModelGetContext",
|
|
75
|
+
"MentalModelGetResult",
|
|
76
|
+
"MentalModelRefreshContext",
|
|
77
|
+
"MentalModelRefreshResult",
|
|
68
78
|
# Tenant/Auth
|
|
69
79
|
"ApiKeyTenantExtension",
|
|
70
80
|
"AuthenticationError",
|
|
@@ -5,6 +5,42 @@ from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantC
|
|
|
5
5
|
from hindsight_api.models import RequestContext
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
class DefaultTenantExtension(TenantExtension):
|
|
9
|
+
"""
|
|
10
|
+
Default single-tenant extension with no authentication.
|
|
11
|
+
|
|
12
|
+
This is the default extension used when no tenant extension is configured.
|
|
13
|
+
It provides single-tenant behavior using the configured schema from
|
|
14
|
+
HINDSIGHT_API_DATABASE_SCHEMA (defaults to 'public').
|
|
15
|
+
|
|
16
|
+
Features:
|
|
17
|
+
- No authentication required (passes all requests)
|
|
18
|
+
- Uses configured schema from environment
|
|
19
|
+
- Perfect for single-tenant deployments without auth
|
|
20
|
+
|
|
21
|
+
Configuration:
|
|
22
|
+
HINDSIGHT_API_DATABASE_SCHEMA=your-schema (optional, defaults to 'public')
|
|
23
|
+
|
|
24
|
+
This is automatically enabled by default. To use custom authentication,
|
|
25
|
+
configure a different tenant extension:
|
|
26
|
+
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: dict[str, str]):
|
|
30
|
+
super().__init__(config)
|
|
31
|
+
# Cache the schema at initialization for consistency
|
|
32
|
+
# Support explicit schema override via config, otherwise use environment
|
|
33
|
+
self._schema = config.get("schema", get_config().database_schema)
|
|
34
|
+
|
|
35
|
+
async def authenticate(self, context: RequestContext) -> TenantContext:
|
|
36
|
+
"""Return configured schema without any authentication."""
|
|
37
|
+
return TenantContext(schema_name=self._schema)
|
|
38
|
+
|
|
39
|
+
async def list_tenants(self) -> list[Tenant]:
|
|
40
|
+
"""Return configured schema for single-tenant setup."""
|
|
41
|
+
return [Tenant(schema=self._schema)]
|
|
42
|
+
|
|
43
|
+
|
|
8
44
|
class ApiKeyTenantExtension(TenantExtension):
|
|
9
45
|
"""
|
|
10
46
|
Built-in tenant extension that validates API key against an environment variable.
|
|
@@ -196,6 +196,57 @@ class ConsolidateResult:
|
|
|
196
196
|
error: str | None = None
|
|
197
197
|
|
|
198
198
|
|
|
199
|
+
# =============================================================================
|
|
200
|
+
# Mental Model Contexts
|
|
201
|
+
# =============================================================================
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass
|
|
205
|
+
class MentalModelGetContext:
|
|
206
|
+
"""Context for a mental model GET operation validation (pre-operation)."""
|
|
207
|
+
|
|
208
|
+
bank_id: str
|
|
209
|
+
mental_model_id: str
|
|
210
|
+
request_context: "RequestContext"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass
|
|
214
|
+
class MentalModelRefreshContext:
|
|
215
|
+
"""Context for a mental model refresh/create operation validation (pre-operation)."""
|
|
216
|
+
|
|
217
|
+
bank_id: str
|
|
218
|
+
mental_model_id: str | None # None for create (not yet assigned)
|
|
219
|
+
request_context: "RequestContext"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass
|
|
223
|
+
class MentalModelGetResult:
|
|
224
|
+
"""Result context for post-mental-model-GET hook."""
|
|
225
|
+
|
|
226
|
+
bank_id: str
|
|
227
|
+
mental_model_id: str
|
|
228
|
+
request_context: "RequestContext"
|
|
229
|
+
output_tokens: int # tokens in the returned content
|
|
230
|
+
success: bool = True
|
|
231
|
+
error: str | None = None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass
|
|
235
|
+
class MentalModelRefreshResult:
|
|
236
|
+
"""Result context for post-mental-model-refresh hook."""
|
|
237
|
+
|
|
238
|
+
bank_id: str
|
|
239
|
+
mental_model_id: str
|
|
240
|
+
request_context: "RequestContext"
|
|
241
|
+
query_tokens: int # tokens in source_query
|
|
242
|
+
output_tokens: int # tokens in generated content
|
|
243
|
+
context_tokens: int # tokens in context (if any)
|
|
244
|
+
facts_used: int # facts referenced in based_on
|
|
245
|
+
mental_models_used: int # mental models referenced in based_on
|
|
246
|
+
success: bool = True
|
|
247
|
+
error: str | None = None
|
|
248
|
+
|
|
249
|
+
|
|
199
250
|
class OperationValidatorExtension(Extension, ABC):
|
|
200
251
|
"""
|
|
201
252
|
Validates and hooks into retain/recall/reflect/consolidate operations.
|
|
@@ -402,3 +453,81 @@ class OperationValidatorExtension(Extension, ABC):
|
|
|
402
453
|
- error: Error message (if failed)
|
|
403
454
|
"""
|
|
404
455
|
pass
|
|
456
|
+
|
|
457
|
+
# =========================================================================
|
|
458
|
+
# Mental Model - Pre-operation validation hook (optional - override to implement)
|
|
459
|
+
# =========================================================================
|
|
460
|
+
|
|
461
|
+
async def validate_mental_model_get(self, ctx: MentalModelGetContext) -> ValidationResult:
|
|
462
|
+
"""
|
|
463
|
+
Validate a mental model GET operation before execution.
|
|
464
|
+
|
|
465
|
+
Override to implement custom validation logic for mental model retrieval.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
ctx: Context containing:
|
|
469
|
+
- bank_id: Bank identifier
|
|
470
|
+
- mental_model_id: Mental model identifier
|
|
471
|
+
- request_context: Request context with auth info
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
ValidationResult indicating whether the operation is allowed.
|
|
475
|
+
"""
|
|
476
|
+
return ValidationResult.accept()
|
|
477
|
+
|
|
478
|
+
async def validate_mental_model_refresh(self, ctx: MentalModelRefreshContext) -> ValidationResult:
|
|
479
|
+
"""
|
|
480
|
+
Validate a mental model refresh/create operation before execution.
|
|
481
|
+
|
|
482
|
+
Override to implement custom validation logic for mental model refresh.
|
|
483
|
+
|
|
484
|
+
Args:
|
|
485
|
+
ctx: Context containing:
|
|
486
|
+
- bank_id: Bank identifier
|
|
487
|
+
- mental_model_id: Mental model identifier (None for create)
|
|
488
|
+
- request_context: Request context with auth info
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
ValidationResult indicating whether the operation is allowed.
|
|
492
|
+
"""
|
|
493
|
+
return ValidationResult.accept()
|
|
494
|
+
|
|
495
|
+
# =========================================================================
|
|
496
|
+
# Mental Model - Post-operation hooks (optional - override to implement)
|
|
497
|
+
# =========================================================================
|
|
498
|
+
|
|
499
|
+
async def on_mental_model_get_complete(self, result: MentalModelGetResult) -> None:
|
|
500
|
+
"""
|
|
501
|
+
Called after a mental model GET operation completes (success or failure).
|
|
502
|
+
|
|
503
|
+
Override to implement post-operation logic such as tracking or audit logging.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
result: Result context containing:
|
|
507
|
+
- bank_id: Bank identifier
|
|
508
|
+
- mental_model_id: Mental model identifier
|
|
509
|
+
- output_tokens: Token count of the returned content
|
|
510
|
+
- success: Whether the operation succeeded
|
|
511
|
+
- error: Error message (if failed)
|
|
512
|
+
"""
|
|
513
|
+
pass
|
|
514
|
+
|
|
515
|
+
async def on_mental_model_refresh_complete(self, result: MentalModelRefreshResult) -> None:
|
|
516
|
+
"""
|
|
517
|
+
Called after a mental model refresh operation completes (success or failure).
|
|
518
|
+
|
|
519
|
+
Override to implement post-operation logic such as tracking or audit logging.
|
|
520
|
+
|
|
521
|
+
Args:
|
|
522
|
+
result: Result context containing:
|
|
523
|
+
- bank_id: Bank identifier
|
|
524
|
+
- mental_model_id: Mental model identifier
|
|
525
|
+
- query_tokens: Tokens in source_query
|
|
526
|
+
- output_tokens: Tokens in generated content
|
|
527
|
+
- context_tokens: Tokens in context
|
|
528
|
+
- facts_used: Number of facts referenced
|
|
529
|
+
- mental_models_used: Number of mental models referenced
|
|
530
|
+
- success: Whether the operation succeeded
|
|
531
|
+
- error: Error message (if failed)
|
|
532
|
+
"""
|
|
533
|
+
pass
|
hindsight_api/main.py
CHANGED
|
@@ -20,14 +20,13 @@ import warnings
|
|
|
20
20
|
|
|
21
21
|
import uvicorn
|
|
22
22
|
|
|
23
|
-
from . import MemoryEngine
|
|
23
|
+
from . import MemoryEngine, __version__
|
|
24
24
|
from .api import create_app
|
|
25
25
|
from .banner import print_banner
|
|
26
26
|
from .config import DEFAULT_WORKERS, ENV_WORKERS, HindsightConfig, get_config
|
|
27
27
|
from .daemon import (
|
|
28
28
|
DEFAULT_DAEMON_PORT,
|
|
29
29
|
DEFAULT_IDLE_TIMEOUT,
|
|
30
|
-
DaemonLock,
|
|
31
30
|
IdleTimeoutMiddleware,
|
|
32
31
|
daemonize,
|
|
33
32
|
)
|
|
@@ -136,30 +135,15 @@ def main():
|
|
|
136
135
|
|
|
137
136
|
# Daemon mode handling
|
|
138
137
|
if args.daemon:
|
|
139
|
-
# Use
|
|
140
|
-
args.port
|
|
138
|
+
# Use port from args (may be custom for profiles)
|
|
139
|
+
if args.port == config.port: # No custom port specified
|
|
140
|
+
args.port = DEFAULT_DAEMON_PORT
|
|
141
141
|
args.host = "127.0.0.1" # Only bind to localhost for security
|
|
142
142
|
|
|
143
|
-
# Check if another daemon is already running
|
|
144
|
-
daemon_lock = DaemonLock()
|
|
145
|
-
if not daemon_lock.acquire():
|
|
146
|
-
print(f"Daemon already running (PID: {daemon_lock.get_pid()})", file=sys.stderr)
|
|
147
|
-
sys.exit(1)
|
|
148
|
-
|
|
149
143
|
# Fork into background
|
|
144
|
+
# No lockfile needed - port binding prevents duplicate daemons
|
|
150
145
|
daemonize()
|
|
151
146
|
|
|
152
|
-
# Re-acquire lock in child process
|
|
153
|
-
daemon_lock = DaemonLock()
|
|
154
|
-
if not daemon_lock.acquire():
|
|
155
|
-
sys.exit(1)
|
|
156
|
-
|
|
157
|
-
# Register cleanup to release lock
|
|
158
|
-
def release_lock():
|
|
159
|
-
daemon_lock.release()
|
|
160
|
-
|
|
161
|
-
atexit.register(release_lock)
|
|
162
|
-
|
|
163
147
|
# Print banner (not in daemon mode)
|
|
164
148
|
if not args.daemon:
|
|
165
149
|
print()
|
|
@@ -362,6 +346,7 @@ def main():
|
|
|
362
346
|
embeddings_provider=config.embeddings_provider,
|
|
363
347
|
reranker_provider=config.reranker_provider,
|
|
364
348
|
mcp_enabled=config.mcp_enabled,
|
|
349
|
+
version=__version__,
|
|
365
350
|
)
|
|
366
351
|
|
|
367
352
|
# Start idle checker in daemon mode
|
hindsight_api/migrations.py
CHANGED
|
@@ -165,6 +165,81 @@ def run_migrations(
|
|
|
165
165
|
logger.debug("Migration advisory lock acquired")
|
|
166
166
|
|
|
167
167
|
try:
|
|
168
|
+
# Ensure pgvector extension is installed globally BEFORE schema migrations
|
|
169
|
+
# This is critical: the extension must exist database-wide before any schema
|
|
170
|
+
# migrations run, otherwise custom schemas won't have access to vector types
|
|
171
|
+
logger.debug("Checking pgvector extension availability...")
|
|
172
|
+
|
|
173
|
+
# First, check if extension already exists
|
|
174
|
+
ext_check = conn.execute(
|
|
175
|
+
text(
|
|
176
|
+
"SELECT extname, nspname FROM pg_extension e "
|
|
177
|
+
"JOIN pg_namespace n ON e.extnamespace = n.oid "
|
|
178
|
+
"WHERE extname = 'vector'"
|
|
179
|
+
)
|
|
180
|
+
).fetchone()
|
|
181
|
+
|
|
182
|
+
if ext_check:
|
|
183
|
+
# Extension exists - check if in correct schema
|
|
184
|
+
ext_schema = ext_check[1]
|
|
185
|
+
if ext_schema == "public":
|
|
186
|
+
logger.info("pgvector extension found in public schema - ready to use")
|
|
187
|
+
else:
|
|
188
|
+
# Extension in wrong schema - try to fix if we have permissions
|
|
189
|
+
logger.warning(
|
|
190
|
+
f"pgvector extension found in schema '{ext_schema}' instead of 'public'. "
|
|
191
|
+
f"Attempting to relocate..."
|
|
192
|
+
)
|
|
193
|
+
try:
|
|
194
|
+
conn.execute(text("DROP EXTENSION vector CASCADE"))
|
|
195
|
+
conn.execute(text("SET search_path TO public"))
|
|
196
|
+
conn.execute(text("CREATE EXTENSION vector"))
|
|
197
|
+
conn.commit()
|
|
198
|
+
logger.info("pgvector extension relocated to public schema")
|
|
199
|
+
except Exception as e:
|
|
200
|
+
# Failed to relocate - log but don't fail if extension exists somewhere
|
|
201
|
+
logger.warning(
|
|
202
|
+
f"Could not relocate pgvector extension to public schema: {e}. "
|
|
203
|
+
f"Continuing with extension in '{ext_schema}' schema."
|
|
204
|
+
)
|
|
205
|
+
conn.rollback()
|
|
206
|
+
else:
|
|
207
|
+
# Extension doesn't exist - try to install
|
|
208
|
+
logger.info("pgvector extension not found, attempting to install...")
|
|
209
|
+
try:
|
|
210
|
+
conn.execute(text("SET search_path TO public"))
|
|
211
|
+
conn.execute(text("CREATE EXTENSION vector"))
|
|
212
|
+
conn.commit()
|
|
213
|
+
logger.info("pgvector extension installed in public schema")
|
|
214
|
+
except Exception as e:
|
|
215
|
+
# Installation failed - this is only fatal if extension truly doesn't exist
|
|
216
|
+
# Check one more time in case another process installed it
|
|
217
|
+
conn.rollback()
|
|
218
|
+
ext_recheck = conn.execute(
|
|
219
|
+
text(
|
|
220
|
+
"SELECT nspname FROM pg_extension e "
|
|
221
|
+
"JOIN pg_namespace n ON e.extnamespace = n.oid "
|
|
222
|
+
"WHERE extname = 'vector'"
|
|
223
|
+
)
|
|
224
|
+
).fetchone()
|
|
225
|
+
|
|
226
|
+
if ext_recheck:
|
|
227
|
+
logger.warning(
|
|
228
|
+
f"Could not install pgvector extension (permission denied?), "
|
|
229
|
+
f"but extension exists in '{ext_recheck[0]}' schema. Continuing..."
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
# Extension truly doesn't exist and we can't install it
|
|
233
|
+
logger.error(
|
|
234
|
+
f"pgvector extension is not installed and cannot be installed: {e}. "
|
|
235
|
+
f"Please ensure pgvector is installed by a database administrator. "
|
|
236
|
+
f"See: https://github.com/pgvector/pgvector#installation"
|
|
237
|
+
)
|
|
238
|
+
raise RuntimeError(
|
|
239
|
+
"pgvector extension is required but not installed. "
|
|
240
|
+
"Please install it with: CREATE EXTENSION vector;"
|
|
241
|
+
) from e
|
|
242
|
+
|
|
168
243
|
# Run migrations while holding the lock
|
|
169
244
|
_run_migrations_internal(database_url, script_location, schema=schema)
|
|
170
245
|
finally:
|
hindsight_api/worker/main.py
CHANGED
|
@@ -176,7 +176,7 @@ def main():
|
|
|
176
176
|
nonlocal memory, poller
|
|
177
177
|
import uvicorn
|
|
178
178
|
|
|
179
|
-
from ..extensions import TenantExtension, load_extension
|
|
179
|
+
from ..extensions import OperationValidatorExtension, TenantExtension, load_extension
|
|
180
180
|
|
|
181
181
|
# Load tenant extension BEFORE creating MemoryEngine so it can
|
|
182
182
|
# set correct schema context during task execution. Without this,
|
|
@@ -184,6 +184,12 @@ def main():
|
|
|
184
184
|
# causing worker writes to land in the wrong schema.
|
|
185
185
|
tenant_extension = load_extension("TENANT", TenantExtension)
|
|
186
186
|
|
|
187
|
+
# Load operation validator so workers can record usage metering
|
|
188
|
+
# for async operations (e.g. refresh_mental_model after consolidation)
|
|
189
|
+
operation_validator = load_extension("OPERATION_VALIDATOR", OperationValidatorExtension)
|
|
190
|
+
if operation_validator:
|
|
191
|
+
logger.info(f"Loaded operation validator: {operation_validator.__class__.__name__}")
|
|
192
|
+
|
|
187
193
|
# Initialize MemoryEngine
|
|
188
194
|
# Workers use SyncTaskBackend because they execute tasks directly,
|
|
189
195
|
# they don't need to store tasks (they poll from DB)
|
|
@@ -191,6 +197,7 @@ def main():
|
|
|
191
197
|
run_migrations=False, # Workers don't run migrations
|
|
192
198
|
task_backend=SyncTaskBackend(),
|
|
193
199
|
tenant_extension=tenant_extension,
|
|
200
|
+
operation_validator=operation_validator,
|
|
194
201
|
)
|
|
195
202
|
|
|
196
203
|
await memory.initialize()
|
|
@@ -200,15 +207,20 @@ def main():
|
|
|
200
207
|
if tenant_extension:
|
|
201
208
|
print("Tenant extension loaded - schemas will be discovered dynamically on each poll")
|
|
202
209
|
else:
|
|
203
|
-
print("No tenant extension configured, using
|
|
210
|
+
print(f"No tenant extension configured, using schema: {config.database_schema}")
|
|
204
211
|
|
|
205
212
|
# Create a single poller that handles all schemas dynamically
|
|
213
|
+
# Convert default schema to None for SQL compatibility (no schema prefix)
|
|
214
|
+
from hindsight_api.config import DEFAULT_DATABASE_SCHEMA
|
|
215
|
+
|
|
216
|
+
schema = None if config.database_schema == DEFAULT_DATABASE_SCHEMA else config.database_schema
|
|
206
217
|
poller = WorkerPoller(
|
|
207
218
|
pool=memory._pool,
|
|
208
219
|
worker_id=args.worker_id,
|
|
209
220
|
executor=memory.execute_task,
|
|
210
221
|
poll_interval_ms=args.poll_interval,
|
|
211
222
|
max_retries=args.max_retries,
|
|
223
|
+
schema=schema,
|
|
212
224
|
tenant_extension=tenant_extension,
|
|
213
225
|
max_slots=config.worker_max_slots,
|
|
214
226
|
consolidation_max_slots=config.worker_consolidation_max_slots,
|
|
@@ -217,15 +229,30 @@ def main():
|
|
|
217
229
|
# Create the HTTP app for metrics/health
|
|
218
230
|
app = create_worker_app(poller, memory)
|
|
219
231
|
|
|
220
|
-
# Setup signal handlers for graceful shutdown
|
|
232
|
+
# Setup signal handlers for graceful shutdown using asyncio
|
|
221
233
|
shutdown_requested = asyncio.Event()
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
234
|
+
force_exit = False
|
|
235
|
+
|
|
236
|
+
loop = asyncio.get_event_loop()
|
|
237
|
+
|
|
238
|
+
def signal_handler():
|
|
239
|
+
nonlocal force_exit
|
|
240
|
+
if shutdown_requested.is_set():
|
|
241
|
+
# Second signal = force exit
|
|
242
|
+
print("\nReceived second signal, forcing immediate exit...")
|
|
243
|
+
force_exit = True
|
|
244
|
+
# Restore default handler so third signal kills process
|
|
245
|
+
loop.remove_signal_handler(signal.SIGINT)
|
|
246
|
+
loop.remove_signal_handler(signal.SIGTERM)
|
|
247
|
+
sys.exit(1)
|
|
248
|
+
else:
|
|
249
|
+
print("\nReceived shutdown signal, initiating graceful shutdown...")
|
|
250
|
+
print("(Press Ctrl+C again to force immediate exit)")
|
|
251
|
+
shutdown_requested.set()
|
|
252
|
+
|
|
253
|
+
# Use asyncio's signal handlers which work properly with the event loop
|
|
254
|
+
loop.add_signal_handler(signal.SIGINT, signal_handler)
|
|
255
|
+
loop.add_signal_handler(signal.SIGTERM, signal_handler)
|
|
229
256
|
|
|
230
257
|
# Create uvicorn config and server
|
|
231
258
|
uvicorn_config = uvicorn.Config(
|
|
@@ -244,7 +271,10 @@ def main():
|
|
|
244
271
|
print(f"Worker started. Metrics available at http://{args.http_host}:{args.http_port}/metrics")
|
|
245
272
|
|
|
246
273
|
# Wait for shutdown signal
|
|
247
|
-
|
|
274
|
+
try:
|
|
275
|
+
await shutdown_requested.wait()
|
|
276
|
+
except KeyboardInterrupt:
|
|
277
|
+
print("\nReceived interrupt, initiating graceful shutdown...")
|
|
248
278
|
|
|
249
279
|
# Graceful shutdown
|
|
250
280
|
print("Shutting down HTTP server...")
|
hindsight_api/worker/poller.py
CHANGED
|
@@ -72,9 +72,9 @@ class WorkerPoller:
|
|
|
72
72
|
executor: Async function to execute tasks (typically MemoryEngine.execute_task)
|
|
73
73
|
poll_interval_ms: Interval between polls when no tasks found (milliseconds)
|
|
74
74
|
max_retries: Maximum retry attempts before marking task as failed
|
|
75
|
-
schema: Database schema for single-tenant support (
|
|
76
|
-
tenant_extension: Extension for dynamic multi-tenant discovery. If
|
|
77
|
-
|
|
75
|
+
schema: Database schema for single-tenant support (deprecated, use tenant_extension)
|
|
76
|
+
tenant_extension: Extension for dynamic multi-tenant discovery. If None, creates a
|
|
77
|
+
DefaultTenantExtension with the configured schema.
|
|
78
78
|
max_slots: Maximum concurrent tasks per worker
|
|
79
79
|
consolidation_max_slots: Maximum concurrent consolidation tasks per worker
|
|
80
80
|
"""
|
|
@@ -84,6 +84,13 @@ class WorkerPoller:
|
|
|
84
84
|
self._poll_interval_ms = poll_interval_ms
|
|
85
85
|
self._max_retries = max_retries
|
|
86
86
|
self._schema = schema
|
|
87
|
+
# Always set tenant extension (use DefaultTenantExtension if none provided)
|
|
88
|
+
if tenant_extension is None:
|
|
89
|
+
from ..extensions.builtin.tenant import DefaultTenantExtension
|
|
90
|
+
|
|
91
|
+
# Pass schema parameter to DefaultTenantExtension if explicitly provided
|
|
92
|
+
config = {"schema": schema} if schema else {}
|
|
93
|
+
tenant_extension = DefaultTenantExtension(config=config)
|
|
87
94
|
self._tenant_extension = tenant_extension
|
|
88
95
|
self._max_slots = max_slots
|
|
89
96
|
self._consolidation_max_slots = consolidation_max_slots
|
|
@@ -99,13 +106,12 @@ class WorkerPoller:
|
|
|
99
106
|
self._in_flight_by_type: dict[str, int] = {}
|
|
100
107
|
|
|
101
108
|
async def _get_schemas(self) -> list[str | None]:
|
|
102
|
-
"""Get list of schemas to poll. Returns [None] for
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
return [self._schema]
|
|
109
|
+
"""Get list of schemas to poll. Returns [None] for default schema (no prefix)."""
|
|
110
|
+
from ..config import DEFAULT_DATABASE_SCHEMA
|
|
111
|
+
|
|
112
|
+
tenants = await self._tenant_extension.list_tenants()
|
|
113
|
+
# Convert default schema to None for SQL compatibility (no prefix), keep others as-is
|
|
114
|
+
return [t.schema if t.schema != DEFAULT_DATABASE_SCHEMA else None for t in tenants]
|
|
109
115
|
|
|
110
116
|
async def _get_available_slots(self) -> tuple[int, int]:
|
|
111
117
|
"""
|
|
@@ -194,7 +200,9 @@ class WorkerPoller:
|
|
|
194
200
|
try:
|
|
195
201
|
return await self._claim_batch_for_schema_inner(schema, limit, consolidation_limit)
|
|
196
202
|
except Exception as e:
|
|
197
|
-
|
|
203
|
+
# Format schema for logging: custom schemas in quotes, None as-is
|
|
204
|
+
schema_display = f'"{schema}"' if schema else str(schema)
|
|
205
|
+
logger.warning(f"Worker {self._worker_id} failed to claim tasks for schema {schema_display}: {e}")
|
|
198
206
|
return []
|
|
199
207
|
|
|
200
208
|
async def _claim_batch_for_schema_inner(
|
|
@@ -418,7 +426,9 @@ class WorkerPoller:
|
|
|
418
426
|
count = int(result.split()[-1]) if result else 0
|
|
419
427
|
total_count += count
|
|
420
428
|
except Exception as e:
|
|
421
|
-
|
|
429
|
+
# Format schema for logging: custom schemas in quotes, None as-is
|
|
430
|
+
schema_display = f'"{schema}"' if schema else str(schema)
|
|
431
|
+
logger.warning(f"Worker {self._worker_id} failed to recover tasks for schema {schema_display}: {e}")
|
|
422
432
|
|
|
423
433
|
if total_count > 0:
|
|
424
434
|
logger.info(f"Worker {self._worker_id} recovered {total_count} stale tasks from previous run")
|
|
@@ -457,7 +467,8 @@ class WorkerPoller:
|
|
|
457
467
|
consolidation_count += 1
|
|
458
468
|
|
|
459
469
|
types_str = ", ".join(f"{k}:{v}" for k, v in task_types.items())
|
|
460
|
-
|
|
470
|
+
# Display None as "default" in logs
|
|
471
|
+
schemas_str = ", ".join(s if s else "default" for s in schemas_seen)
|
|
461
472
|
logger.info(
|
|
462
473
|
f"Worker {self._worker_id} claimed {len(tasks)} tasks "
|
|
463
474
|
f"({consolidation_count} consolidation): {types_str} (schemas: {schemas_str})"
|
|
@@ -591,7 +602,8 @@ class WorkerPoller:
|
|
|
591
602
|
other_workers.append(f"{wid}:{cnt}")
|
|
592
603
|
others_str = ", ".join(other_workers) if other_workers else "none"
|
|
593
604
|
|
|
594
|
-
|
|
605
|
+
# Display None as "default" in logs
|
|
606
|
+
schemas_str = ", ".join(s if s else "default" for s in schemas)
|
|
595
607
|
logger.info(
|
|
596
608
|
f"[WORKER_STATS] worker={self._worker_id} "
|
|
597
609
|
f"slots={in_flight}/{self._max_slots} (consolidation={consolidation_count}/{self._consolidation_max_slots}) | "
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hindsight-api
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.8
|
|
4
4
|
Summary: Hindsight: Agent Memory That Works Like Human Memory
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: aiohttp>=3.13.3
|
|
@@ -8,6 +8,7 @@ Requires-Dist: alembic>=1.17.1
|
|
|
8
8
|
Requires-Dist: anthropic>=0.40.0
|
|
9
9
|
Requires-Dist: asyncpg>=0.29.0
|
|
10
10
|
Requires-Dist: authlib>=1.6.6
|
|
11
|
+
Requires-Dist: claude-agent-sdk>=0.1.27
|
|
11
12
|
Requires-Dist: cohere>=5.0.0
|
|
12
13
|
Requires-Dist: dateparser>=1.2.2
|
|
13
14
|
Requires-Dist: fastapi[standard]>=0.120.3
|