refactor(embedded): replace UI programmatic API with constructor flags (#728)
Replace start_ui()/stop_ui()/is_ui_running() methods with declarative constructor flags (ui, ui_port, ui_hostname). UI lifecycle now follows the daemon automatically - starts in _ensure_started, stops in _cleanup. Add integration test verifying UI starts and can reach the dataplane via the control plane's /api/health endpoint. Add Node.js setup to test-hindsight-all CI job to support the UI test.
This commit is contained in:
parent
a69bdbb55f
commit
7a3dbc1958
3 changed files with 131 additions and 54 deletions
22
.github/workflows/test.yml
vendored
22
.github/workflows/test.yml
vendored
|
|
@ -1822,6 +1822,28 @@ jobs:
|
||||||
with:
|
with:
|
||||||
python-version-file: ".python-version"
|
python-version-file: ".python-version"
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
cache-dependency-path: package-lock.json
|
||||||
|
|
||||||
|
- name: Install SDK dependencies
|
||||||
|
run: npm ci --workspace=hindsight-clients/typescript
|
||||||
|
|
||||||
|
- name: Build SDK
|
||||||
|
run: npm run build --workspace=hindsight-clients/typescript
|
||||||
|
|
||||||
|
- name: Install Control Plane dependencies
|
||||||
|
run: |
|
||||||
|
npm install --workspace=hindsight-control-plane
|
||||||
|
rm -rf node_modules/lightningcss node_modules/@tailwindcss
|
||||||
|
npm install lightningcss @tailwindcss/postcss @tailwindcss/node
|
||||||
|
|
||||||
|
- name: Build Control Plane
|
||||||
|
run: npm run build --workspace=hindsight-control-plane
|
||||||
|
|
||||||
- name: Build hindsight-all
|
- name: Build hindsight-all
|
||||||
working-directory: ./hindsight-all
|
working-directory: ./hindsight-all
|
||||||
run: uv build
|
run: uv build
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,9 @@ class HindsightEmbedded:
|
||||||
database_url: Optional database URL override (default: profile-specific pg0)
|
database_url: Optional database URL override (default: profile-specific pg0)
|
||||||
idle_timeout: Seconds before daemon auto-exits when idle (default: 300)
|
idle_timeout: Seconds before daemon auto-exits when idle (default: 300)
|
||||||
log_level: Daemon log level (default: "info")
|
log_level: Daemon log level (default: "info")
|
||||||
|
ui: Whether to start the control plane web UI alongside the daemon (default: False)
|
||||||
|
ui_port: Port for the UI. Defaults to daemon_port + 10000.
|
||||||
|
ui_hostname: Hostname to bind the UI to. Defaults to "0.0.0.0".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -85,6 +88,9 @@ class HindsightEmbedded:
|
||||||
database_url: Optional[str] = None,
|
database_url: Optional[str] = None,
|
||||||
idle_timeout: int = 300,
|
idle_timeout: int = 300,
|
||||||
log_level: str = "info",
|
log_level: str = "info",
|
||||||
|
ui: bool = False,
|
||||||
|
ui_port: Optional[int] = None,
|
||||||
|
ui_hostname: str = "0.0.0.0",
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize the embedded client (daemon starts on first use).
|
Initialize the embedded client (daemon starts on first use).
|
||||||
|
|
@ -98,6 +104,9 @@ class HindsightEmbedded:
|
||||||
database_url: Optional database URL override
|
database_url: Optional database URL override
|
||||||
idle_timeout: Seconds before daemon auto-exits when idle
|
idle_timeout: Seconds before daemon auto-exits when idle
|
||||||
log_level: Daemon log level
|
log_level: Daemon log level
|
||||||
|
ui: Whether to start the control plane web UI alongside the daemon
|
||||||
|
ui_port: Port for the UI (defaults to daemon_port + 10000)
|
||||||
|
ui_hostname: Hostname to bind the UI to (defaults to "0.0.0.0")
|
||||||
"""
|
"""
|
||||||
self.profile = profile
|
self.profile = profile
|
||||||
|
|
||||||
|
|
@ -116,6 +125,10 @@ class HindsightEmbedded:
|
||||||
if database_url:
|
if database_url:
|
||||||
self.config["HINDSIGHT_EMBED_API_DATABASE_URL"] = database_url
|
self.config["HINDSIGHT_EMBED_API_DATABASE_URL"] = database_url
|
||||||
|
|
||||||
|
self._ui = ui
|
||||||
|
self._ui_port = ui_port
|
||||||
|
self._ui_hostname = ui_hostname
|
||||||
|
|
||||||
self._client: Optional[Hindsight] = None
|
self._client: Optional[Hindsight] = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._started = False
|
self._started = False
|
||||||
|
|
@ -157,6 +170,15 @@ class HindsightEmbedded:
|
||||||
self._started = True
|
self._started = True
|
||||||
logger.info(f"Connected to daemon at {daemon_url}")
|
logger.info(f"Connected to daemon at {daemon_url}")
|
||||||
|
|
||||||
|
# Start UI if requested
|
||||||
|
if self._ui:
|
||||||
|
logger.info(f"Starting UI for profile '{self.profile}'...")
|
||||||
|
ui_started = self._manager.start_ui(
|
||||||
|
self.profile, self._ui_port, self._ui_hostname
|
||||||
|
)
|
||||||
|
if not ui_started:
|
||||||
|
logger.warning(f"Failed to start UI for profile '{self.profile}'")
|
||||||
|
|
||||||
def _cleanup(self, stop_daemon_on_close: bool = False):
|
def _cleanup(self, stop_daemon_on_close: bool = False):
|
||||||
"""
|
"""
|
||||||
Cleanup client resources (idempotent).
|
Cleanup client resources (idempotent).
|
||||||
|
|
@ -176,6 +198,11 @@ class HindsightEmbedded:
|
||||||
self._client.close()
|
self._client.close()
|
||||||
self._client = None
|
self._client = None
|
||||||
|
|
||||||
|
# Stop UI if it was started
|
||||||
|
if self._ui and self._started:
|
||||||
|
logger.info(f"Stopping UI for profile '{self.profile}'...")
|
||||||
|
self._manager.stop_ui(self.profile, self._ui_port)
|
||||||
|
|
||||||
# Optionally stop daemon (daemon has idle timeout, so not required)
|
# Optionally stop daemon (daemon has idle timeout, so not required)
|
||||||
if stop_daemon_on_close and self._started:
|
if stop_daemon_on_close and self._started:
|
||||||
logger.info(f"Stopping daemon for profile '{self.profile}'...")
|
logger.info(f"Stopping daemon for profile '{self.profile}'...")
|
||||||
|
|
@ -379,43 +406,6 @@ class HindsightEmbedded:
|
||||||
"""Check if the client is initialized."""
|
"""Check if the client is initialized."""
|
||||||
return self._started and not self._closed and self._client is not None
|
return self._started and not self._closed and self._client is not None
|
||||||
|
|
||||||
def start_ui(self, ui_port: int | None = None, hostname: str = "0.0.0.0") -> bool:
|
|
||||||
"""Start the control plane web UI.
|
|
||||||
|
|
||||||
The daemon is started automatically if not already running.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ui_port: Port for the UI. Defaults to daemon_port + 10000.
|
|
||||||
hostname: Hostname to bind to. Defaults to 0.0.0.0.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if UI started successfully.
|
|
||||||
"""
|
|
||||||
self._ensure_started()
|
|
||||||
return self._manager.start_ui(self.profile, ui_port, hostname)
|
|
||||||
|
|
||||||
def stop_ui(self, ui_port: int | None = None) -> bool:
|
|
||||||
"""Stop the control plane web UI.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ui_port: Port the UI is running on. Defaults to daemon_port + 10000.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if stopped successfully.
|
|
||||||
"""
|
|
||||||
return self._manager.stop_ui(self.profile, ui_port)
|
|
||||||
|
|
||||||
def is_ui_running(self, ui_port: int | None = None) -> bool:
|
|
||||||
"""Check if the control plane web UI is running.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ui_port: Port to check. Defaults to daemon_port + 10000.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if UI is running and responsive.
|
|
||||||
"""
|
|
||||||
return self._manager.is_ui_running(self.profile, ui_port)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ui_url(self) -> str:
|
def ui_url(self) -> str:
|
||||||
"""Get the UI URL for this profile."""
|
"""Get the UI URL for this profile."""
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ import os
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import urllib.request
|
||||||
|
import json
|
||||||
|
|
||||||
from hindsight import HindsightEmbedded
|
from hindsight import HindsightEmbedded
|
||||||
|
|
||||||
|
|
@ -23,12 +25,20 @@ from hindsight import HindsightEmbedded
|
||||||
def llm_config():
|
def llm_config():
|
||||||
"""Get LLM configuration from environment (session-scoped)."""
|
"""Get LLM configuration from environment (session-scoped)."""
|
||||||
# Try both naming conventions
|
# Try both naming conventions
|
||||||
provider = os.getenv("HINDSIGHT_API_LLM_PROVIDER") or os.getenv("HINDSIGHT_LLM_PROVIDER", "groq")
|
provider = os.getenv("HINDSIGHT_API_LLM_PROVIDER") or os.getenv(
|
||||||
api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY") or os.getenv("HINDSIGHT_LLM_API_KEY", "")
|
"HINDSIGHT_LLM_PROVIDER", "groq"
|
||||||
model = os.getenv("HINDSIGHT_API_LLM_MODEL") or os.getenv("HINDSIGHT_LLM_MODEL", "openai/gpt-oss-120b")
|
)
|
||||||
|
api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY") or os.getenv(
|
||||||
|
"HINDSIGHT_LLM_API_KEY", ""
|
||||||
|
)
|
||||||
|
model = os.getenv("HINDSIGHT_API_LLM_MODEL") or os.getenv(
|
||||||
|
"HINDSIGHT_LLM_MODEL", "openai/gpt-oss-120b"
|
||||||
|
)
|
||||||
|
|
||||||
if not api_key:
|
if not api_key:
|
||||||
pytest.skip("LLM API key not configured. Set HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_LLM_API_KEY.")
|
pytest.skip(
|
||||||
|
"LLM API key not configured. Set HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_LLM_API_KEY."
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"llm_provider": provider,
|
"llm_provider": provider,
|
||||||
|
|
@ -78,7 +88,9 @@ def test_embedded_context_manager(llm_config):
|
||||||
|
|
||||||
# Recall memory
|
# Recall memory
|
||||||
recall_results = client.recall(bank_id=bank_id, query="context")
|
recall_results = client.recall(bank_id=bank_id, query="context")
|
||||||
assert isinstance(recall_results.results, list), "Recall should return results list"
|
assert isinstance(recall_results.results, list), (
|
||||||
|
"Recall should return results list"
|
||||||
|
)
|
||||||
|
|
||||||
# Server should be stopped after context exit
|
# Server should be stopped after context exit
|
||||||
# Note: We can't check client.is_running here as client is out of scope
|
# Note: We can't check client.is_running here as client is out of scope
|
||||||
|
|
@ -105,7 +117,9 @@ def test_embedded_complete_workflow(llm_config):
|
||||||
# Step 1: Create a memory bank
|
# Step 1: Create a memory bank
|
||||||
print(f"\n1. Creating memory bank: {bank_id}")
|
print(f"\n1. Creating memory bank: {bank_id}")
|
||||||
bank_response = client.create_bank(
|
bank_response = client.create_bank(
|
||||||
bank_id=bank_id, name="Test Assistant", mission="Help with programming tasks"
|
bank_id=bank_id,
|
||||||
|
name="Test Assistant",
|
||||||
|
mission="Help with programming tasks",
|
||||||
)
|
)
|
||||||
assert bank_response.bank_id == bank_id
|
assert bank_response.bank_id == bank_id
|
||||||
|
|
||||||
|
|
@ -126,7 +140,9 @@ def test_embedded_complete_workflow(llm_config):
|
||||||
items=[
|
items=[
|
||||||
{"content": "User works with pandas and numpy."},
|
{"content": "User works with pandas and numpy."},
|
||||||
{"content": "User likes matplotlib for visualization."},
|
{"content": "User likes matplotlib for visualization."},
|
||||||
{"content": "User is interested in machine learning with scikit-learn."},
|
{
|
||||||
|
"content": "User is interested in machine learning with scikit-learn."
|
||||||
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
assert batch_response.success
|
assert batch_response.success
|
||||||
|
|
@ -134,7 +150,9 @@ def test_embedded_complete_workflow(llm_config):
|
||||||
|
|
||||||
# Step 4: Recall memories
|
# Step 4: Recall memories
|
||||||
print("\n4. Recalling memories...")
|
print("\n4. Recalling memories...")
|
||||||
recall_response = client.recall(bank_id=bank_id, query="What tools does the user prefer?", max_tokens=2000)
|
recall_response = client.recall(
|
||||||
|
bank_id=bank_id, query="What tools does the user prefer?", max_tokens=2000
|
||||||
|
)
|
||||||
assert isinstance(recall_response.results, list)
|
assert isinstance(recall_response.results, list)
|
||||||
assert len(recall_response.results) > 0
|
assert len(recall_response.results) > 0
|
||||||
print(f" Found {len(recall_response.results)} relevant memories")
|
print(f" Found {len(recall_response.results)} relevant memories")
|
||||||
|
|
@ -152,7 +170,9 @@ def test_embedded_complete_workflow(llm_config):
|
||||||
|
|
||||||
# Verify answer mentions relevant tools
|
# Verify answer mentions relevant tools
|
||||||
answer_lower = reflect_response.text.lower()
|
answer_lower = reflect_response.text.lower()
|
||||||
assert any(term in answer_lower for term in ["python", "pandas", "numpy", "data"])
|
assert any(
|
||||||
|
term in answer_lower for term in ["python", "pandas", "numpy", "data"]
|
||||||
|
)
|
||||||
|
|
||||||
# Step 6: List memories
|
# Step 6: List memories
|
||||||
print("\n6. Listing memories...")
|
print("\n6. Listing memories...")
|
||||||
|
|
@ -215,7 +235,9 @@ def test_embedded_method_proxying(llm_config):
|
||||||
assert bank.bank_id == bank_id
|
assert bank.bank_id == bank_id
|
||||||
|
|
||||||
# Test mission setting
|
# Test mission setting
|
||||||
mission_response = client.set_mission(bank_id=bank_id, mission="Test mission for proxying")
|
mission_response = client.set_mission(
|
||||||
|
bank_id=bank_id, mission="Test mission for proxying"
|
||||||
|
)
|
||||||
assert mission_response.bank_id == bank_id
|
assert mission_response.bank_id == bank_id
|
||||||
|
|
||||||
# Test retain
|
# Test retain
|
||||||
|
|
@ -264,7 +286,9 @@ def test_embedded_multiple_banks(llm_config):
|
||||||
|
|
||||||
# Create second bank and store data
|
# Create second bank and store data
|
||||||
client.create_bank(bank_id=bank2_id, name="Bank 2")
|
client.create_bank(bank_id=bank2_id, name="Bank 2")
|
||||||
client.retain(bank_id=bank2_id, content="Bob uses JavaScript for web development")
|
client.retain(
|
||||||
|
bank_id=bank2_id, content="Bob uses JavaScript for web development"
|
||||||
|
)
|
||||||
|
|
||||||
# Recall from both banks
|
# Recall from both banks
|
||||||
results1 = client.recall(bank_id=bank1_id, query="programming language")
|
results1 = client.recall(bank_id=bank1_id, query="programming language")
|
||||||
|
|
@ -275,9 +299,9 @@ def test_embedded_multiple_banks(llm_config):
|
||||||
|
|
||||||
# Verify banks are isolated (each should only see their own content)
|
# Verify banks are isolated (each should only see their own content)
|
||||||
# This is a basic check - content isolation is tested more thoroughly in other tests
|
# This is a basic check - content isolation is tested more thoroughly in other tests
|
||||||
assert results1.results[0].text != results2.results[0].text or len(results1.results) != len(
|
assert results1.results[0].text != results2.results[0].text or len(
|
||||||
results2.results
|
results1.results
|
||||||
)
|
) != len(results2.results)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
|
|
@ -296,10 +320,14 @@ def test_embedded_profile_isolation(llm_config):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Store data in profile1
|
# Store data in profile1
|
||||||
client1.retain(bank_id=bank_id, content="User likes TypeScript for frontend development")
|
client1.retain(
|
||||||
|
bank_id=bank_id, content="User likes TypeScript for frontend development"
|
||||||
|
)
|
||||||
|
|
||||||
# Store different data in profile2
|
# Store different data in profile2
|
||||||
client2.retain(bank_id=bank_id, content="User prefers Rust for systems programming")
|
client2.retain(
|
||||||
|
bank_id=bank_id, content="User prefers Rust for systems programming"
|
||||||
|
)
|
||||||
|
|
||||||
# Each profile should only see its own data
|
# Each profile should only see its own data
|
||||||
results1 = client1.recall(bank_id=bank_id, query="programming preference")
|
results1 = client1.recall(bank_id=bank_id, query="programming preference")
|
||||||
|
|
@ -334,5 +362,42 @@ def test_embedded_error_after_close(llm_config):
|
||||||
assert not client.is_running
|
assert not client.is_running
|
||||||
|
|
||||||
# Trying to use it after close should raise an error
|
# Trying to use it after close should raise an error
|
||||||
with pytest.raises(RuntimeError, match="Cannot use HindsightEmbedded after it has been closed"):
|
with pytest.raises(
|
||||||
|
RuntimeError, match="Cannot use HindsightEmbedded after it has been closed"
|
||||||
|
):
|
||||||
client.retain(bank_id=bank_id, content="This should fail")
|
client.retain(bank_id=bank_id, content="This should fail")
|
||||||
|
|
||||||
|
|
||||||
|
def test_embedded_ui_flag(llm_config):
|
||||||
|
"""
|
||||||
|
Test that ui=True starts the control plane UI alongside the daemon,
|
||||||
|
and that the UI's health endpoint reports a connected dataplane.
|
||||||
|
"""
|
||||||
|
profile = f"test_ui_{uuid.uuid4().hex[:8]}"
|
||||||
|
bank_id = f"bank_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
client = HindsightEmbedded(profile=profile, log_level="info", ui=True, **llm_config)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# First use triggers daemon + UI startup
|
||||||
|
result = client.retain(bank_id=bank_id, content="UI integration test content")
|
||||||
|
assert result.success, "Retain should succeed"
|
||||||
|
assert client.is_running, "Daemon should be running"
|
||||||
|
|
||||||
|
# Verify UI is reachable and reports connected dataplane
|
||||||
|
ui_url = client.ui_url
|
||||||
|
assert ui_url, "ui_url should be set"
|
||||||
|
|
||||||
|
health_url = f"{ui_url}/api/health"
|
||||||
|
with urllib.request.urlopen(health_url, timeout=10) as resp:
|
||||||
|
health = json.loads(resp.read().decode())
|
||||||
|
|
||||||
|
assert health["status"] == "ok", (
|
||||||
|
f"UI health status should be 'ok', got: {health['status']}"
|
||||||
|
)
|
||||||
|
assert health["dataplane"]["status"] == "connected", (
|
||||||
|
f"Dataplane should be connected, got: {health['dataplane']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue