Skip to content

Commit e6511e7

Browse files
authored
feat: refactor hindsight-embed architecture (#66)
* feat: refactor hindsight-embed architecture * feat: refactor hindsight-embed architecture * refactor deamin * refactor deamin * refactor deamin * refactor deamin
1 parent 904ea4d commit e6511e7

13 files changed

Lines changed: 1215 additions & 287 deletions

File tree

hindsight-api/hindsight_api/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
3434
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
3535

36+
# Optimization flags
37+
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
38+
ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
39+
3640
# Default values
3741
DEFAULT_DATABASE_URL = "pg0"
3842
DEFAULT_LLM_PROVIDER = "openai"
@@ -107,6 +111,10 @@ class HindsightConfig:
107111
# Recall
108112
graph_retriever: str
109113

114+
# Optimization flags
115+
skip_llm_verification: bool
116+
lazy_reranker: bool
117+
110118
@classmethod
111119
def from_env(cls) -> "HindsightConfig":
112120
"""Create configuration from environment variables."""
@@ -133,6 +141,9 @@ def from_env(cls) -> "HindsightConfig":
133141
mcp_enabled=os.getenv(ENV_MCP_ENABLED, str(DEFAULT_MCP_ENABLED)).lower() == "true",
134142
# Recall
135143
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
144+
# Optimization flags
145+
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
146+
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
136147
)
137148

