hindsight-api 0.1.11__py3-none-any.whl → 0.1.13__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 (47) hide show
  1. hindsight_api/__init__.py +2 -0
  2. hindsight_api/alembic/env.py +24 -1
  3. hindsight_api/alembic/versions/d9f6a3b4c5e2_rename_bank_to_interactions.py +14 -4
  4. hindsight_api/alembic/versions/e0a1b2c3d4e5_disposition_to_3_traits.py +54 -13
  5. hindsight_api/alembic/versions/rename_personality_to_disposition.py +18 -7
  6. hindsight_api/api/http.py +253 -230
  7. hindsight_api/api/mcp.py +14 -3
  8. hindsight_api/config.py +11 -0
  9. hindsight_api/daemon.py +204 -0
  10. hindsight_api/engine/__init__.py +12 -1
  11. hindsight_api/engine/entity_resolver.py +38 -37
  12. hindsight_api/engine/interface.py +592 -0
  13. hindsight_api/engine/llm_wrapper.py +176 -6
  14. hindsight_api/engine/memory_engine.py +1092 -293
  15. hindsight_api/engine/retain/bank_utils.py +13 -12
  16. hindsight_api/engine/retain/chunk_storage.py +3 -2
  17. hindsight_api/engine/retain/fact_storage.py +10 -7
  18. hindsight_api/engine/retain/link_utils.py +17 -16
  19. hindsight_api/engine/retain/observation_regeneration.py +17 -16
  20. hindsight_api/engine/retain/orchestrator.py +2 -3
  21. hindsight_api/engine/retain/types.py +25 -8
  22. hindsight_api/engine/search/graph_retrieval.py +6 -5
  23. hindsight_api/engine/search/mpfp_retrieval.py +8 -7
  24. hindsight_api/engine/search/reranking.py +17 -0
  25. hindsight_api/engine/search/retrieval.py +12 -11
  26. hindsight_api/engine/search/think_utils.py +1 -1
  27. hindsight_api/engine/search/tracer.py +1 -1
  28. hindsight_api/engine/task_backend.py +32 -0
  29. hindsight_api/extensions/__init__.py +66 -0
  30. hindsight_api/extensions/base.py +81 -0
  31. hindsight_api/extensions/builtin/__init__.py +18 -0
  32. hindsight_api/extensions/builtin/tenant.py +33 -0
  33. hindsight_api/extensions/context.py +110 -0
  34. hindsight_api/extensions/http.py +89 -0
  35. hindsight_api/extensions/loader.py +125 -0
  36. hindsight_api/extensions/operation_validator.py +325 -0
  37. hindsight_api/extensions/tenant.py +63 -0
  38. hindsight_api/main.py +97 -17
  39. hindsight_api/mcp_local.py +7 -1
  40. hindsight_api/migrations.py +54 -10
  41. hindsight_api/models.py +15 -0
  42. hindsight_api/pg0.py +1 -1
  43. {hindsight_api-0.1.11.dist-info → hindsight_api-0.1.13.dist-info}/METADATA +1 -1
  44. hindsight_api-0.1.13.dist-info/RECORD +75 -0
  45. hindsight_api-0.1.11.dist-info/RECORD +0 -64
  46. {hindsight_api-0.1.11.dist-info → hindsight_api-0.1.13.dist-info}/WHEEL +0 -0
  47. {hindsight_api-0.1.11.dist-info → hindsight_api-0.1.13.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,325 @@
1
+ """Operation Validator Extension for validating retain/recall/reflect operations."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from hindsight_api.extensions.base import Extension
9
+
10
+ if TYPE_CHECKING:
11
+ from hindsight_api.engine.memory_engine import Budget
12
+ from hindsight_api.engine.response_models import RecallResult as RecallResultModel
13
+ from hindsight_api.engine.response_models import ReflectResult
14
+ from hindsight_api.models import RequestContext
15
+
16
+
17
+ class OperationValidationError(Exception):
18
+ """Raised when an operation fails validation."""
19
+
20
+ def __init__(self, reason: str):
21
+ self.reason = reason
22
+ super().__init__(f"Operation validation failed: {reason}")
23
+
24
+
25
+ @dataclass
26
+ class ValidationResult:
27
+ """Result of an operation validation."""
28
+
29
+ allowed: bool
30
+ reason: str | None = None
31
+
32
+ @classmethod
33
+ def accept(cls) -> "ValidationResult":
34
+ """Create an accepted validation result."""
35
+ return cls(allowed=True)
36
+
37
+ @classmethod
38
+ def reject(cls, reason: str) -> "ValidationResult":
39
+ """Create a rejected validation result with a reason."""
40
+ return cls(allowed=False, reason=reason)
41
+
42
+
43
+ # =============================================================================
44
+ # Pre-operation Contexts (all user-provided parameters)
45
+ # =============================================================================
46
+
47
+
48
+ @dataclass
49
+ class RetainContext:
50
+ """Context for a retain operation validation (pre-operation).
51
+
52
+ Contains ALL user-provided parameters for the retain operation.
53
+ """
54
+
55
+ bank_id: str
56
+ contents: list[dict] # List of {content, context, event_date, document_id}
57
+ request_context: "RequestContext"
58
+ document_id: str | None = None
59
+ fact_type_override: str | None = None
60
+ confidence_score: float | None = None
61
+
62
+
63
+ @dataclass
64
+ class RecallContext:
65
+ """Context for a recall operation validation (pre-operation).
66
+
67
+ Contains ALL user-provided parameters for the recall operation.
68
+ """
69
+
70
+ bank_id: str
71
+ query: str
72
+ request_context: "RequestContext"
73
+ budget: "Budget | None" = None
74
+ max_tokens: int = 4096
75
+ enable_trace: bool = False
76
+ fact_types: list[str] = field(default_factory=list)
77
+ question_date: datetime | None = None
78
+ include_entities: bool = False
79
+ max_entity_tokens: int = 500
80
+ include_chunks: bool = False
81
+ max_chunk_tokens: int = 8192
82
+
83
+
84
+ @dataclass
85
+ class ReflectContext:
86
+ """Context for a reflect operation validation (pre-operation).
87
+
88
+ Contains ALL user-provided parameters for the reflect operation.
89
+ """
90
+
91
+ bank_id: str
92
+ query: str
93
+ request_context: "RequestContext"
94
+ budget: "Budget | None" = None
95
+ context: str | None = None
96
+
97
+
98
+ # =============================================================================
99
+ # Post-operation Contexts (includes results)
100
+ # =============================================================================
101
+
102
+
103
+ @dataclass
104
+ class RetainResult:
105
+ """Result context for post-retain hook.
106
+
107
+ Contains the operation parameters and the result.
108
+ """
109
+
110
+ bank_id: str
111
+ contents: list[dict]
112
+ request_context: "RequestContext"
113
+ document_id: str | None
114
+ fact_type_override: str | None
115
+ confidence_score: float | None
116
+ # Result
117
+ unit_ids: list[list[str]] # List of unit IDs per content item
118
+ success: bool = True
119
+ error: str | None = None
120
+
121
+
122
+ @dataclass
123
+ class RecallResult:
124
+ """Result context for post-recall hook.
125
+
126
+ Contains the operation parameters and the result.
127
+ """
128
+
129
+ bank_id: str
130
+ query: str
131
+ request_context: "RequestContext"
132
+ budget: "Budget | None"
133
+ max_tokens: int
134
+ enable_trace: bool
135
+ fact_types: list[str]
136
+ question_date: datetime | None
137
+ include_entities: bool
138
+ max_entity_tokens: int
139
+ include_chunks: bool
140
+ max_chunk_tokens: int
141
+ # Result
142
+ result: "RecallResultModel | None" = None
143
+ success: bool = True
144
+ error: str | None = None
145
+
146
+
147
+ @dataclass
148
+ class ReflectResultContext:
149
+ """Result context for post-reflect hook.
150
+
151
+ Contains the operation parameters and the result.
152
+ """
153
+
154
+ bank_id: str
155
+ query: str
156
+ request_context: "RequestContext"
157
+ budget: "Budget | None"
158
+ context: str | None
159
+ # Result
160
+ result: "ReflectResult | None" = None
161
+ success: bool = True
162
+ error: str | None = None
163
+
164
+
165
+ class OperationValidatorExtension(Extension, ABC):
166
+ """
167
+ Validates and hooks into retain/recall/reflect operations.
168
+
169
+ This extension allows implementing custom logic such as:
170
+ - Rate limiting (pre-operation)
171
+ - Quota enforcement (pre-operation)
172
+ - Permission checks (pre-operation)
173
+ - Content filtering (pre-operation)
174
+ - Usage tracking (post-operation)
175
+ - Audit logging (post-operation)
176
+ - Metrics collection (post-operation)
177
+
178
+ Enable via environment variable:
179
+ HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator
180
+
181
+ Configuration is passed from prefixed environment variables:
182
+ HINDSIGHT_API_OPERATION_VALIDATOR_MAX_REQUESTS=100
183
+ -> config = {"max_requests": "100"}
184
+
185
+ Hook execution order:
186
+ 1. validate_retain/validate_recall/validate_reflect (pre-operation)
187
+ 2. [operation executes]
188
+ 3. on_retain_complete/on_recall_complete/on_reflect_complete (post-operation)
189
+ """
190
+
191
+ # =========================================================================
192
+ # Pre-operation validation hooks (abstract - must be implemented)
193
+ # =========================================================================
194
+
195
+ @abstractmethod
196
+ async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
197
+ """
198
+ Validate a retain operation before execution.
199
+
200
+ Called before the retain operation is processed. Return ValidationResult.reject()
201
+ to prevent the operation from executing.
202
+
203
+ Args:
204
+ ctx: Context containing all user-provided parameters:
205
+ - bank_id: Bank identifier
206
+ - contents: List of content dicts
207
+ - request_context: Request context with auth info
208
+ - document_id: Optional document ID
209
+ - fact_type_override: Optional fact type override
210
+ - confidence_score: Optional confidence score
211
+
212
+ Returns:
213
+ ValidationResult indicating whether the operation is allowed.
214
+ """
215
+ ...
216
+
217
+ @abstractmethod
218
+ async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
219
+ """
220
+ Validate a recall operation before execution.
221
+
222
+ Called before the recall operation is processed. Return ValidationResult.reject()
223
+ to prevent the operation from executing.
224
+
225
+ Args:
226
+ ctx: Context containing all user-provided parameters:
227
+ - bank_id: Bank identifier
228
+ - query: Search query
229
+ - request_context: Request context with auth info
230
+ - budget: Budget level
231
+ - max_tokens: Maximum tokens to return
232
+ - enable_trace: Whether to include trace info
233
+ - fact_types: List of fact types to search
234
+ - question_date: Optional date context for query
235
+ - include_entities: Whether to include entity data
236
+ - max_entity_tokens: Max tokens for entities
237
+ - include_chunks: Whether to include chunks
238
+ - max_chunk_tokens: Max tokens for chunks
239
+
240
+ Returns:
241
+ ValidationResult indicating whether the operation is allowed.
242
+ """
243
+ ...
244
+
245
+ @abstractmethod
246
+ async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
247
+ """
248
+ Validate a reflect operation before execution.
249
+
250
+ Called before the reflect operation is processed. Return ValidationResult.reject()
251
+ to prevent the operation from executing.
252
+
253
+ Args:
254
+ ctx: Context containing all user-provided parameters:
255
+ - bank_id: Bank identifier
256
+ - query: Question to answer
257
+ - request_context: Request context with auth info
258
+ - budget: Budget level
259
+ - context: Optional additional context
260
+
261
+ Returns:
262
+ ValidationResult indicating whether the operation is allowed.
263
+ """
264
+ ...
265
+
266
+ # =========================================================================
267
+ # Post-operation hooks (optional - override to implement)
268
+ # =========================================================================
269
+
270
+ async def on_retain_complete(self, result: RetainResult) -> None:
271
+ """
272
+ Called after a retain operation completes (success or failure).
273
+
274
+ Override this method to implement post-operation logic such as:
275
+ - Usage tracking
276
+ - Audit logging
277
+ - Metrics collection
278
+ - Notifications
279
+
280
+ Args:
281
+ result: Result context containing:
282
+ - All original operation parameters
283
+ - unit_ids: List of created unit IDs (if success)
284
+ - success: Whether the operation succeeded
285
+ - error: Error message (if failed)
286
+ """
287
+ pass
288
+
289
+ async def on_recall_complete(self, result: RecallResult) -> None:
290
+ """
291
+ Called after a recall operation completes (success or failure).
292
+
293
+ Override this method to implement post-operation logic such as:
294
+ - Usage tracking
295
+ - Audit logging
296
+ - Metrics collection
297
+ - Query analytics
298
+
299
+ Args:
300
+ result: Result context containing:
301
+ - All original operation parameters
302
+ - result: RecallResultModel (if success)
303
+ - success: Whether the operation succeeded
304
+ - error: Error message (if failed)
305
+ """
306
+ pass
307
+
308
+ async def on_reflect_complete(self, result: ReflectResultContext) -> None:
309
+ """
310
+ Called after a reflect operation completes (success or failure).
311
+
312
+ Override this method to implement post-operation logic such as:
313
+ - Usage tracking
314
+ - Audit logging
315
+ - Metrics collection
316
+ - Response analytics
317
+
318
+ Args:
319
+ result: Result context containing:
320
+ - All original operation parameters
321
+ - result: ReflectResult (if success)
322
+ - success: Whether the operation succeeded
323
+ - error: Error message (if failed)
324
+ """
325
+ pass
@@ -0,0 +1,63 @@
1
+ """Tenant Extension for multi-tenancy and API key authentication."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+
6
+ from hindsight_api.extensions.base import Extension
7
+ from hindsight_api.models import RequestContext
8
+
9
+
10
+ class AuthenticationError(Exception):
11
+ """Raised when authentication fails."""
12
+
13
+ def __init__(self, reason: str):
14
+ self.reason = reason
15
+ super().__init__(f"Authentication failed: {reason}")
16
+
17
+
18
+ @dataclass
19
+ class TenantContext:
20
+ """
21
+ Tenant context returned by authentication.
22
+
23
+ Contains the PostgreSQL schema name for tenant isolation.
24
+ All database queries will use fully-qualified table names
25
+ with this schema (e.g., schema_name.memory_units).
26
+ """
27
+
28
+ schema_name: str
29
+
30
+
31
+ class TenantExtension(Extension, ABC):
32
+ """
33
+ Extension for multi-tenancy and API key authentication.
34
+
35
+ This extension validates incoming requests and returns the tenant context
36
+ including the PostgreSQL schema to use for database operations.
37
+
38
+ Built-in implementation:
39
+ hindsight_api.extensions.builtin.tenant.ApiKeyTenantExtension
40
+
41
+ Enable via environment variable:
42
+ HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
43
+ HINDSIGHT_API_TENANT_API_KEY=your-secret-key
44
+
45
+ The returned schema_name is used for fully-qualified table names in queries,
46
+ enabling tenant isolation at the database level.
47
+ """
48
+
49
+ @abstractmethod
50
+ async def authenticate(self, context: RequestContext) -> TenantContext:
51
+ """
52
+ Authenticate the action context and return tenant context.
53
+
54
+ Args:
55
+ context: The action context containing API key and other auth data.
56
+
57
+ Returns:
58
+ TenantContext with the schema_name for database operations.
59
+
60
+ Raises:
61
+ AuthenticationError: If authentication fails.
62
+ """
63
+ ...
hindsight_api/main.py CHANGED
@@ -4,6 +4,9 @@ Command-line interface for Hindsight API.
4
4
  Run the server with:
5
5
  hindsight-api
6
6
 
7
+ Run as background daemon:
8
+ hindsight-api --daemon
9
+
7
10
  Stop with Ctrl+C.
8
11
  """
9
12
 
@@ -21,9 +24,13 @@ from . import MemoryEngine
21
24
  from .api import create_app
22
25
  from .banner import print_banner
23
26
  from .config import HindsightConfig, get_config
24
-
25
- print()
26
- print_banner()
27
+ from .daemon import (
28
+ DEFAULT_DAEMON_PORT,
29
+ DEFAULT_IDLE_TIMEOUT,
30
+ DaemonLock,
31
+ IdleTimeoutMiddleware,
32
+ daemonize,
33
+ )
27
34
 
28
35
  # Filter deprecation warnings from third-party libraries
29
36
  warnings.filterwarnings("ignore", message="websockets.legacy is deprecated")
@@ -106,8 +113,52 @@ def main():
106
113
  parser.add_argument("--ssl-keyfile", default=None, help="SSL key file")
107
114
  parser.add_argument("--ssl-certfile", default=None, help="SSL certificate file")
108
115
 
116
+ # Daemon mode options
117
+ parser.add_argument(
118
+ "--daemon",
119
+ action="store_true",
120
+ help=f"Run as background daemon (uses port {DEFAULT_DAEMON_PORT}, auto-exits after idle)",
121
+ )
122
+ parser.add_argument(
123
+ "--idle-timeout",
124
+ type=int,
125
+ default=DEFAULT_IDLE_TIMEOUT,
126
+ help=f"Idle timeout in seconds before auto-exit in daemon mode (default: {DEFAULT_IDLE_TIMEOUT})",
127
+ )
128
+
109
129
  args = parser.parse_args()
110
130
 
131
+ # Daemon mode handling
132
+ if args.daemon:
133
+ # Use fixed daemon port
134
+ args.port = DEFAULT_DAEMON_PORT
135
+ args.host = "127.0.0.1" # Only bind to localhost for security
136
+
137
+ # Check if another daemon is already running
138
+ daemon_lock = DaemonLock()
139
+ if not daemon_lock.acquire():
140
+ print(f"Daemon already running (PID: {daemon_lock.get_pid()})", file=sys.stderr)
141
+ sys.exit(1)
142
+
143
+ # Fork into background
144
+ daemonize()
145
+
146
+ # Re-acquire lock in child process
147
+ daemon_lock = DaemonLock()
148
+ if not daemon_lock.acquire():
149
+ sys.exit(1)
150
+
151
+ # Register cleanup to release lock
152
+ def release_lock():
153
+ daemon_lock.release()
154
+
155
+ atexit.register(release_lock)
156
+
157
+ # Print banner (not in daemon mode)
158
+ if not args.daemon:
159
+ print()
160
+ print_banner()
161
+
111
162
  # Configure Python logging based on log level
112
163
  # Update config with CLI override if provided
113
164
  if args.log_level != config.log_level:
@@ -128,9 +179,12 @@ def main():
128
179
  log_level=args.log_level,
129
180
  mcp_enabled=config.mcp_enabled,
130
181
  graph_retriever=config.graph_retriever,
182
+ skip_llm_verification=config.skip_llm_verification,
183
+ lazy_reranker=config.lazy_reranker,
131
184
  )
132
185
  config.configure_logging()
133
- config.log_config()
186
+ if not args.daemon:
187
+ config.log_config()
134
188
 
135
189
  # Register cleanup handlers
136
190
  atexit.register(_cleanup)
@@ -149,6 +203,12 @@ def main():
149
203
  initialize_memory=True,
150
204
  )
151
205
 
206
+ # Wrap with idle timeout middleware in daemon mode
207
+ idle_middleware = None
208
+ if args.daemon:
209
+ idle_middleware = IdleTimeoutMiddleware(app, idle_timeout=args.idle_timeout)
210
+ app = idle_middleware
211
+
152
212
  # Prepare uvicorn config
153
213
  uvicorn_config = {
154
214
  "app": app,
@@ -172,20 +232,40 @@ def main():
172
232
  if args.ssl_certfile:
173
233
  uvicorn_config["ssl_certfile"] = args.ssl_certfile
174
234
 
175
- from .banner import print_startup_info
176
-
177
- print_startup_info(
178
- host=args.host,
179
- port=args.port,
180
- database_url=config.database_url,
181
- llm_provider=config.llm_provider,
182
- llm_model=config.llm_model,
183
- embeddings_provider=config.embeddings_provider,
184
- reranker_provider=config.reranker_provider,
185
- mcp_enabled=config.mcp_enabled,
186
- )
235
+ # Print startup info (not in daemon mode)
236
+ if not args.daemon:
237
+ from .banner import print_startup_info
238
+
239
+ print_startup_info(
240
+ host=args.host,
241
+ port=args.port,
242
+ database_url=config.database_url,
243
+ llm_provider=config.llm_provider,
244
+ llm_model=config.llm_model,
245
+ embeddings_provider=config.embeddings_provider,
246
+ reranker_provider=config.reranker_provider,
247
+ mcp_enabled=config.mcp_enabled,
248
+ )
249
+
250
+ # Start idle checker in daemon mode
251
+ if idle_middleware is not None:
252
+ # Start the idle checker in a background thread with its own event loop
253
+ import threading
254
+
255
+ def run_idle_checker():
256
+ import time
257
+
258
+ time.sleep(2) # Wait for uvicorn to start
259
+ try:
260
+ loop = asyncio.new_event_loop()
261
+ asyncio.set_event_loop(loop)
262
+ loop.run_until_complete(idle_middleware._check_idle())
263
+ except Exception:
264
+ pass
265
+
266
+ threading.Thread(target=run_idle_checker, daemon=True).start()
187
267
 
188
- uvicorn.run(**uvicorn_config)
268
+ uvicorn.run(**uvicorn_config) # type: ignore[invalid-argument-type] - dict kwargs
189
269
 
190
270
 
191
271
  if __name__ == "__main__":
@@ -87,6 +87,7 @@ def create_local_mcp_server(bank_id: str, memory=None) -> FastMCP:
87
87
  from hindsight_api import MemoryEngine
88
88
  from hindsight_api.engine.memory_engine import Budget
89
89
  from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
90
+ from hindsight_api.models import RequestContext
90
91
 
91
92
  # Create memory engine with pg0 embedded database if not provided
92
93
  if memory is None:
@@ -115,7 +116,11 @@ def create_local_mcp_server(bank_id: str, memory=None) -> FastMCP:
115
116
 
116
117
  async def _retain():
117
118
  try:
118
- await memory.retain_batch_async(bank_id=bank_id, contents=[{"content": content, "context": context}])
119
+ await memory.retain_batch_async(
120
+ bank_id=bank_id,
121
+ contents=[{"content": content, "context": context}],
122
+ request_context=RequestContext(),
123
+ )
119
124
  except Exception as e:
120
125
  logger.error(f"Error storing memory: {e}", exc_info=True)
121
126
 
@@ -142,6 +147,7 @@ def create_local_mcp_server(bank_id: str, memory=None) -> FastMCP:
142
147
  fact_type=list(VALID_RECALL_FACT_TYPES),
143
148
  budget=budget_enum,
144
149
  max_tokens=max_tokens,
150
+ request_context=RequestContext(),
145
151
  )
146
152
 
147
153
  return search_result.model_dump()