138149
def get_llm_base_url(self) -> str:
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""
2+
Daemon mode support for Hindsight API.
3+
4+
Provides idle timeout and lockfile management for running as a background daemon.
5+
"""
6+
7+
import asyncio
8+
import fcntl
9+
import logging
10+
import os
11+
import sys
12+
import time
13+
from pathlib import Path
14+
15+
logger = logging.getLogger(__name__)
16+
17+
# Default daemon configuration
18+
DEFAULT_DAEMON_PORT = 8889
19+
DEFAULT_IDLE_TIMEOUT = 0 # 0 = no auto-exit (hindsight-embed passes its own timeout)
20+
LOCKFILE_PATH = Path.home() / ".hindsight" / "daemon.lock"
21+
DAEMON_LOG_PATH = Path.home() / ".hindsight" / "daemon.log"
22+
23+
24+
class IdleTimeoutMiddleware:
25+
"""ASGI middleware that tracks activity and exits after idle timeout."""
26+
27+
def __init__(self, app, idle_timeout: int = DEFAULT_IDLE_TIMEOUT):
28+
self.app = app
29+
self.idle_timeout = idle_timeout
30+
self.last_activity = time.time()
31+
self._checker_task = None
32+
33+
async def __call__(self, scope, receive, send):
34+
# Update activity timestamp on each request
35+
self.last_activity = time.time()
36+
await self.app(scope, receive, send)
37+
38+
def start_idle_checker(self):
39+
"""Start the background task that checks for idle timeout."""
40+
self._checker_task = asyncio.create_task(self._check_idle())
41+
42+
async def _check_idle(self):
43+
"""Background task that exits the process after idle timeout."""
44+
# If idle_timeout is 0, don't auto-exit
45+
if self.idle_timeout <= 0:
46+
return
47+
48+
while True:
49+
await asyncio.sleep(30) # Check every 30 seconds
50+
idle_time = time.time() - self.last_activity
51+
if idle_time > self.idle_timeout:
52+
logger.info(f"Idle timeout reached ({self.idle_timeout}s), shutting down daemon")
53+
# Give a moment for any in-flight requests
54+
await asyncio.sleep(1)
55+
os._exit(0)
56+
57+
58+
class DaemonLock:
59+
"""
60+
File-based lock to prevent multiple daemon instances.
61+
62+
Uses fcntl.flock for atomic locking on Unix systems.
63+
"""
64+
65+
def __init__(self, lockfile: Path = LOCKFILE_PATH):
66+
self.lockfile = lockfile
67+
self._fd = None
68+
69+
def acquire(self) -> bool:
70+
"""
71+
Try to acquire the daemon lock.
72+
73+
Returns True if lock acquired, False if another daemon is running.
74+
"""
75+
self.lockfile.parent.mkdir(parents=True, exist_ok=True)
76+
77+
try:
78+
self._fd = open(self.lockfile, "w")
79+
fcntl.flock(self._fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
80+
# Write PID for debugging
81+
self._fd.write(str(os.getpid()))
82+
self._fd.flush()
83+
return True
84+
except (IOError, OSError):
85+
# Lock is held by another process
86+
if self._fd:
87+
self._fd.close()
88+
self._fd = None
89+
return False
90+
91+
def release(self):
92+
"""Release the daemon lock."""
93+
if self._fd:
94+
try:
95+
fcntl.flock(self._fd.fileno(), fcntl.LOCK_UN)
96+
self._fd.close()
97+
except Exception:
98+
pass
99+
finally:
100+
self._fd = None
101+
# Remove lockfile
102+
try:
103+
self.lockfile.unlink()
104+
except Exception:
105+
pass
106+
107+
def is_locked(self) -> bool:
108+
"""Check if the lock is held by another process."""
109+
if not self.lockfile.exists():
110+
return False
111+
112+
try:
113+
fd = open(self.lockfile, "r")
114+
fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
115+
# We got the lock, so no one else has it
116+
fcntl.flock(fd.fileno(), fcntl.LOCK_UN)
117+
fd.close()
118+
return False
119+
except (IOError, OSError):
120+
return True
121+
122+
def get_pid(self) -> int | None:
123+
"""Get the PID of the daemon holding the lock."""
124+
if not self.lockfile.exists():
125+
return None
126+
try:
127+
with open(self.lockfile, "r") as f:
128+
return int(f.read().strip())
129+
except (ValueError, IOError):
130+
return None
131+
132+
133+
def daemonize():
134+
"""
135+
Fork the current process into a background daemon.
136+
137+
Uses double-fork technique to properly detach from terminal.
138+
"""
139+
# First fork
140+
pid = os.fork()
141+
if pid > 0:
142+
# Parent exits
143+
sys.exit(0)
144+
145+
# Create new session
146+
os.setsid()
147+
148+
# Second fork to prevent zombie processes
149+
pid = os.fork()
150+
if pid > 0:
151+
sys.exit(0)
152+
153+
# Redirect standard file descriptors to log file
154+
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
155+
156+
sys.stdout.flush()
157+
sys.stderr.flush()
158+
159+
# Redirect stdin to /dev/null
160+
with open("/dev/null", "r") as devnull:
161+
os.dup2(devnull.fileno(), sys.stdin.fileno())
162+
163+
# Redirect stdout/stderr to log file
164+
log_fd = open(DAEMON_LOG_PATH, "a")
165+
os.dup2(log_fd.fileno(), sys.stdout.fileno())
166+
os.dup2(log_fd.fileno(), sys.stderr.fileno())
167+
168+
169+
def check_daemon_running(port: int = DEFAULT_DAEMON_PORT) -> bool:
170+
"""Check if a daemon is running and responsive on the given port."""
171+
import socket
172+
173+
try:
174+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
175+
sock.settimeout(1)
176+
result = sock.connect_ex(("127.0.0.1", port))
177+
sock.close()
178+
return result == 0
179+
except Exception:
180+
return False
181+
182+
183+
def stop_daemon(port: int = DEFAULT_DAEMON_PORT) -> bool:
184+
"""Stop a running daemon by sending SIGTERM to the process."""
185+
lock = DaemonLock()
186+
pid = lock.get_pid()
187+
188+
if pid is None:
189+
return False
190+
191+
try:
192+
import signal
193+
194+
os.kill(pid, signal.SIGTERM)
195+
# Wait for process to exit
196+
for _ in range(50): # Wait up to 5 seconds
197+
time.sleep(0.1)
198+
try:
199+
os.kill(pid, 0) # Check if process exists
200+
except OSError:
201+
return True # Process exited
202+
return False
203+
except OSError:
204+
return False

hindsight-api/hindsight_api/engine/memory_engine.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,8 @@ def __init__(
202202
run_migrations: bool = True,
203203
operation_validator: "OperationValidatorExtension | None" = None,
204204
tenant_extension: "TenantExtension | None" = None,
205+
skip_llm_verification: bool | None = None,
206+
lazy_reranker: bool | None = None,
205207
):
206208
"""
207209
Initialize the temporal + semantic memory system.
@@ -227,12 +229,23 @@ def __init__(
227229
If provided, retain/recall/reflect operations will be validated.
228230
tenant_extension: Optional extension for multi-tenancy and API key authentication.
229231
If provided, operations require a RequestContext for authentication.
232+
skip_llm_verification: Skip LLM connection verification during initialization.
233+
Defaults to HINDSIGHT_API_SKIP_LLM_VERIFICATION env var or False.
234+
lazy_reranker: Delay reranker initialization until first use. Useful for retain-only
235+
operations that don't need the cross-encoder. Defaults to
236+
HINDSIGHT_API_LAZY_RERANKER env var or False.
230237
"""
231238
# Load config from environment for any missing parameters
232239
from ..config import get_config
233240

234241
config = get_config()
235242

243+
# Apply optimization flags from config if not explicitly provided
244+
self._skip_llm_verification = (
245+
skip_llm_verification if skip_llm_verification is not None else config.skip_llm_verification
246+
)
247+
self._lazy_reranker = lazy_reranker if lazy_reranker is not None else config.lazy_reranker
248+
236249
# Apply defaults from config
237250
db_url = db_url or config.database_url
238251
memory_llm_provider = memory_llm_provider or config.llm_provider
@@ -592,6 +605,8 @@ async def init_cross_encoder():
592605
await loop.run_in_executor(None, lambda: asyncio.run(cross_encoder.initialize()))
593606
else:
594607
await cross_encoder.initialize()
608+
# Mark reranker as initialized
609+
self._cross_encoder_reranker._initialized = True
595610

596611
async def init_query_analyzer():
597612
"""Initialize query analyzer model."""
@@ -600,16 +615,26 @@ async def init_query_analyzer():
600615

601616
async def verify_llm():
602617
"""Verify LLM connection is working."""
603-
await self._llm_config.verify_connection()
618+
if not self._skip_llm_verification:
619+
await self._llm_config.verify_connection()
604620

605-
# Run pg0 and all model initializations in parallel
606-
await asyncio.gather(
621+
# Build list of initialization tasks
622+
init_tasks = [
607623
start_pg0(),
608624
init_embeddings(),
609-
init_cross_encoder(),
610625
init_query_analyzer(),
611-
verify_llm(),
612-
)
626+
]
627+
628+
# Only init cross-encoder eagerly if not using lazy initialization
629+
if not self._lazy_reranker:
630+
init_tasks.append(init_cross_encoder())
631+
632+
# Only verify LLM if not skipping
633+
if not self._skip_llm_verification:
634+
init_tasks.append(verify_llm())
635+
636+
# Run pg0 and selected model initializations in parallel
637+
await asyncio.gather(*init_tasks)
613638

614639
# Run database migrations if enabled
615640
if self._run_migrations:
@@ -1639,6 +1664,9 @@ def to_tuple_format(results):
16391664
step_start = time.time()
16401665
reranker_instance = self._cross_encoder_reranker
16411666

1667+
# Ensure reranker is initialized (for lazy initialization mode)
1668+
await reranker_instance.ensure_initialized()
1669+
16421670
# Rerank using cross-encoder
16431671
scored_results = reranker_instance.rerank(query, merged_candidates)
16441672

hindsight-api/hindsight_api/engine/search/reranking.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ def __init__(self, cross_encoder=None):
2626

2727
cross_encoder = create_cross_encoder_from_env()
2828
self.cross_encoder = cross_encoder
29+
self._initialized = False
30+
31+
async def ensure_initialized(self):
32+
"""Ensure the cross-encoder model is initialized (for lazy initialization)."""
33+
if self._initialized:
34+
return
35+
36+
import asyncio
37+
38+
cross_encoder = self.cross_encoder
39+
# For local providers, run in thread pool to avoid blocking event loop
40+
if cross_encoder.provider_name == "local":
41+
loop = asyncio.get_event_loop()
42+
await loop.run_in_executor(None, lambda: asyncio.run(cross_encoder.initialize()))
43+
else:
44+
await cross_encoder.initialize()
45+
self._initialized = True
2946

3047
def rerank(self, query: str, candidates: list[MergedCandidate]) -> list[ScoredResult]:
3148
"""

0 commit comments

Comments
 (0)