feat: implement hierarchical configuration (system, tenant, bank) (#329)
* feat: implement hierarchical configuration (system, tenant, bank) * feat: implement hierarchical configuration (system, tenant, bank) * docs: add instructions for hierarchical config in CLAUDE.md * feat: add ENABLE_BANK_CONFIG_API flag (disabled by default) - Add HINDSIGHT_API_ENABLE_BANK_CONFIG_API env var (default: false) - Return 403 Forbidden from bank config endpoints when disabled - Update tests to enable the flag - Update CLAUDE.md documentation This provides security control over the bank configuration API, ensuring it's only accessible when explicitly enabled. * docs: add hierarchical configuration section * feat(cli): add bank config commands (config, set-config, reset-config) - Add 'hindsight bank config' to view bank configuration - Add 'hindsight bank set-config' to update LLM settings per bank - Add 'hindsight bank reset-config' to reset to defaults - Implements client API calls to new bank config endpoints * fix(cli): fix compilation errors in bank config commands - Fix type signature: use ApiClient instead of api::Client - Fix confirmation: use ui::prompt_confirmation instead of ui::confirm - Fix error handling: use anyhow! macro instead of errors::Error - Fix type conversion: convert HashMap to serde_json::Map for API call * feat: implement type-safe hierarchical config with bank overrides Implements a production-ready hierarchical configuration system that prevents accidentally using global defaults when bank-specific overrides exist. - Created StaticConfigProxy that wraps HindsightConfig - get_config() now returns proxy that blocks access to bank-configurable fields - Raises ConfigFieldAccessError with clear message when accessing configurable fields - Added _get_raw_config() for internal use only - Forces developers to use resolve_full_config(bank_id, context) for bank settings - Added resolve_full_config() method that returns complete HindsightConfig - Resolves hierarchy: Global (env) → Tenant → Bank - No caching to support multi-server deployments (always fresh from DB) - LLM provider pooling handles expensive operations separately - Updated entire retain pipeline to pass resolved config through call chain - memory_engine.py: Resolves config at top level where bank_id/context available - orchestrator.py: Accepts and passes config to fact_extraction - fact_extraction.py: Uses passed config instead of get_config() - utils.py: Added optional config param for backward compatibility - consolidator.py: Uses resolve_full_config() for enable_observations check - memory_engine.py: Resolves config before triggering consolidation - Renamed "Memory Bank" to "Bank Configuration" with tabs - Combined Stats and Operations into "General" tab - Consolidated Profile and Configuration into "Configuration" tab - Moved Actions dropdown to page level (outside tabs) - Created new component for managing bank-specific config - Displays configurable fields: retain_chunk_size, retain_extraction_mode, etc. - Edit via dialog with form validation - Reset to defaults via AlertDialog confirmation - Shows field IDs in monospace for clarity - Visual separation with borders and hover effects - Removed inline edit mode, switched to dialog-based editing - Separate dialogs for Disposition and Mission editing - Read-only display with clear edit buttons - Removed duplicate stats cards and operations - bank-stats-view.tsx: Overview statistics (memories, links, documents, pending ops) - bank-operations-view.tsx: Background operations table with filtering **Problem**: Consolidation always used global enable_observations, ignoring bank overrides **Root Cause**: consolidator.py called get_config() instead of resolving bank-specific config **Solution**: Pass resolved config through the entire pipeline **Problem**: asyncpg returning JSONB as JSON string instead of parsed dict **Solution**: Explicit JSON parsing in config_resolver.py with type checking - All 19 API integration tests pass - All 10 hierarchical config tests pass - Retain operations work correctly with bank-specific config - Consolidation respects bank-specific enable_observations setting - Updated developer/configuration.md with type-safe config access pattern - Added examples showing correct usage patterns - Documented ConfigFieldAccessError and resolution methods - get_config() now returns StaticConfigProxy (blocks configurable field access) - Code accessing bank-configurable fields must use resolve_full_config() - Clear migration path with helpful error messages Fixes hierarchical configuration to be production-ready with proper type safety. * refactor: remove LLM client pool and simplify config resolver Since LLM config (provider, model, api_key) is now static and not bank-configurable, the LLMClientPool is no longer needed. Changes: - Remove hindsight_api/llm_client_pool.py (no longer needed) - Remove memory_engine._get_bank_llm_config() (dead code, never called) - Simplify config_resolver.py by eliminating duplication between resolve_full_config() and get_bank_config() - get_bank_config() now calls resolve_full_config() and filters results - Remove outdated "LLM provider pooling" comments from docstrings All tests pass (10 hierarchical config tests, 19 API integration tests) * fix: update tests to use _get_raw_config() for configurable fields Fixed test fixtures that were accessing configurable fields (like enable_observations) from get_config(), which now raises ConfigFieldAccessError due to type-safe config access. Changes: - test_consolidation.py: Changed enable_observations fixture to use _get_raw_config() instead of get_config() - test_consolidation.py: Updated test_consolidation_returns_disabled_status to set bank config instead of mocking get_config() - test_link_expansion_retrieval.py: Changed fixture to use _get_raw_config() - test_observations.py: Changed disable_observations fixture to use _get_raw_config() - Regenerated OpenAPI spec and clients All 39 previously failing tests now pass. * fix: add missing config parameter to test calls of extract_facts_from_text() Fixed 45 test failures where tests were calling extract_facts_from_text() without the new required config parameter. Changes: - Added config=_get_raw_config() to all extract_facts_from_text() calls - Fixed test_main_module.py to patch _get_raw_config instead of get_config - Updated 6 test files with 37 function call sites All tests should now pass. * fix: add missing config parameter to test_skip_podcast_meta_commentary One more test was missing the config parameter for extract_facts_from_text().
This commit is contained in:
parent
f9a8a8e01e
commit
8d731f2e5f
48 changed files with 5026 additions and 609 deletions
48
CLAUDE.md
48
CLAUDE.md
|
|
@ -238,26 +238,61 @@ def process(data: UserData) -> str:
|
|||
|
||||
### Adding New API Configuration Flags
|
||||
|
||||
When adding a new environment variable configuration:
|
||||
Configuration follows a hierarchical system: **Global (env vars) → Tenant (via extension) → Bank (database)**.
|
||||
|
||||
Fields must be categorized as either **hierarchical** (can be overridden per-tenant/bank) or **static** (server-level only).
|
||||
|
||||
#### Adding a New Configuration Field
|
||||
|
||||
1. **config.py** (`hindsight-api/hindsight_api/config.py`):
|
||||
- Add `ENV_*` constant for the environment variable name
|
||||
- Add `ENV_*` constant for the environment variable name (e.g., `ENV_MY_SETTING = "HINDSIGHT_API_MY_SETTING"`)
|
||||
- Add `DEFAULT_*` constant for the default value
|
||||
- Add field to `HindsightConfig` dataclass
|
||||
- Add field to `HindsightConfig` dataclass with type annotation
|
||||
- **Mark as hierarchical or static** by adding to `_HIERARCHICAL_FIELDS` set (hierarchical) or leaving it out (static)
|
||||
- Add initialization in `from_env()` method
|
||||
|
||||
```python
|
||||
# Hierarchical field (can be overridden per-bank)
|
||||
_HIERARCHICAL_FIELDS = {
|
||||
...,
|
||||
"my_setting", # Add here for hierarchical
|
||||
}
|
||||
|
||||
# Static field - just don't add to _HIERARCHICAL_FIELDS
|
||||
```
|
||||
|
||||
2. **main.py** (`hindsight-api/hindsight_api/main.py`):
|
||||
- Add field to the manual `HindsightConfig()` constructor call (search for "CLI override")
|
||||
|
||||
3. **Use the config** in code:
|
||||
3. **Use hierarchical config in MemoryEngine**:
|
||||
```python
|
||||
# Config is resolved automatically per bank via ConfigResolver
|
||||
config_dict = await self._config_resolver.get_bank_config(bank_id, context)
|
||||
value = config_dict["my_setting"]
|
||||
```
|
||||
|
||||
4. **Use static config** (non-hierarchical):
|
||||
```python
|
||||
from ...config import get_config
|
||||
config = get_config()
|
||||
value = config.your_new_field
|
||||
value = config.my_static_field
|
||||
```
|
||||
|
||||
4. **Documentation** (`hindsight-docs/docs/developer/configuration.md`):
|
||||
5. **Documentation** (`hindsight-docs/docs/developer/configuration.md`):
|
||||
- Add to appropriate section table with Variable, Description, Default
|
||||
- Mark if it's hierarchical (can be overridden per-bank)
|
||||
|
||||
#### Hierarchical vs Static Guidelines
|
||||
|
||||
**Hierarchical** (per-bank overridable):
|
||||
- LLM settings (provider, model, API key, base URL)
|
||||
- Operation-specific settings (retain mode, chunk size, etc.)
|
||||
- Feature flags that vary by customer/bank
|
||||
|
||||
**Static** (server-level only):
|
||||
- Infrastructure settings (database URL, port, host)
|
||||
- Global limits (max concurrent operations)
|
||||
- System-wide feature flags
|
||||
|
||||
## Environment Setup
|
||||
|
||||
|
|
@ -281,3 +316,4 @@ Optional (uses local models by default):
|
|||
- `HINDSIGHT_API_EMBEDDINGS_PROVIDER`: local (default) or tei
|
||||
- `HINDSIGHT_API_RERANKER_PROVIDER`: local (default) or tei
|
||||
- `HINDSIGHT_API_DATABASE_URL`: External PostgreSQL (uses embedded pg0 by default)
|
||||
- `HINDSIGHT_API_ENABLE_BANK_CONFIG_API`: Enable per-bank config API (default: false, disabled for security)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
"""Add config JSONB column to banks table for hierarchical configuration
|
||||
|
||||
Revision ID: x9s0t1u2v3w4
|
||||
Revises: w8r9s0t1u2v3
|
||||
Create Date: 2026-02-09
|
||||
|
||||
This migration adds a `config` JSONB column to the banks table to support
|
||||
per-bank configuration overrides. This enables hierarchical configuration where:
|
||||
- Global config is loaded from environment variables
|
||||
- Tenant config is provided via TenantExtension
|
||||
- Bank config overrides are stored in banks.config JSONB column
|
||||
|
||||
The config column stores overrides for hierarchical fields (LLM settings,
|
||||
retention parameters, retrieval settings, etc.) in Python field name format.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import context, op
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
revision: str = "x9s0t1u2v3w4"
|
||||
down_revision: str | Sequence[str] | None = "w8r9s0t1u2v3"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Get schema prefix for table names (required for multi-tenant support)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add config JSONB column to banks table with GIN index."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Add config column to banks table
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}banks
|
||||
ADD COLUMN config JSONB NOT NULL DEFAULT '{{}}'::jsonb
|
||||
""")
|
||||
|
||||
# Add GIN index for efficient JSONB queries
|
||||
op.execute(f"""
|
||||
CREATE INDEX idx_banks_config
|
||||
ON {schema}banks
|
||||
USING gin(config)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove config column and index from banks table."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Drop index first
|
||||
op.execute(f"DROP INDEX IF EXISTS {schema}idx_banks_config")
|
||||
|
||||
# Drop column
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}banks
|
||||
DROP COLUMN IF EXISTS config
|
||||
""")
|
||||
|
|
@ -70,6 +70,7 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
|
|||
return Field(default_factory=default_factory, json_schema_extra=json_extra, **kwargs)
|
||||
|
||||
|
||||
from hindsight_api.config import get_config
|
||||
from hindsight_api.engine.db_utils import acquire_with_retry
|
||||
from hindsight_api.engine.memory_engine import Budget, _get_tiktoken_encoding, fq_table
|
||||
from hindsight_api.engine.reflect.observations import Observation
|
||||
|
|
@ -826,6 +827,55 @@ class CreateBankRequest(BaseModel):
|
|||
background: str | None = Field(default=None, description="Deprecated: use mission instead")
|
||||
|
||||
|
||||
class BankConfigUpdate(BaseModel):
|
||||
"""Request model for updating bank configuration."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"updates": {
|
||||
"llm_model": "claude-sonnet-4-5",
|
||||
"retain_extraction_mode": "verbose",
|
||||
"retain_custom_instructions": "Extract technical details carefully",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
updates: dict[str, Any] = Field(
|
||||
description="Configuration overrides. Keys can be in Python field format (llm_provider) "
|
||||
"or environment variable format (HINDSIGHT_API_LLM_PROVIDER). "
|
||||
"Only hierarchical fields can be overridden per-bank."
|
||||
)
|
||||
|
||||
|
||||
class BankConfigResponse(BaseModel):
|
||||
"""Response model for bank configuration."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"bank_id": "my-bank",
|
||||
"config": {
|
||||
"llm_provider": "openai",
|
||||
"llm_model": "gpt-4",
|
||||
"retain_extraction_mode": "verbose",
|
||||
},
|
||||
"overrides": {
|
||||
"llm_model": "gpt-4",
|
||||
"retain_extraction_mode": "verbose",
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
bank_id: str = Field(description="Bank identifier")
|
||||
config: dict[str, Any] = Field(
|
||||
description="Fully resolved configuration with all hierarchical overrides applied (Python field names)"
|
||||
)
|
||||
overrides: dict[str, Any] = Field(description="Bank-specific configuration overrides only (Python field names)")
|
||||
|
||||
|
||||
class GraphDataResponse(BaseModel):
|
||||
"""Response model for graph data endpoint."""
|
||||
|
||||
|
|
@ -1355,6 +1405,7 @@ class FeaturesInfo(BaseModel):
|
|||
observations: bool = Field(description="Whether observations (auto-consolidation) are enabled")
|
||||
mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled")
|
||||
worker: bool = Field(description="Whether the background worker is enabled")
|
||||
bank_config_api: bool = Field(description="Whether per-bank configuration API is enabled")
|
||||
|
||||
|
||||
class VersionResponse(BaseModel):
|
||||
|
|
@ -1368,6 +1419,7 @@ class VersionResponse(BaseModel):
|
|||
"observations": False,
|
||||
"mcp": True,
|
||||
"worker": True,
|
||||
"bank_config_api": False,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1647,17 +1699,21 @@ def _register_routes(app: FastAPI):
|
|||
|
||||
Returns version info and feature flags that can be used by clients
|
||||
to determine which capabilities are available.
|
||||
|
||||
Note: observations flag shows the global default. Individual banks
|
||||
may override this setting via bank-specific configuration.
|
||||
"""
|
||||
from hindsight_api import __version__
|
||||
from hindsight_api.config import get_config
|
||||
from hindsight_api.config import _get_raw_config
|
||||
|
||||
config = get_config()
|
||||
config = _get_raw_config()
|
||||
return VersionResponse(
|
||||
api_version=__version__,
|
||||
features=FeaturesInfo(
|
||||
observations=config.enable_observations,
|
||||
mcp=config.mcp_enabled,
|
||||
worker=config.worker_enabled,
|
||||
bank_config_api=config.enable_bank_config_api,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -3311,6 +3367,112 @@ def _register_routes(app: FastAPI):
|
|||
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/observations: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get(
|
||||
"/v1/default/banks/{bank_id}/config",
|
||||
response_model=BankConfigResponse,
|
||||
summary="Get bank configuration",
|
||||
description="Get fully resolved configuration for a bank including all hierarchical overrides (global → tenant → bank). "
|
||||
"The 'config' field contains all resolved config values. The 'overrides' field shows only bank-specific overrides.",
|
||||
operation_id="get_bank_config",
|
||||
tags=["Banks"],
|
||||
)
|
||||
async def api_get_bank_config(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
|
||||
"""Get configuration for a bank with all hierarchical overrides applied."""
|
||||
if not get_config().enable_bank_config_api:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Bank configuration API is disabled. Set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true to enable.",
|
||||
)
|
||||
try:
|
||||
# Get resolved config from config resolver
|
||||
config_dict = await app.state.memory._config_resolver.get_bank_config(bank_id, request_context)
|
||||
|
||||
# Get bank-specific overrides only
|
||||
bank_overrides = await app.state.memory._config_resolver._load_bank_config(bank_id)
|
||||
|
||||
return BankConfigResponse(bank_id=bank_id, config=config_dict, overrides=bank_overrides)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in GET /v1/default/banks/{bank_id}/config: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.patch(
|
||||
"/v1/default/banks/{bank_id}/config",
|
||||
response_model=BankConfigResponse,
|
||||
summary="Update bank configuration",
|
||||
description="Update configuration overrides for a bank. Only hierarchical fields can be overridden (LLM settings, retention parameters, etc.). "
|
||||
"Keys can be provided in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER).",
|
||||
operation_id="update_bank_config",
|
||||
tags=["Banks"],
|
||||
)
|
||||
async def api_update_bank_config(
|
||||
bank_id: str, request: BankConfigUpdate, request_context: RequestContext = Depends(get_request_context)
|
||||
):
|
||||
"""Update configuration overrides for a bank."""
|
||||
if not get_config().enable_bank_config_api:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Bank configuration API is disabled. Set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true to enable.",
|
||||
)
|
||||
try:
|
||||
# Update config via config resolver (validates configurable fields and permissions)
|
||||
await app.state.memory._config_resolver.update_bank_config(bank_id, request.updates, request_context)
|
||||
|
||||
# Return updated config
|
||||
config_dict = await app.state.memory._config_resolver.get_bank_config(bank_id, request_context)
|
||||
bank_overrides = await app.state.memory._config_resolver._load_bank_config(bank_id)
|
||||
|
||||
return BankConfigResponse(bank_id=bank_id, config=config_dict, overrides=bank_overrides)
|
||||
except ValueError as e:
|
||||
# Validation error (e.g., trying to override static field)
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/config: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.delete(
|
||||
"/v1/default/banks/{bank_id}/config",
|
||||
response_model=BankConfigResponse,
|
||||
summary="Reset bank configuration",
|
||||
description="Reset bank configuration to defaults by removing all bank-specific overrides. "
|
||||
"The bank will then use global and tenant-level configuration only.",
|
||||
operation_id="reset_bank_config",
|
||||
tags=["Banks"],
|
||||
)
|
||||
async def api_reset_bank_config(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
|
||||
"""Reset bank configuration to defaults (remove all overrides)."""
|
||||
if not get_config().enable_bank_config_api:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Bank configuration API is disabled. Set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true to enable.",
|
||||
)
|
||||
try:
|
||||
# Reset config via config resolver
|
||||
await app.state.memory._config_resolver.reset_bank_config(bank_id)
|
||||
|
||||
# Return updated config (should match defaults now)
|
||||
config_dict = await app.state.memory._config_resolver.get_bank_config(bank_id, request_context)
|
||||
bank_overrides = await app.state.memory._config_resolver._load_bank_config(bank_id)
|
||||
|
||||
return BankConfigResponse(bank_id=bank_id, config=config_dict, overrides=bank_overrides)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/config: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post(
|
||||
"/v1/default/banks/{bank_id}/consolidate",
|
||||
response_model=ConsolidationResponse,
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field, fields
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from dotenv import find_dotenv, load_dotenv
|
||||
|
||||
|
|
@ -18,6 +19,103 @@ load_dotenv(find_dotenv(usecwd=True), override=True)
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigFieldAccessError(AttributeError):
|
||||
"""Raised when trying to access a bank-configurable field from global config."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class StaticConfigProxy:
|
||||
"""
|
||||
Proxy that wraps HindsightConfig and only allows access to static (non-configurable) fields.
|
||||
|
||||
Raises ConfigFieldAccessError when trying to access configurable fields that vary per-bank.
|
||||
Forces developers to use get_resolved_config(bank_id, context) for bank-specific settings.
|
||||
"""
|
||||
|
||||
def __init__(self, config: "HindsightConfig"):
|
||||
object.__setattr__(self, "_config", config)
|
||||
object.__setattr__(self, "_configurable_fields", HindsightConfig.get_configurable_fields())
|
||||
|
||||
def __getattribute__(self, name: str):
|
||||
if name.startswith("_"):
|
||||
return object.__getattribute__(self, name)
|
||||
|
||||
configurable_fields = object.__getattribute__(self, "_configurable_fields")
|
||||
if name in configurable_fields:
|
||||
raise ConfigFieldAccessError(
|
||||
f"Field '{name}' is bank-configurable and cannot be accessed from global config. "
|
||||
f"Use ConfigResolver.resolve_full_config(bank_id, context) to get bank-specific config. "
|
||||
f"This prevents accidentally using global defaults when bank-specific overrides exist."
|
||||
)
|
||||
|
||||
config = object.__getattribute__(self, "_config")
|
||||
return getattr(config, name)
|
||||
|
||||
def __setattr__(self, name: str, value):
|
||||
raise AttributeError("Config is read-only. Modifications must go through ConfigResolver.")
|
||||
|
||||
|
||||
# Configuration field markers for hierarchical configuration
|
||||
def hierarchical(default_value):
|
||||
"""
|
||||
Mark a config field as hierarchical (can be overridden per-tenant/bank).
|
||||
|
||||
Hierarchical fields can be customized at the tenant or bank level via database
|
||||
configuration. Examples: LLM settings, retention parameters, retrieval settings.
|
||||
"""
|
||||
return field(default=default_value, metadata={"hierarchical": True})
|
||||
|
||||
|
||||
def static(default_value):
|
||||
"""
|
||||
Mark a config field as static (server-level only, cannot be overridden).
|
||||
|
||||
Static fields are infrastructure-level settings that affect the entire server
|
||||
and cannot vary per tenant or bank. Examples: database URL, API port, worker settings.
|
||||
"""
|
||||
return field(default=default_value, metadata={"hierarchical": False})
|
||||
|
||||
|
||||
# Configuration key normalization utilities
|
||||
def normalize_config_key(key: str) -> str:
|
||||
"""
|
||||
Convert environment variable format to Python field name format.
|
||||
|
||||
Examples:
|
||||
HINDSIGHT_API_LLM_PROVIDER -> llm_provider
|
||||
LLM_MODEL -> llm_model
|
||||
llm_model -> llm_model (already normalized)
|
||||
|
||||
Args:
|
||||
key: Environment variable name or Python field name
|
||||
|
||||
Returns:
|
||||
Normalized Python field name (lowercase snake_case)
|
||||
"""
|
||||
if key.startswith("HINDSIGHT_API_"):
|
||||
key = key[len("HINDSIGHT_API_") :]
|
||||
return key.lower()
|
||||
|
||||
|
||||
def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Normalize all keys in a config dict to Python field names.
|
||||
|
||||
Allows users to provide config overrides in either format:
|
||||
- Python field format: {"llm_provider": "openai"}
|
||||
- Env var format: {"HINDSIGHT_API_LLM_PROVIDER": "openai"}
|
||||
|
||||
Args:
|
||||
config: Dict with env var or Python field names as keys
|
||||
|
||||
Returns:
|
||||
Dict with all keys normalized to Python field names
|
||||
"""
|
||||
return {normalize_config_key(k): v for k, v in config.items()}
|
||||
|
||||
|
||||
# Environment variable names
|
||||
ENV_DATABASE_URL = "HINDSIGHT_API_DATABASE_URL"
|
||||
ENV_DATABASE_SCHEMA = "HINDSIGHT_API_DATABASE_SCHEMA"
|
||||
|
|
@ -114,6 +212,7 @@ ENV_LOG_LEVEL = "HINDSIGHT_API_LOG_LEVEL"
|
|||
ENV_LOG_FORMAT = "HINDSIGHT_API_LOG_FORMAT"
|
||||
ENV_WORKERS = "HINDSIGHT_API_WORKERS"
|
||||
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
|
||||
ENV_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API"
|
||||
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
|
||||
ENV_MPFP_TOP_K_NEIGHBORS = "HINDSIGHT_API_MPFP_TOP_K_NEIGHBORS"
|
||||
ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
|
||||
|
|
@ -236,6 +335,7 @@ DEFAULT_LOG_LEVEL = "info"
|
|||
DEFAULT_LOG_FORMAT = "text" # Options: "text", "json"
|
||||
DEFAULT_WORKERS = 1
|
||||
DEFAULT_MCP_ENABLED = True
|
||||
DEFAULT_ENABLE_BANK_CONFIG_API = False # Disabled by default for security
|
||||
DEFAULT_GRAPH_RETRIEVER = "link_expansion" # Options: "link_expansion", "mpfp", "bfs"
|
||||
DEFAULT_MPFP_TOP_K_NEIGHBORS = 20 # Fan-out limit per node in MPFP graph traversal
|
||||
DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker
|
||||
|
|
@ -446,6 +546,7 @@ class HindsightConfig:
|
|||
log_level: str
|
||||
log_format: str
|
||||
mcp_enabled: bool
|
||||
enable_bank_config_api: bool
|
||||
|
||||
# Recall
|
||||
graph_retriever: str
|
||||
|
|
@ -498,6 +599,92 @@ class HindsightConfig:
|
|||
otel_service_name: str
|
||||
otel_deployment_environment: str
|
||||
|
||||
# Class-level sets for configuration categorization
|
||||
|
||||
# CREDENTIAL_FIELDS: Never exposed via API, never configurable per-tenant/bank
|
||||
_CREDENTIAL_FIELDS = {
|
||||
# API Keys
|
||||
"llm_api_key",
|
||||
"retain_llm_api_key",
|
||||
"reflect_llm_api_key",
|
||||
"consolidation_llm_api_key",
|
||||
# Base URLs (could expose infrastructure)
|
||||
"llm_base_url",
|
||||
"retain_llm_base_url",
|
||||
"reflect_llm_base_url",
|
||||
"consolidation_llm_base_url",
|
||||
"embeddings_tei_base_url",
|
||||
"reranker_tei_base_url",
|
||||
"reranker_cohere_base_url",
|
||||
# Service Account Keys
|
||||
"llm_vertexai_service_account_key",
|
||||
}
|
||||
|
||||
# CONFIGURABLE_FIELDS: Safe behavioral settings that can be customized per-tenant/bank
|
||||
# These fields are manually tagged as safe to expose and modify.
|
||||
# Excludes credentials, infrastructure config, provider/model selection, and performance tuning.
|
||||
_CONFIGURABLE_FIELDS = {
|
||||
# Retention settings (behavioral)
|
||||
"retain_chunk_size",
|
||||
"retain_extraction_mode",
|
||||
"retain_custom_instructions",
|
||||
# Consolidation settings
|
||||
"enable_observations",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_configurable_fields(cls) -> set[str]:
|
||||
"""
|
||||
Get set of field names that are configurable per-tenant/bank via API.
|
||||
|
||||
Configurable fields are manually tagged behavioral settings that are safe
|
||||
to expose and modify (e.g., retain_chunk_size, custom_instructions).
|
||||
Excludes credentials, infrastructure config, and provider/model selection.
|
||||
|
||||
Returns:
|
||||
Set of configurable field names
|
||||
"""
|
||||
return cls._CONFIGURABLE_FIELDS.copy()
|
||||
|
||||
@classmethod
|
||||
def get_credential_fields(cls) -> set[str]:
|
||||
"""
|
||||
Get set of field names that are credentials (NEVER exposed via API).
|
||||
|
||||
Credential fields include API keys, base URLs, and service account keys.
|
||||
These must never be returned in API responses or accepted in updates.
|
||||
|
||||
Returns:
|
||||
Set of credential field names
|
||||
"""
|
||||
return cls._CREDENTIAL_FIELDS.copy()
|
||||
|
||||
@classmethod
|
||||
def get_hierarchical_fields(cls) -> set[str]:
|
||||
"""
|
||||
DEPRECATED: Use get_configurable_fields() instead.
|
||||
|
||||
Kept for backward compatibility during migration.
|
||||
"""
|
||||
return cls.get_configurable_fields()
|
||||
|
||||
@classmethod
|
||||
def get_static_fields(cls) -> set[str]:
|
||||
"""
|
||||
Get set of field names that are static (server-level only).
|
||||
|
||||
Static fields are infrastructure-level settings that cannot vary
|
||||
per tenant or bank. These include database config, API port, worker settings, etc.
|
||||
Also includes credential fields which are never configurable.
|
||||
|
||||
Returns:
|
||||
Set of static field names
|
||||
"""
|
||||
# Get all field names from dataclass
|
||||
all_fields = {f.name for f in fields(cls)}
|
||||
# Static fields = all fields - configurable fields
|
||||
return all_fields - cls._CONFIGURABLE_FIELDS
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate configuration values and raise errors for invalid combinations."""
|
||||
# RETAIN_MAX_COMPLETION_TOKENS must be greater than RETAIN_CHUNK_SIZE
|
||||
|
|
@ -669,6 +856,8 @@ class HindsightConfig:
|
|||
log_level=os.getenv(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL),
|
||||
log_format=os.getenv(ENV_LOG_FORMAT, DEFAULT_LOG_FORMAT).lower(),
|
||||
mcp_enabled=os.getenv(ENV_MCP_ENABLED, str(DEFAULT_MCP_ENABLED)).lower() == "true",
|
||||
enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower()
|
||||
== "true",
|
||||
# Recall
|
||||
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
|
||||
mpfp_top_k_neighbors=int(os.getenv(ENV_MPFP_TOP_K_NEIGHBORS, str(DEFAULT_MPFP_TOP_K_NEIGHBORS))),
|
||||
|
|
@ -809,8 +998,35 @@ class HindsightConfig:
|
|||
_config_cache: HindsightConfig | None = None
|
||||
|
||||
|
||||
def get_config() -> HindsightConfig:
|
||||
"""Get the cached configuration, loading from environment on first call."""
|
||||
def get_config() -> StaticConfigProxy:
|
||||
"""
|
||||
Get global configuration with ONLY static (non-configurable) fields accessible.
|
||||
|
||||
This returns a proxy that prevents access to bank-configurable fields
|
||||
(like enable_observations, retain_chunk_size, etc.).
|
||||
|
||||
For bank-specific configuration, use:
|
||||
config_resolver.resolve_full_config(bank_id, context)
|
||||
|
||||
This design prevents accidentally using global defaults when bank-specific
|
||||
overrides exist.
|
||||
|
||||
Returns:
|
||||
StaticConfigProxy that only exposes static infrastructure fields
|
||||
|
||||
Raises:
|
||||
ConfigFieldAccessError: If you try to access a bank-configurable field
|
||||
"""
|
||||
return StaticConfigProxy(_get_raw_config())
|
||||
|
||||
|
||||
def _get_raw_config() -> HindsightConfig:
|
||||
"""
|
||||
Get raw config (internal use only).
|
||||
|
||||
INTERNAL USE ONLY. Do not use this directly in application code.
|
||||
Use get_config() for static fields or ConfigResolver.resolve_full_config() for bank-specific config.
|
||||
"""
|
||||
global _config_cache
|
||||
if _config_cache is None:
|
||||
_config_cache = HindsightConfig.from_env()
|
||||
|
|
|
|||
274
hindsight-api/hindsight_api/config_resolver.py
Normal file
274
hindsight-api/hindsight_api/config_resolver.py
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
"""
|
||||
Configuration resolution with hierarchical overrides.
|
||||
|
||||
Resolves config values through the hierarchy:
|
||||
Global (env vars) → Tenant config (via extension) → Bank config (database)
|
||||
|
||||
Config values are resolved on every request to ensure consistency across
|
||||
multiple API servers.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
|
||||
from hindsight_api.config import HindsightConfig, _get_raw_config, normalize_config_dict
|
||||
from hindsight_api.extensions.tenant import TenantExtension
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigResolver:
|
||||
"""Resolves hierarchical configuration with tenant/bank overrides."""
|
||||
|
||||
def __init__(self, pool: asyncpg.Pool, tenant_extension: TenantExtension | None = None):
|
||||
"""
|
||||
Initialize config resolver.
|
||||
|
||||
Args:
|
||||
pool: Database connection pool
|
||||
tenant_extension: Optional tenant extension for tenant-level config and permissions
|
||||
"""
|
||||
self.pool = pool
|
||||
self.tenant_extension = tenant_extension
|
||||
self._global_config = _get_raw_config()
|
||||
self._configurable_fields = HindsightConfig.get_configurable_fields()
|
||||
self._credential_fields = HindsightConfig.get_credential_fields()
|
||||
|
||||
async def resolve_full_config(self, bank_id: str, context: RequestContext | None = None) -> HindsightConfig:
|
||||
"""
|
||||
Resolve full HindsightConfig for a bank with hierarchical overrides applied.
|
||||
|
||||
This is for INTERNAL USE ONLY. Returns the complete config object with all fields
|
||||
including credentials and static fields. Use get_bank_config() for API responses.
|
||||
|
||||
Resolution order:
|
||||
1. Global config (from environment variables)
|
||||
2. Tenant config overrides (from TenantExtension.get_tenant_config())
|
||||
3. Bank config overrides (from banks.config JSONB)
|
||||
|
||||
Args:
|
||||
bank_id: Bank identifier
|
||||
context: Request context for tenant config resolution
|
||||
|
||||
Returns:
|
||||
Complete HindsightConfig with hierarchical overrides applied
|
||||
"""
|
||||
# Start with global config (all fields)
|
||||
config_dict = asdict(self._global_config)
|
||||
|
||||
# Load tenant config overrides (if tenant extension available)
|
||||
if self.tenant_extension and context:
|
||||
try:
|
||||
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
|
||||
if tenant_overrides:
|
||||
# Normalize keys and filter to configurable fields only
|
||||
normalized_tenant = normalize_config_dict(tenant_overrides)
|
||||
configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields}
|
||||
config_dict.update(configurable_tenant)
|
||||
logger.debug(
|
||||
f"Applied tenant config overrides for bank {bank_id}: {list(configurable_tenant.keys())}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load tenant config for bank {bank_id}: {e}")
|
||||
|
||||
# Load bank config overrides
|
||||
bank_overrides = await self._load_bank_config(bank_id)
|
||||
if bank_overrides:
|
||||
config_dict.update(bank_overrides)
|
||||
logger.debug(f"Applied bank config overrides for bank {bank_id}: {list(bank_overrides.keys())}")
|
||||
|
||||
# Return full config object (dataclass doesn't have __init__ that accepts kwargs, so we update the object)
|
||||
# Create a new config instance by copying the global config and updating fields
|
||||
resolved_config = HindsightConfig(**config_dict)
|
||||
return resolved_config
|
||||
|
||||
async def get_bank_config(self, bank_id: str, context: RequestContext | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Get fully resolved config for a bank (filtered by permissions).
|
||||
|
||||
Resolution order:
|
||||
1. Global config (from environment variables)
|
||||
2. Tenant config overrides (from TenantExtension.get_tenant_config())
|
||||
3. Bank config overrides (from banks.config JSONB)
|
||||
|
||||
Note: Config is resolved on every call (not cached) to ensure consistency
|
||||
across multiple API servers.
|
||||
|
||||
SECURITY:
|
||||
- Only returns configurable fields (excludes static/infrastructure fields)
|
||||
- Filters out ALL credential fields (API keys, base URLs, etc.)
|
||||
- Further filtered by tenant/bank permissions if extension provides them
|
||||
|
||||
Args:
|
||||
bank_id: Bank identifier
|
||||
context: Request context for tenant config resolution and permissions
|
||||
|
||||
Returns:
|
||||
Dict of allowed configurable fields only (never includes credentials or static fields)
|
||||
"""
|
||||
# Resolve full config with all hierarchical overrides
|
||||
resolved_config = await self.resolve_full_config(bank_id, context)
|
||||
config_dict = asdict(resolved_config)
|
||||
|
||||
# SECURITY: Filter to only configurable fields (exclude static/infrastructure)
|
||||
filtered = {k: v for k, v in config_dict.items() if k in self._configurable_fields}
|
||||
|
||||
# SECURITY: Remove ALL credential fields (API keys, base URLs, etc.)
|
||||
filtered = {k: v for k, v in filtered.items() if k not in self._credential_fields}
|
||||
|
||||
# PERMISSIONS: Further filter based on tenant/bank permissions
|
||||
if self.tenant_extension and context:
|
||||
try:
|
||||
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
|
||||
if allowed_fields is not None: # None means "allow all"
|
||||
filtered = {k: v for k, v in filtered.items() if k in allowed_fields}
|
||||
logger.debug(
|
||||
f"Applied permission filter for bank {bank_id}: allowed={len(allowed_fields)} fields, "
|
||||
f"returned={len(filtered)} fields"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load permissions for bank {bank_id}: {e}")
|
||||
|
||||
return filtered
|
||||
|
||||
async def _load_bank_config(self, bank_id: str) -> dict[str, Any]:
|
||||
"""
|
||||
Load bank config overrides from banks.config JSONB column.
|
||||
|
||||
Args:
|
||||
bank_id: Bank identifier
|
||||
|
||||
Returns:
|
||||
Dict of config overrides (only configurable fields, normalized keys)
|
||||
"""
|
||||
try:
|
||||
async with self.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT config FROM banks WHERE bank_id = $1
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
if row and row["config"]:
|
||||
config_data = row["config"]
|
||||
|
||||
# Handle case where JSONB is returned as JSON string
|
||||
if isinstance(config_data, str):
|
||||
config_data = json.loads(config_data)
|
||||
|
||||
# Normalize keys (handle both env var format and Python field format)
|
||||
normalized = normalize_config_dict(config_data)
|
||||
|
||||
# Only return overrides for configurable fields
|
||||
return {k: v for k, v in normalized.items() if k in self._configurable_fields}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load bank config for {bank_id}: {e}")
|
||||
|
||||
return {}
|
||||
|
||||
async def update_bank_config(
|
||||
self, bank_id: str, updates: dict[str, Any], context: RequestContext | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Update bank configuration overrides (with permission checking).
|
||||
|
||||
Args:
|
||||
bank_id: Bank identifier
|
||||
updates: Dict of config field names to new values.
|
||||
Keys can be in env var format (HINDSIGHT_API_LLM_PROVIDER)
|
||||
or Python field format (llm_provider).
|
||||
Only configurable fields are allowed.
|
||||
context: Request context for permission checking
|
||||
|
||||
Raises:
|
||||
ValueError: If attempting to override invalid/disallowed fields
|
||||
"""
|
||||
# Normalize keys
|
||||
normalized_updates = normalize_config_dict(updates)
|
||||
|
||||
# SECURITY: Reject credential fields explicitly
|
||||
credential_attempts = set(normalized_updates.keys()) & self._credential_fields
|
||||
if credential_attempts:
|
||||
raise ValueError(
|
||||
f"Cannot set credential fields via API: {sorted(credential_attempts)}. "
|
||||
f"Credentials (API keys, base URLs) must be set at server level only."
|
||||
)
|
||||
|
||||
# Validate all fields are configurable
|
||||
invalid_fields = set(normalized_updates.keys()) - self._configurable_fields
|
||||
if invalid_fields:
|
||||
static_fields = HindsightConfig.get_static_fields()
|
||||
invalid_static = invalid_fields & static_fields
|
||||
if invalid_static:
|
||||
raise ValueError(
|
||||
f"Cannot override static (server-level) fields: {sorted(invalid_static)}. "
|
||||
f"Only configurable fields can be overridden per-bank. "
|
||||
f"Configurable fields include: {sorted(list(self._configurable_fields)[:10])}... "
|
||||
f"(total: {len(self._configurable_fields)} fields)"
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown configuration fields: {sorted(invalid_fields)}. "
|
||||
f"Valid configurable fields: {sorted(list(self._configurable_fields)[:10])}..."
|
||||
)
|
||||
|
||||
# PERMISSIONS: Check tenant/bank permissions
|
||||
if self.tenant_extension and context:
|
||||
try:
|
||||
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
|
||||
if allowed_fields is not None: # None means "allow all"
|
||||
disallowed = set(normalized_updates.keys()) - allowed_fields
|
||||
if disallowed:
|
||||
raise ValueError(
|
||||
f"Not allowed to modify fields: {sorted(disallowed)}. "
|
||||
f"Your permissions allow: {sorted(list(allowed_fields)[:10])}..."
|
||||
if allowed_fields
|
||||
else "Not allowed to modify fields: {sorted(disallowed)}. "
|
||||
"Your permissions do not allow any config modifications."
|
||||
)
|
||||
except ValueError:
|
||||
raise # Re-raise permission errors
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check permissions for bank {bank_id}: {e}")
|
||||
# Continue without permission check (fail open for backward compatibility)
|
||||
|
||||
# Merge with existing config (JSONB || operator)
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE banks
|
||||
SET config = config || $1::jsonb,
|
||||
updated_at = now()
|
||||
WHERE bank_id = $2
|
||||
""",
|
||||
json.dumps(normalized_updates),
|
||||
bank_id,
|
||||
)
|
||||
|
||||
logger.info(f"Updated bank config for {bank_id}: {list(normalized_updates.keys())}")
|
||||
|
||||
async def reset_bank_config(self, bank_id: str) -> None:
|
||||
"""
|
||||
Reset bank configuration to defaults (remove all overrides).
|
||||
|
||||
Args:
|
||||
bank_id: Bank identifier
|
||||
"""
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE banks
|
||||
SET config = '{}'::jsonb,
|
||||
updated_at = now()
|
||||
WHERE bank_id = $1
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
logger.info(f"Reset bank config for {bank_id} to defaults")
|
||||
|
|
@ -82,9 +82,8 @@ async def run_consolidation_job(
|
|||
Returns:
|
||||
Dict with consolidation results
|
||||
"""
|
||||
from ...config import get_config
|
||||
|
||||
config = get_config()
|
||||
# Resolve bank-specific config with hierarchical overrides
|
||||
config = await memory_engine._config_resolver.resolve_full_config(bank_id, request_context)
|
||||
perf = ConsolidationPerfLog(bank_id)
|
||||
max_memories_per_batch = config.consolidation_batch_size
|
||||
|
||||
|
|
|
|||
|
|
@ -1018,6 +1018,12 @@ class MemoryEngine(MemoryEngineInterface):
|
|||
# Initialize entity resolver with pool
|
||||
self.entity_resolver = EntityResolver(self._pool)
|
||||
|
||||
# Initialize config resolver for hierarchical configuration
|
||||
from ..config_resolver import ConfigResolver
|
||||
|
||||
self._config_resolver = ConfigResolver(pool=self._pool, tenant_extension=self._tenant_extension)
|
||||
logger.debug("Config resolver initialized for hierarchical configuration")
|
||||
|
||||
# Set executor for task backend and initialize
|
||||
self._task_backend.set_executor(self.execute_task)
|
||||
await self._task_backend.initialize()
|
||||
|
|
@ -1447,6 +1453,7 @@ class MemoryEngine(MemoryEngineInterface):
|
|||
sub_results, sub_usage = await self._retain_batch_async_internal(
|
||||
bank_id=bank_id,
|
||||
contents=sub_batch,
|
||||
request_context=request_context,
|
||||
document_id=document_id,
|
||||
is_first_batch=i == 1, # Only upsert on first batch
|
||||
fact_type_override=fact_type_override,
|
||||
|
|
@ -1466,6 +1473,7 @@ class MemoryEngine(MemoryEngineInterface):
|
|||
result, total_usage = await self._retain_batch_async_internal(
|
||||
bank_id=bank_id,
|
||||
contents=contents,
|
||||
request_context=request_context,
|
||||
document_id=document_id,
|
||||
is_first_batch=True,
|
||||
fact_type_override=fact_type_override,
|
||||
|
|
@ -1497,9 +1505,8 @@ class MemoryEngine(MemoryEngineInterface):
|
|||
logger.warning(f"Post-retain hook error (non-fatal): {e}")
|
||||
|
||||
# Trigger consolidation as a tracked async operation if enabled
|
||||
from ..config import get_config
|
||||
|
||||
config = get_config()
|
||||
# Resolve bank-specific config to check if observations are enabled for this bank
|
||||
config = await self._config_resolver.resolve_full_config(bank_id, request_context)
|
||||
if config.enable_observations:
|
||||
try:
|
||||
await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context)
|
||||
|
|
@ -1515,6 +1522,7 @@ class MemoryEngine(MemoryEngineInterface):
|
|||
self,
|
||||
bank_id: str,
|
||||
contents: list[RetainContentDict],
|
||||
request_context: "RequestContext",
|
||||
document_id: str | None = None,
|
||||
is_first_batch: bool = True,
|
||||
fact_type_override: str | None = None,
|
||||
|
|
@ -1532,6 +1540,7 @@ class MemoryEngine(MemoryEngineInterface):
|
|||
Args:
|
||||
bank_id: Unique identifier for the bank
|
||||
contents: List of dicts with content, context, event_date
|
||||
request_context: Request context for config resolution
|
||||
document_id: Optional document ID (always upserts if exists)
|
||||
is_first_batch: Whether this is the first batch (for chunked operations, only delete on first batch)
|
||||
fact_type_override: Override fact type for all facts
|
||||
|
|
@ -1548,6 +1557,9 @@ class MemoryEngine(MemoryEngineInterface):
|
|||
|
||||
pool = await self._get_pool()
|
||||
|
||||
# Resolve bank-specific config for this operation
|
||||
resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
|
||||
|
||||
# Create parent span for retain operation
|
||||
with create_operation_span("retain", bank_id):
|
||||
return await orchestrator.retain_batch(
|
||||
|
|
@ -1564,6 +1576,7 @@ class MemoryEngine(MemoryEngineInterface):
|
|||
fact_type_override=fact_type_override,
|
||||
confidence_score=confidence_score,
|
||||
document_tags=document_tags,
|
||||
config=resolved_config,
|
||||
)
|
||||
|
||||
def recall(
|
||||
|
|
|
|||
|
|
@ -702,6 +702,7 @@ async def _extract_facts_from_chunk(
|
|||
event_date: datetime,
|
||||
context: str,
|
||||
llm_config: "LLMConfig",
|
||||
config,
|
||||
agent_name: str = None,
|
||||
) -> tuple[list[dict[str, str]], TokenUsage]:
|
||||
"""
|
||||
|
|
@ -721,7 +722,6 @@ async def _extract_facts_from_chunk(
|
|||
fact_types_instruction = "Extract ONLY 'world' and 'assistant' type facts."
|
||||
|
||||
# Check config for extraction mode and causal link extraction
|
||||
config = get_config()
|
||||
extraction_mode = config.retain_extraction_mode
|
||||
extract_causal_links = config.retain_extract_causal_links
|
||||
|
||||
|
|
@ -1055,6 +1055,7 @@ async def _extract_facts_with_auto_split(
|
|||
event_date: datetime,
|
||||
context: str,
|
||||
llm_config: LLMConfig,
|
||||
config,
|
||||
agent_name: str = None,
|
||||
) -> tuple[list[dict[str, str]], TokenUsage]:
|
||||
"""
|
||||
|
|
@ -1070,6 +1071,7 @@ async def _extract_facts_with_auto_split(
|
|||
event_date: Reference date for temporal information
|
||||
context: Context about the conversation/document
|
||||
llm_config: LLM configuration to use
|
||||
config: Resolved HindsightConfig for this bank
|
||||
agent_name: Optional agent name (memory owner)
|
||||
|
||||
Returns:
|
||||
|
|
@ -1088,6 +1090,7 @@ async def _extract_facts_with_auto_split(
|
|||
event_date=event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
config=config,
|
||||
agent_name=agent_name,
|
||||
)
|
||||
except OutputTooLongError:
|
||||
|
|
@ -1132,6 +1135,7 @@ async def _extract_facts_with_auto_split(
|
|||
event_date=event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
config=config,
|
||||
agent_name=agent_name,
|
||||
),
|
||||
_extract_facts_with_auto_split(
|
||||
|
|
@ -1141,6 +1145,7 @@ async def _extract_facts_with_auto_split(
|
|||
event_date=event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
config=config,
|
||||
agent_name=agent_name,
|
||||
),
|
||||
]
|
||||
|
|
@ -1164,6 +1169,7 @@ async def extract_facts_from_text(
|
|||
event_date: datetime,
|
||||
llm_config: LLMConfig,
|
||||
agent_name: str,
|
||||
config,
|
||||
context: str = "",
|
||||
) -> tuple[list[Fact], list[tuple[str, int]], TokenUsage]:
|
||||
"""
|
||||
|
|
@ -1178,9 +1184,10 @@ async def extract_facts_from_text(
|
|||
Args:
|
||||
text: Input text (conversation, article, etc.)
|
||||
event_date: Reference date for resolving relative times
|
||||
context: Context about the conversation/document
|
||||
llm_config: LLM configuration to use
|
||||
agent_name: Agent name (memory owner)
|
||||
config: Resolved HindsightConfig for this bank
|
||||
context: Context about the conversation/document
|
||||
|
||||
Returns:
|
||||
Tuple of (facts, chunks, usage) where:
|
||||
|
|
@ -1188,7 +1195,6 @@ async def extract_facts_from_text(
|
|||
- chunks: List of tuples (chunk_text, fact_count) for each chunk
|
||||
- usage: Aggregated token usage across all LLM calls
|
||||
"""
|
||||
config = get_config()
|
||||
chunks = chunk_text(text, max_chars=config.retain_chunk_size)
|
||||
|
||||
# Log chunk count before starting LLM requests
|
||||
|
|
@ -1207,6 +1213,7 @@ async def extract_facts_from_text(
|
|||
event_date=event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
config=config,
|
||||
agent_name=agent_name,
|
||||
)
|
||||
for i, chunk in enumerate(chunks)
|
||||
|
|
@ -1239,7 +1246,7 @@ SECONDS_PER_FACT = 10
|
|||
|
||||
|
||||
async def extract_facts_from_contents(
|
||||
contents: list[RetainContent], llm_config, agent_name: str
|
||||
contents: list[RetainContent], llm_config, agent_name: str, config
|
||||
) -> tuple[list[ExtractedFactType], list[ChunkMetadata], TokenUsage]:
|
||||
"""
|
||||
Extract facts from multiple content items in parallel.
|
||||
|
|
@ -1254,6 +1261,7 @@ async def extract_facts_from_contents(
|
|||
contents: List of RetainContent objects to process
|
||||
llm_config: LLM configuration for fact extraction
|
||||
agent_name: Name of the agent (for agent-related fact detection)
|
||||
config: Resolved HindsightConfig for this bank
|
||||
|
||||
Returns:
|
||||
Tuple of (extracted_facts, chunks_metadata, usage)
|
||||
|
|
@ -1272,6 +1280,7 @@ async def extract_facts_from_contents(
|
|||
context=item.context,
|
||||
llm_config=llm_config,
|
||||
agent_name=agent_name,
|
||||
config=config,
|
||||
)
|
||||
fact_extraction_tasks.append(task)
|
||||
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ async def retain_batch(
|
|||
duplicate_checker_fn,
|
||||
bank_id: str,
|
||||
contents_dicts: list[RetainContentDict],
|
||||
config,
|
||||
document_id: str | None = None,
|
||||
is_first_batch: bool = True,
|
||||
fact_type_override: str | None = None,
|
||||
|
|
@ -94,6 +95,7 @@ async def retain_batch(
|
|||
duplicate_checker_fn: Function to check for duplicate facts
|
||||
bank_id: Bank identifier
|
||||
contents_dicts: List of content dictionaries
|
||||
config: Resolved HindsightConfig for this bank
|
||||
document_id: Optional document ID
|
||||
is_first_batch: Whether this is the first batch
|
||||
fact_type_override: Override fact type for all facts
|
||||
|
|
@ -144,7 +146,9 @@ async def retain_batch(
|
|||
# Step 1: Extract facts from all contents
|
||||
step_start = time.time()
|
||||
|
||||
extracted_facts, chunks, usage = await fact_extraction.extract_facts_from_contents(contents, llm_config, agent_name)
|
||||
extracted_facts, chunks, usage = await fact_extraction.extract_facts_from_contents(
|
||||
contents, llm_config, agent_name, config
|
||||
)
|
||||
log_buffer.append(
|
||||
f"[1] Extract facts: {len(extracted_facts)} facts, {len(chunks)} chunks from {len(contents)} contents in {time.time() - step_start:.3f}s"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ async def extract_facts(
|
|||
context: str = "",
|
||||
llm_config: "LLMConfig" = None,
|
||||
agent_name: str = None,
|
||||
config=None,
|
||||
) -> tuple[list["Fact"], list[tuple[str, int]]]:
|
||||
"""
|
||||
Extract semantic facts from text using LLM.
|
||||
|
|
@ -35,6 +36,7 @@ async def extract_facts(
|
|||
context: Context about the conversation/document
|
||||
llm_config: LLM configuration to use
|
||||
agent_name: Optional agent name to help identify agent-related facts
|
||||
config: HindsightConfig to use (defaults to global config if not provided)
|
||||
|
||||
Returns:
|
||||
Tuple of (facts, chunks) where:
|
||||
|
|
@ -47,12 +49,19 @@ async def extract_facts(
|
|||
if not text or not text.strip():
|
||||
return [], []
|
||||
|
||||
# Use provided config or fall back to global config
|
||||
if config is None:
|
||||
from ..config import _get_raw_config
|
||||
|
||||
config = _get_raw_config()
|
||||
|
||||
facts, chunks, _ = await extract_facts_from_text(
|
||||
text,
|
||||
event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name=agent_name,
|
||||
config=config,
|
||||
context=context,
|
||||
)
|
||||
|
||||
if not facts:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from hindsight_api.extensions.base import Extension
|
||||
from hindsight_api.models import RequestContext
|
||||
|
|
@ -88,6 +89,54 @@ class TenantExtension(Extension, ABC):
|
|||
"""
|
||||
...
|
||||
|
||||
async def get_tenant_config(self, context: RequestContext) -> dict[str, Any]:
|
||||
"""
|
||||
Get tenant-specific configuration overrides.
|
||||
|
||||
This method is called during hierarchical configuration resolution to get
|
||||
tenant-level config overrides. The returned dict should contain Python field
|
||||
names (lowercase snake_case) as keys, not environment variable names.
|
||||
|
||||
Example:
|
||||
{"llm_model": "gpt-4", "retain_extraction_mode": "verbose"}
|
||||
|
||||
The default implementation returns an empty dict (no tenant-specific config).
|
||||
Override this method in custom extensions to provide tenant-specific configuration.
|
||||
|
||||
Args:
|
||||
context: The request context containing tenant information.
|
||||
|
||||
Returns:
|
||||
Dict of config field names to values (only configurable fields).
|
||||
Empty dict if no tenant-specific config.
|
||||
"""
|
||||
return {}
|
||||
|
||||
async def get_allowed_config_fields(self, context: RequestContext, bank_id: str) -> set[str] | None:
|
||||
"""
|
||||
Get set of config fields that this tenant/bank is allowed to modify.
|
||||
|
||||
This method controls which configurable fields can be modified via the bank config API.
|
||||
It enables fine-grained permission control per tenant or per bank.
|
||||
|
||||
Examples:
|
||||
- Return None: Allow all configurable fields (default)
|
||||
- Return {"retain_chunk_size", "retain_custom_instructions"}: Allow only these fields
|
||||
- Return set(): Allow no modifications (read-only)
|
||||
|
||||
The default implementation returns None (all configurable fields allowed).
|
||||
Override this method in custom extensions to implement custom permission logic.
|
||||
|
||||
Args:
|
||||
context: The request context containing tenant information.
|
||||
bank_id: The bank identifier for per-bank permissions.
|
||||
|
||||
Returns:
|
||||
Set of allowed field names, or None to allow all configurable fields.
|
||||
Returned fields must be a subset of HindsightConfig.get_configurable_fields().
|
||||
"""
|
||||
return None
|
||||
|
||||
async def authenticate_mcp(self, context: RequestContext) -> TenantContext:
|
||||
"""
|
||||
Authenticate MCP requests.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import uvicorn
|
|||
from . import MemoryEngine, __version__
|
||||
from .api import create_app
|
||||
from .banner import print_banner
|
||||
from .config import DEFAULT_WORKERS, ENV_WORKERS, HindsightConfig, get_config
|
||||
from .config import DEFAULT_WORKERS, ENV_WORKERS, HindsightConfig, _get_raw_config
|
||||
from .daemon import (
|
||||
DEFAULT_DAEMON_PORT,
|
||||
DEFAULT_IDLE_TIMEOUT,
|
||||
|
|
@ -68,7 +68,7 @@ def main():
|
|||
global _memory
|
||||
|
||||
# Load configuration from environment (for CLI args defaults)
|
||||
config = get_config()
|
||||
config = _get_raw_config()
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="hindsight-api",
|
||||
|
|
@ -227,6 +227,7 @@ def main():
|
|||
log_level=args.log_level,
|
||||
log_format=config.log_format,
|
||||
mcp_enabled=config.mcp_enabled,
|
||||
enable_bank_config_api=config.enable_bank_config_api,
|
||||
graph_retriever=config.graph_retriever,
|
||||
mpfp_top_k_neighbors=config.mpfp_top_k_neighbors,
|
||||
recall_max_concurrent=config.recall_max_concurrent,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from datetime import datetime
|
|||
import pytest
|
||||
|
||||
from hindsight_api import LLMConfig
|
||||
from hindsight_api.config import _get_raw_config
|
||||
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
|
||||
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ class TestCausalRelationsValidation:
|
|||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -88,6 +90,7 @@ class TestCausalRelationsValidation:
|
|||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -124,6 +127,7 @@ class TestCausalRelationsValidation:
|
|||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract facts about the causal chain"
|
||||
|
|
@ -173,6 +177,7 @@ class TestCausalRelationsValidation:
|
|||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract facts"
|
||||
|
|
@ -209,6 +214,7 @@ class TestCausalRelationsValidation:
|
|||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
# Verify relation types are all backward-looking
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from datetime import datetime
|
|||
import pytest
|
||||
|
||||
from hindsight_api import LLMConfig
|
||||
from hindsight_api.config import _get_raw_config
|
||||
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
|
||||
|
||||
|
||||
|
|
@ -37,7 +38,8 @@ After searching for weeks, I finally found a cheaper apartment in Brooklyn.
|
|||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text, event_date=datetime(2024, 3, 15), context=context, llm_config=llm_config, agent_name="TestUser"
|
||||
text=text, event_date=datetime(2024, 3, 15), context=context, llm_config=llm_config, agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) >= 3, f"Should extract at least 3 facts from the causal chain. Got {len(facts)}"
|
||||
|
|
@ -106,7 +108,8 @@ The renovation took three months and cost $15,000.
|
|||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text, event_date=datetime(2024, 6, 1), context=context, llm_config=llm_config, agent_name="TestUser"
|
||||
text=text, event_date=datetime(2024, 6, 1), context=context, llm_config=llm_config, agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) >= 4, f"Should extract at least 4 facts. Got {len(facts)}"
|
||||
|
|
@ -136,7 +139,8 @@ Machine learning fascinated me so much that I changed my career to data science.
|
|||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text, event_date=datetime(2024, 1, 1), context=context, llm_config=llm_config, agent_name="TestUser"
|
||||
text=text, event_date=datetime(2024, 1, 1), context=context, llm_config=llm_config, agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
# Check no fact references itself
|
||||
|
|
@ -163,7 +167,8 @@ The new role enabled me to lead a team of engineers.
|
|||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text, event_date=datetime(2024, 2, 15), context=context, llm_config=llm_config, agent_name="TestUser"
|
||||
text=text, event_date=datetime(2024, 2, 15), context=context, llm_config=llm_config, agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
# Validate all indices (must reference PREVIOUS facts only)
|
||||
|
|
@ -190,7 +195,8 @@ Reduced spending somewhat affected local businesses.
|
|||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text, event_date=datetime(2024, 4, 1), context=context, llm_config=llm_config, agent_name="TestUser"
|
||||
text=text, event_date=datetime(2024, 4, 1), context=context, llm_config=llm_config, agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
for i, fact in enumerate(facts):
|
||||
|
|
|
|||
|
|
@ -21,9 +21,9 @@ from hindsight_api.engine.reflect.tools import (
|
|||
@pytest.fixture(autouse=True)
|
||||
def enable_observations():
|
||||
"""Enable observations for all tests in this module."""
|
||||
from hindsight_api.config import get_config
|
||||
from hindsight_api.config import _get_raw_config
|
||||
|
||||
config = get_config()
|
||||
config = _get_raw_config()
|
||||
original_value = config.enable_observations
|
||||
config.enable_observations = True
|
||||
yield
|
||||
|
|
@ -563,25 +563,26 @@ class TestConsolidationDisabled:
|
|||
self, memory: MemoryEngine, request_context
|
||||
):
|
||||
"""Test that consolidation returns disabled status when enable_observations is False."""
|
||||
from unittest.mock import patch
|
||||
|
||||
bank_id = f"test-consolidation-disabled-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create the bank
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
# Disable observations via config
|
||||
with patch("hindsight_api.config.get_config") as mock_config:
|
||||
mock_config.return_value.enable_observations = False
|
||||
# Disable observations for this bank via bank config
|
||||
await memory._config_resolver.update_bank_config(
|
||||
bank_id=bank_id,
|
||||
updates={"enable_observations": False},
|
||||
context=request_context,
|
||||
)
|
||||
|
||||
result = await run_consolidation_job(
|
||||
memory_engine=memory,
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
result = await run_consolidation_job(
|
||||
memory_engine=memory,
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert result["status"] == "disabled"
|
||||
assert result["bank_id"] == bank_id
|
||||
assert result["status"] == "disabled"
|
||||
assert result["bank_id"] == bank_id
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from datetime import datetime
|
|||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.config import get_config, clear_config_cache
|
||||
from hindsight_api.config import get_config, clear_config_cache, _get_raw_config
|
||||
from hindsight_api.engine.llm_wrapper import LLMConfig
|
||||
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
|
||||
|
||||
|
|
@ -58,6 +58,7 @@ async def test_fact_extraction_basic_analysis(llm_config):
|
|||
llm_config=llm_config,
|
||||
agent_name="test-agent",
|
||||
context="Friday Standup meeting",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
duration = time.time() - start_time
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from datetime import datetime
|
|||
import pytest
|
||||
|
||||
from hindsight_api import LLMConfig
|
||||
from hindsight_api.config import _get_raw_config
|
||||
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
|
||||
|
||||
|
||||
|
|
@ -44,7 +45,8 @@ I ran into my neighbor Sarah who mentioned she's planning a trip to Italy next m
|
|||
event_date=datetime(2024, 6, 15),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
input_length = len(text)
|
||||
|
|
@ -88,7 +90,8 @@ User: Perfect, I'll make a reservation for Saturday at 7pm.
|
|||
event_date=datetime(2024, 6, 15),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
input_length = len(text)
|
||||
|
|
@ -144,7 +147,8 @@ I edited about 20 photos from my recent trip to the mountains.
|
|||
event_date=datetime(2024, 4, 15),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
input_length = len(text)
|
||||
|
|
@ -208,7 +212,8 @@ I edited about 20 photos from my recent trip to the mountains.
|
|||
event_date=datetime(2023, 5, 8), # Date from locomo dataset
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name=data["conversation"]["speaker_a"]
|
||||
agent_name=data["conversation"]["speaker_a"],
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
# Calculate ratios
|
||||
|
|
@ -269,7 +274,8 @@ I'm planning to visit Japan next year.
|
|||
event_date=datetime(2024, 6, 15),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
# Count approximate number of statements (sentences)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from datetime import UTC, datetime
|
|||
import pytest
|
||||
|
||||
from hindsight_api import LLMConfig
|
||||
from hindsight_api.config import _get_raw_config
|
||||
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
|
||||
|
||||
# =============================================================================
|
||||
|
|
@ -48,7 +49,8 @@ Marcus felt anxious about the upcoming interview.
|
|||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -80,7 +82,8 @@ The music was so loud I could barely hear myself think.
|
|||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -113,7 +116,8 @@ Maybe we should reconsider the timeline.
|
|||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -146,7 +150,8 @@ I'm unable to attend the conference due to scheduling conflicts.
|
|||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -178,7 +183,8 @@ Unlike last year, we're ahead of schedule.
|
|||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -211,7 +217,8 @@ She's enthusiastic about the opportunity.
|
|||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -244,7 +251,8 @@ I'm planning to switch careers because I'm not fulfilled in my current role.
|
|||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -281,7 +289,8 @@ Family is the most important thing to her.
|
|||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -315,7 +324,8 @@ I prefer presenting in person rather than virtually because I can read the room
|
|||
event_date=event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -372,7 +382,8 @@ I'm planning to visit Tokyo next month.
|
|||
event_date=event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -423,7 +434,8 @@ with a concert surrounded by music, joy and the warm summer breeze.
|
|||
event_date=event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="Melanie"
|
||||
agent_name="Melanie",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -493,7 +505,8 @@ It was a beautiful day and I plan to make this a regular habit.
|
|||
event_date=event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -547,7 +560,8 @@ It was a beautiful day and I plan to make this a regular habit.
|
|||
event_date=reference_date,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser",
|
||||
context="Personal diary"
|
||||
context="Personal diary",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -577,7 +591,8 @@ It was a beautiful day and I plan to make this a regular habit.
|
|||
event_date=reference_date,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser",
|
||||
context="General info"
|
||||
context="General info",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -604,7 +619,8 @@ It was a beautiful day and I plan to make this a regular habit.
|
|||
event_date=reference_date,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser",
|
||||
context="Calendar events"
|
||||
context="Calendar events",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -655,7 +671,8 @@ great time! Every time I see it, I can't help but smile.
|
|||
event_date=event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="Deborah"
|
||||
agent_name="Deborah",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -705,7 +722,8 @@ I've learned so much from it.
|
|||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -774,7 +792,8 @@ Jamie: Congratulations! I'd love to read it.
|
|||
event_date=datetime(2024, 11, 13),
|
||||
llm_config=llm_config,
|
||||
agent_name="Marcus",
|
||||
context=context
|
||||
context=context,
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact from the transcript"
|
||||
|
|
@ -819,7 +838,8 @@ We presented our findings to the team yesterday.
|
|||
event_date=datetime(2024, 11, 13),
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser",
|
||||
context=context
|
||||
context=context,
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract facts"
|
||||
|
|
@ -854,7 +874,8 @@ Jamie: [teasing] We'll see who's right, my Niners pick is solid.
|
|||
event_date=datetime(2024, 11, 14),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name=agent_name
|
||||
agent_name=agent_name,
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
@ -920,7 +941,8 @@ so the algorithm learns to box out. See you next week!
|
|||
event_date=datetime(2024, 11, 13),
|
||||
llm_config=llm_config,
|
||||
agent_name="Marcus",
|
||||
context=context
|
||||
context=context,
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
assert len(facts) > 0, "Should extract at least one fact"
|
||||
|
|
|
|||
491
hindsight-api/tests/test_hierarchical_config.py
Normal file
491
hindsight-api/tests/test_hierarchical_config.py
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
"""
|
||||
Tests for hierarchical configuration system.
|
||||
|
||||
Tests config resolution hierarchy (global → tenant → bank),
|
||||
key normalization, API endpoints, validation, and caching.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api import MemoryEngine
|
||||
from hindsight_api.config import HindsightConfig, normalize_config_dict, normalize_config_key
|
||||
from hindsight_api.config_resolver import ConfigResolver
|
||||
from hindsight_api.extensions.tenant import TenantExtension
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
# Enable bank config API for all tests in this module
|
||||
os.environ["HINDSIGHT_API_ENABLE_BANK_CONFIG_API"] = "true"
|
||||
|
||||
|
||||
class MockTenantExtension(TenantExtension):
|
||||
"""Mock tenant extension for testing tenant-level config."""
|
||||
|
||||
def __init__(self, tenant_config: dict):
|
||||
self.tenant_config = tenant_config
|
||||
|
||||
async def authenticate(self, context):
|
||||
from hindsight_api.extensions.tenant import TenantContext
|
||||
|
||||
return TenantContext(schema_name="public")
|
||||
|
||||
async def list_tenants(self):
|
||||
from hindsight_api.extensions.tenant import Tenant
|
||||
|
||||
return [Tenant(schema="public")]
|
||||
|
||||
async def get_tenant_config(self, context):
|
||||
"""Return mock tenant config."""
|
||||
return self.tenant_config
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_key_normalization():
|
||||
"""Test that env var keys are normalized to Python field names."""
|
||||
# Test basic normalization
|
||||
assert normalize_config_key("HINDSIGHT_API_LLM_PROVIDER") == "llm_provider"
|
||||
assert normalize_config_key("HINDSIGHT_API_LLM_MODEL") == "llm_model"
|
||||
assert normalize_config_key("HINDSIGHT_API_RETAIN_LLM_PROVIDER") == "retain_llm_provider"
|
||||
|
||||
# Test already normalized keys
|
||||
assert normalize_config_key("llm_provider") == "llm_provider"
|
||||
assert normalize_config_key("llm_model") == "llm_model"
|
||||
|
||||
# Test dict normalization
|
||||
input_dict = {
|
||||
"HINDSIGHT_API_LLM_PROVIDER": "openai",
|
||||
"HINDSIGHT_API_LLM_MODEL": "gpt-4",
|
||||
"llm_base_url": "https://api.openai.com",
|
||||
}
|
||||
expected = {"llm_provider": "openai", "llm_model": "gpt-4", "llm_base_url": "https://api.openai.com"}
|
||||
assert normalize_config_dict(input_dict) == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hierarchical_fields_categorization():
|
||||
"""Test that fields are correctly categorized as configurable, credentials, or static."""
|
||||
configurable = HindsightConfig.get_configurable_fields()
|
||||
credentials = HindsightConfig.get_credential_fields()
|
||||
static = HindsightConfig.get_static_fields()
|
||||
|
||||
# Verify no overlap between configurable and credentials
|
||||
assert len(configurable & credentials) == 0, "Configurable fields should not include credentials"
|
||||
|
||||
# Verify configurable fields include behavioral settings (safe to modify)
|
||||
assert "retain_extraction_mode" in configurable
|
||||
assert "enable_observations" in configurable
|
||||
assert "retain_chunk_size" in configurable
|
||||
assert "retain_custom_instructions" in configurable
|
||||
|
||||
# Verify count is correct (only 4 fields)
|
||||
assert len(configurable) == 4
|
||||
|
||||
# Verify credential fields (NEVER exposed)
|
||||
assert "llm_api_key" in credentials
|
||||
assert "llm_base_url" in credentials
|
||||
assert "retain_llm_api_key" in credentials
|
||||
assert "reflect_llm_api_key" in credentials
|
||||
|
||||
# Verify static fields include server settings AND non-configurable LLM fields
|
||||
assert "database_url" in static
|
||||
assert "port" in static
|
||||
assert "host" in static
|
||||
assert "embeddings_provider" in static
|
||||
assert "reranker_provider" in static
|
||||
assert "worker_enabled" in static
|
||||
assert "llm_provider" in static # Not configurable (needs presets)
|
||||
assert "llm_model" in static # Not configurable (needs presets)
|
||||
assert "graph_retriever" in static # Performance tuning, not configurable
|
||||
assert "llm_max_concurrent" in static # Performance tuning, not configurable
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_hierarchy_resolution(memory, request_context):
|
||||
"""Test that config resolution follows global → tenant → bank hierarchy."""
|
||||
bank_id = "test-hierarchy-bank"
|
||||
|
||||
try:
|
||||
# Ensure bank exists in database
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
# Set up mock tenant extension with tenant-level config (use configurable fields only)
|
||||
tenant_config = {"retain_chunk_size": 5000, "retain_extraction_mode": "tenant-mode"}
|
||||
mock_tenant = MockTenantExtension(tenant_config)
|
||||
|
||||
# Create config resolver with mock tenant extension
|
||||
resolver = ConfigResolver(pool=memory._pool, tenant_extension=mock_tenant)
|
||||
|
||||
# Test 1: Global config only (no overrides)
|
||||
context = RequestContext(api_key=None, api_key_id=None, tenant_id=None, internal=False)
|
||||
config = await resolver.get_bank_config(bank_id, context)
|
||||
|
||||
# Should have configurable fields from global config (NOT credentials or llm_provider/model)
|
||||
assert "retain_chunk_size" in config # Configurable field
|
||||
assert "llm_api_key" not in config # Credential - never exposed
|
||||
assert "llm_provider" not in config # Not configurable (needs presets)
|
||||
|
||||
# Test 2: Add tenant-level overrides
|
||||
config = await resolver.get_bank_config(bank_id, context)
|
||||
|
||||
# Should apply tenant overrides (only configurable fields)
|
||||
assert config["retain_chunk_size"] == 5000 # Tenant override
|
||||
assert config["retain_extraction_mode"] == "tenant-mode" # Tenant override
|
||||
|
||||
# Test 3: Add bank-level overrides (should take precedence)
|
||||
await resolver.update_bank_config(
|
||||
bank_id,
|
||||
{"retain_chunk_size": 2000, "retain_extraction_mode": "bank-mode"}, # Override tenant settings
|
||||
context,
|
||||
)
|
||||
|
||||
# Config should reflect changes immediately (no caching)
|
||||
config = await resolver.get_bank_config(bank_id, context)
|
||||
|
||||
# Bank overrides should take precedence over tenant
|
||||
assert config["retain_chunk_size"] == 2000 # Bank override wins
|
||||
assert config["retain_extraction_mode"] == "bank-mode" # Bank override wins
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_validation_rejects_static_fields(memory, request_context):
|
||||
"""Test that attempting to override static fields raises ValueError."""
|
||||
bank_id = "test-validation-bank"
|
||||
|
||||
try:
|
||||
# Ensure bank exists in database
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
resolver = ConfigResolver(pool=memory._pool)
|
||||
|
||||
# Test 1: Configurable fields should work
|
||||
await resolver.update_bank_config(bank_id, {"retain_chunk_size": 4000, "retain_extraction_mode": "verbose"})
|
||||
|
||||
# Test 2: Static fields should raise ValueError
|
||||
with pytest.raises(ValueError, match="Cannot override static"):
|
||||
await resolver.update_bank_config(bank_id, {"port": 9000})
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot override static"):
|
||||
await resolver.update_bank_config(bank_id, {"database_url": "postgresql://fake"})
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot override static"):
|
||||
await resolver.update_bank_config(bank_id, {"embeddings_provider": "openai"})
|
||||
|
||||
# Test 3: Credential fields should raise ValueError
|
||||
with pytest.raises(ValueError, match="Cannot set credential fields"):
|
||||
await resolver.update_bank_config(bank_id, {"llm_api_key": "sk-fake"})
|
||||
|
||||
# Test 4: Non-configurable LLM fields should raise ValueError (need presets)
|
||||
with pytest.raises(ValueError, match="Cannot override static"):
|
||||
await resolver.update_bank_config(bank_id, {"llm_model": "gpt-4"})
|
||||
|
||||
# Test 5: Mix of configurable and static should fail
|
||||
with pytest.raises(ValueError, match="Cannot override static"):
|
||||
await resolver.update_bank_config(bank_id, {"retain_chunk_size": 4000, "port": 9000})
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_freshness_across_updates(memory, request_context):
|
||||
"""Test that config changes are immediately visible (no stale cache)."""
|
||||
bank1 = "freshness-test-1"
|
||||
|
||||
try:
|
||||
# Ensure bank exists in database
|
||||
await memory.get_bank_profile(bank1, request_context=request_context)
|
||||
|
||||
resolver = ConfigResolver(pool=memory._pool)
|
||||
|
||||
# Test 1: Initial config reflects global defaults
|
||||
config1 = await resolver.get_bank_config(bank1, None)
|
||||
initial_chunk_size = config1["retain_chunk_size"]
|
||||
|
||||
# Test 2: Update config
|
||||
await resolver.update_bank_config(bank1, {"retain_chunk_size": 4000})
|
||||
|
||||
# Test 3: Next call should see updated value immediately (no stale cache)
|
||||
config2 = await resolver.get_bank_config(bank1, None)
|
||||
assert config2["retain_chunk_size"] == 4000
|
||||
|
||||
# Test 4: Multiple updates are all immediately visible
|
||||
await resolver.update_bank_config(bank1, {"retain_chunk_size": 4500})
|
||||
config3 = await resolver.get_bank_config(bank1, None)
|
||||
assert config3["retain_chunk_size"] == 4500
|
||||
|
||||
# Test 5: Reset restores global defaults immediately
|
||||
await resolver.reset_bank_config(bank1)
|
||||
config4 = await resolver.get_bank_config(bank1, None)
|
||||
assert config4["retain_chunk_size"] == initial_chunk_size # Back to global default
|
||||
|
||||
# Test 6: Each call returns a fresh config dict (not a cached reference)
|
||||
config5 = await resolver.get_bank_config(bank1, None)
|
||||
config6 = await resolver.get_bank_config(bank1, None)
|
||||
assert config5 is not config6 # Different object instances
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank1, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_reset_to_defaults(memory, request_context):
|
||||
"""Test that resetting config removes all bank-specific overrides."""
|
||||
bank_id = "test-reset-bank"
|
||||
|
||||
try:
|
||||
# Ensure bank exists in database
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
resolver = ConfigResolver(pool=memory._pool)
|
||||
|
||||
# Add bank-specific overrides
|
||||
await resolver.update_bank_config(
|
||||
bank_id,
|
||||
{
|
||||
"retain_chunk_size": 5500,
|
||||
"retain_extraction_mode": "custom",
|
||||
"retain_custom_instructions": "Custom instructions",
|
||||
},
|
||||
)
|
||||
|
||||
# Verify overrides applied
|
||||
config = await resolver.get_bank_config(bank_id, None)
|
||||
assert config["retain_chunk_size"] == 5500
|
||||
assert config["retain_extraction_mode"] == "custom"
|
||||
assert config["retain_custom_instructions"] == "Custom instructions"
|
||||
|
||||
# Reset to defaults
|
||||
await resolver.reset_bank_config(bank_id)
|
||||
|
||||
# Verify overrides removed (back to global defaults)
|
||||
config_reset = await resolver.get_bank_config(bank_id, None)
|
||||
assert config_reset["retain_chunk_size"] != 5500 # Should be global default
|
||||
assert config_reset["retain_extraction_mode"] != "custom" # Should be global default
|
||||
|
||||
# Verify bank_config is empty
|
||||
bank_overrides = await resolver._load_bank_config(bank_id)
|
||||
assert bank_overrides == {}
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_supports_both_key_formats(memory, request_context):
|
||||
"""Test that API accepts both env var and Python field formats."""
|
||||
bank_id = "test-key-format-bank"
|
||||
|
||||
try:
|
||||
# Ensure bank exists in database
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
resolver = ConfigResolver(pool=memory._pool)
|
||||
|
||||
# Test 1: Python field format
|
||||
await resolver.update_bank_config(bank_id, {"retain_chunk_size": 7000})
|
||||
|
||||
config = await resolver.get_bank_config(bank_id, None)
|
||||
assert config["retain_chunk_size"] == 7000
|
||||
|
||||
# Test 2: Env var format (should be normalized)
|
||||
await resolver.update_bank_config(bank_id, {"HINDSIGHT_API_RETAIN_CHUNK_SIZE": 8000})
|
||||
|
||||
config = await resolver.get_bank_config(bank_id, None)
|
||||
assert config["retain_chunk_size"] == 8000
|
||||
|
||||
# Test 3: Mixed format in same request
|
||||
await resolver.update_bank_config(
|
||||
bank_id,
|
||||
{
|
||||
"retain_chunk_size": 9000, # Python format
|
||||
"HINDSIGHT_API_RETAIN_EXTRACTION_MODE": "verbose", # Env format
|
||||
},
|
||||
)
|
||||
|
||||
config = await resolver.get_bank_config(bank_id, None)
|
||||
assert config["retain_chunk_size"] == 9000
|
||||
assert config["retain_extraction_mode"] == "verbose"
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_only_configurable_fields_stored(memory, request_context):
|
||||
"""Test that only configurable fields are stored in bank config."""
|
||||
bank_id = "test-filter-bank"
|
||||
|
||||
try:
|
||||
# Ensure bank exists in database
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
resolver = ConfigResolver(pool=memory._pool)
|
||||
|
||||
# Add valid configurable field
|
||||
await resolver.update_bank_config(bank_id, {"retain_chunk_size": 3500})
|
||||
|
||||
# Load bank config and verify only configurable fields present
|
||||
bank_overrides = await resolver._load_bank_config(bank_id)
|
||||
|
||||
for key in bank_overrides.keys():
|
||||
assert key in HindsightConfig.get_configurable_fields(), f"Non-configurable field {key} in bank config"
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_get_bank_config_no_static_or_credential_fields_leak(memory, request_context):
|
||||
"""
|
||||
SECURITY TEST: Verify get_bank_config() only returns configurable fields (no static/credentials).
|
||||
|
||||
This prevents leaking sensitive system configuration like database URLs,
|
||||
API keys, LLM providers/models, worker counts, etc. when retrieving bank configuration.
|
||||
"""
|
||||
bank_id = "test-security-bank"
|
||||
|
||||
try:
|
||||
# Ensure bank exists in database
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
resolver = ConfigResolver(pool=memory._pool)
|
||||
|
||||
# Get bank config
|
||||
config = await resolver.get_bank_config(bank_id, None)
|
||||
|
||||
# Get field categorizations
|
||||
configurable_fields = HindsightConfig.get_configurable_fields()
|
||||
credential_fields = HindsightConfig.get_credential_fields()
|
||||
static_fields = HindsightConfig.get_static_fields()
|
||||
|
||||
# SECURITY: Verify ONLY configurable fields are returned (NO static, NO credentials)
|
||||
for key in config.keys():
|
||||
assert key in configurable_fields, (
|
||||
f"SECURITY VIOLATION: Non-configurable field '{key}' returned by get_bank_config(). "
|
||||
f"Only configurable fields should be returned to prevent leaking system config."
|
||||
)
|
||||
assert key not in credential_fields, (
|
||||
f"SECURITY VIOLATION: Credential field '{key}' returned by get_bank_config(). "
|
||||
f"Credentials must NEVER be exposed via API."
|
||||
)
|
||||
|
||||
# SECURITY: Verify specific sensitive fields are NOT present
|
||||
sensitive_fields = [
|
||||
"database_url", "api_port", "host", "worker_count", # Infrastructure
|
||||
"llm_api_key", "llm_base_url", # Credentials
|
||||
"retain_llm_api_key", "reflect_llm_api_key", # More credentials
|
||||
"llm_provider", "llm_model", # Not configurable (need presets)
|
||||
]
|
||||
for field in sensitive_fields:
|
||||
assert field not in config, (
|
||||
f"SECURITY VIOLATION: Sensitive field '{field}' returned by get_bank_config(). "
|
||||
f"Must not be exposed via bank config API."
|
||||
)
|
||||
|
||||
# Verify we have the expected configurable fields (small set)
|
||||
expected_configurable = ["retain_chunk_size", "retain_extraction_mode", "enable_observations"]
|
||||
for field in expected_configurable:
|
||||
assert field in config, f"Expected configurable field '{field}' missing from config"
|
||||
|
||||
# Should have a small number of configurable fields (not hundreds)
|
||||
assert len(config) < 20, f"Too many fields returned: {len(config)}"
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_permissions_system(memory, request_context):
|
||||
"""
|
||||
Test that tenant extension can control which fields banks are allowed to modify.
|
||||
|
||||
Tests get_allowed_config_fields() permission system.
|
||||
"""
|
||||
bank_id = "test-permissions-bank"
|
||||
|
||||
class PermissionTenantExtension(TenantExtension):
|
||||
"""Mock tenant extension with configurable permissions."""
|
||||
|
||||
def __init__(self, allowed_fields: set[str] | None):
|
||||
self.allowed_fields = allowed_fields
|
||||
|
||||
async def authenticate(self, context):
|
||||
from hindsight_api.extensions.tenant import TenantContext
|
||||
|
||||
return TenantContext(schema_name="public")
|
||||
|
||||
async def list_tenants(self):
|
||||
from hindsight_api.extensions.tenant import Tenant
|
||||
|
||||
return [Tenant(schema="public")]
|
||||
|
||||
async def get_allowed_config_fields(self, context, bank_id):
|
||||
"""Return configured allowed fields."""
|
||||
return self.allowed_fields
|
||||
|
||||
try:
|
||||
# Ensure bank exists in database
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
# Test 1: None = allow all configurable fields
|
||||
extension = PermissionTenantExtension(allowed_fields=None)
|
||||
resolver = ConfigResolver(pool=memory._pool, tenant_extension=extension)
|
||||
|
||||
await resolver.update_bank_config(
|
||||
bank_id, {"retain_chunk_size": 4000, "retain_extraction_mode": "verbose"}, request_context
|
||||
)
|
||||
config = await resolver.get_bank_config(bank_id, request_context)
|
||||
assert config["retain_chunk_size"] == 4000
|
||||
assert config["retain_extraction_mode"] == "verbose"
|
||||
|
||||
# Reset for next test
|
||||
await resolver.reset_bank_config(bank_id)
|
||||
|
||||
# Test 2: Specific set = only those fields allowed
|
||||
extension = PermissionTenantExtension(allowed_fields={"retain_chunk_size"})
|
||||
resolver = ConfigResolver(pool=memory._pool, tenant_extension=extension)
|
||||
|
||||
# Should allow retain_chunk_size
|
||||
await resolver.update_bank_config(bank_id, {"retain_chunk_size": 5000}, request_context)
|
||||
config = await resolver.get_bank_config(bank_id, request_context)
|
||||
assert config["retain_chunk_size"] == 5000
|
||||
|
||||
# Should reject retain_extraction_mode (not in allowed list)
|
||||
with pytest.raises(ValueError, match="Not allowed to modify fields"):
|
||||
await resolver.update_bank_config(bank_id, {"retain_extraction_mode": "verbose"}, request_context)
|
||||
|
||||
# Should reject mix of allowed and disallowed
|
||||
with pytest.raises(ValueError, match="Not allowed to modify fields"):
|
||||
await resolver.update_bank_config(
|
||||
bank_id, {"retain_chunk_size": 6000, "retain_extraction_mode": "verbose"}, request_context
|
||||
)
|
||||
|
||||
# Reset for next test
|
||||
await resolver.reset_bank_config(bank_id)
|
||||
|
||||
# Test 3: Empty set = no modifications allowed (read-only)
|
||||
extension = PermissionTenantExtension(allowed_fields=set())
|
||||
resolver = ConfigResolver(pool=memory._pool, tenant_extension=extension)
|
||||
|
||||
with pytest.raises(ValueError, match="Not allowed to modify fields"):
|
||||
await resolver.update_bank_config(bank_id, {"retain_chunk_size": 7000}, request_context)
|
||||
|
||||
# Test 4: get_bank_config should filter response based on permissions
|
||||
extension = PermissionTenantExtension(allowed_fields={"retain_chunk_size", "enable_observations"})
|
||||
resolver = ConfigResolver(pool=memory._pool, tenant_extension=extension)
|
||||
|
||||
config = await resolver.get_bank_config(bank_id, request_context)
|
||||
|
||||
# Should only return allowed fields
|
||||
assert "retain_chunk_size" in config
|
||||
assert "enable_observations" in config
|
||||
# Other configurable fields should be filtered out
|
||||
assert "retain_extraction_mode" not in config
|
||||
assert "retain_custom_instructions" not in config
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
|
@ -12,9 +12,9 @@ import pytest
|
|||
@pytest.fixture(autouse=True)
|
||||
def enable_observations():
|
||||
"""Enable observations for all tests in this module."""
|
||||
from hindsight_api.config import get_config
|
||||
from hindsight_api.config import _get_raw_config
|
||||
|
||||
config = get_config()
|
||||
config = _get_raw_config()
|
||||
original_value = config.enable_observations
|
||||
config.enable_observations = True
|
||||
yield
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ class TestMainModuleExtensionLoading:
|
|||
|
||||
with patch("hindsight_api.main.MemoryEngine") as mock_engine, \
|
||||
patch("hindsight_api.main.create_app") as mock_create_app, \
|
||||
patch("hindsight_api.main.get_config") as mock_get_config, \
|
||||
patch("hindsight_api.main._get_raw_config") as mock_get_config, \
|
||||
patch("hindsight_api.main.load_extension", side_effect=tracking_load_extension), \
|
||||
patch("hindsight_api.main.DefaultExtensionContext"), \
|
||||
patch("hindsight_api.main.print_banner"), \
|
||||
|
|
@ -96,7 +96,7 @@ class TestMainModuleExtensionLoading:
|
|||
|
||||
with patch("hindsight_api.main.MemoryEngine") as mock_engine, \
|
||||
patch("hindsight_api.main.create_app") as mock_create_app, \
|
||||
patch("hindsight_api.main.get_config") as mock_get_config, \
|
||||
patch("hindsight_api.main._get_raw_config") as mock_get_config, \
|
||||
patch("hindsight_api.main.load_extension", side_effect=tracking_load_extension), \
|
||||
patch("hindsight_api.main.DefaultExtensionContext"), \
|
||||
patch("hindsight_api.main.print_banner"), \
|
||||
|
|
@ -143,7 +143,7 @@ class TestMainModuleExtensionLoading:
|
|||
|
||||
with patch("hindsight_api.main.MemoryEngine", side_effect=capture_memory_engine), \
|
||||
patch("hindsight_api.main.create_app") as mock_create_app, \
|
||||
patch("hindsight_api.main.get_config") as mock_get_config, \
|
||||
patch("hindsight_api.main._get_raw_config") as mock_get_config, \
|
||||
patch("hindsight_api.main.DefaultExtensionContext"), \
|
||||
patch("hindsight_api.main.print_banner"), \
|
||||
patch("uvicorn.run"):
|
||||
|
|
@ -200,7 +200,7 @@ class TestMainModuleExtensionLoading:
|
|||
|
||||
with patch("hindsight_api.main.MemoryEngine", side_effect=capture_memory_engine), \
|
||||
patch("hindsight_api.main.create_app") as mock_create_app, \
|
||||
patch("hindsight_api.main.get_config") as mock_get_config, \
|
||||
patch("hindsight_api.main._get_raw_config") as mock_get_config, \
|
||||
patch("hindsight_api.main.DefaultExtensionContext", side_effect=capture_context), \
|
||||
patch("hindsight_api.main.print_banner"), \
|
||||
patch("uvicorn.run"):
|
||||
|
|
@ -242,7 +242,7 @@ class TestMainModuleExtensionLoading:
|
|||
|
||||
with patch("hindsight_api.main.MemoryEngine", side_effect=capture_memory_engine), \
|
||||
patch("hindsight_api.main.create_app") as mock_create_app, \
|
||||
patch("hindsight_api.main.get_config") as mock_get_config, \
|
||||
patch("hindsight_api.main._get_raw_config") as mock_get_config, \
|
||||
patch("hindsight_api.main.print_banner"), \
|
||||
patch("uvicorn.run"):
|
||||
|
||||
|
|
@ -287,7 +287,7 @@ class TestMainModuleExtensionLoading:
|
|||
|
||||
with patch("hindsight_api.main.MemoryEngine") as mock_engine, \
|
||||
patch("hindsight_api.main.create_app", return_value=mock_app), \
|
||||
patch("hindsight_api.main.get_config") as mock_get_config, \
|
||||
patch("hindsight_api.main._get_raw_config") as mock_get_config, \
|
||||
patch("hindsight_api.main.print_banner"), \
|
||||
patch("uvicorn.run", side_effect=capture_uvicorn_run):
|
||||
|
||||
|
|
@ -327,7 +327,7 @@ class TestMainModuleExtensionLoading:
|
|||
|
||||
with patch("hindsight_api.main.MemoryEngine") as mock_engine, \
|
||||
patch("hindsight_api.main.create_app") as mock_create_app, \
|
||||
patch("hindsight_api.main.get_config") as mock_get_config, \
|
||||
patch("hindsight_api.main._get_raw_config") as mock_get_config, \
|
||||
patch("hindsight_api.main.print_banner"), \
|
||||
patch("uvicorn.run", side_effect=capture_uvicorn_run):
|
||||
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@ populated from the summary for backwards compatibility.
|
|||
import pytest
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
from hindsight_api import RequestContext
|
||||
from hindsight_api.config import get_config
|
||||
from hindsight_api.config import _get_raw_config
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disable_observations():
|
||||
"""Disable observations for a specific test."""
|
||||
config = get_config()
|
||||
config = _get_raw_config()
|
||||
original_value = config.enable_observations
|
||||
config.enable_observations = False
|
||||
yield
|
||||
|
|
|
|||
|
|
@ -2093,7 +2093,7 @@ async def test_custom_extraction_mode():
|
|||
import os
|
||||
from hindsight_api import LLMConfig
|
||||
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
|
||||
from hindsight_api.config import clear_config_cache
|
||||
from hindsight_api.config import clear_config_cache, _get_raw_config
|
||||
|
||||
# Save original env vars
|
||||
original_mode = os.getenv("HINDSIGHT_API_RETAIN_EXTRACTION_MODE")
|
||||
|
|
@ -2135,7 +2135,8 @@ If the text contains both Italian and English content, extract ONLY the Italian
|
|||
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||
context="team meeting notes",
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
agent_name="TestUser",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
logger.info(f"\nExtracted {len(facts)} facts with custom mode (Italian only):")
|
||||
|
|
|
|||
|
|
@ -387,6 +387,43 @@ impl ApiClient {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn get_bank_config(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::BankConfigResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_bank_config(bank_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_bank_config(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
updates: std::collections::HashMap<String, serde_json::Value>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::BankConfigResponse> {
|
||||
self.runtime.block_on(async {
|
||||
// Convert HashMap to serde_json::Map
|
||||
let updates_map: serde_json::Map<String, serde_json::Value> = updates.into_iter().collect();
|
||||
let request = types::BankConfigUpdate { updates: updates_map };
|
||||
let response = self.client.update_bank_config(bank_id, None, &request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reset_bank_config(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::BankConfigResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.reset_bank_config(bank_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Tag Methods ---
|
||||
|
||||
pub fn list_tags(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use anyhow::Result;
|
||||
use anyhow::{anyhow, Result};
|
||||
use crate::api::ApiClient;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
|
|
@ -655,3 +655,159 @@ pub fn clear_observations(
|
|||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
overrides_only: bool,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching bank configuration..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_bank_config(bank_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Configuration for bank '{}'", bank_id));
|
||||
println!();
|
||||
if overrides_only {
|
||||
println!("Bank-specific overrides:");
|
||||
if result.overrides.is_empty() {
|
||||
println!(" (none - using defaults)");
|
||||
} else {
|
||||
for (key, value) in result.overrides.iter() {
|
||||
println!(" {}: {:?}", key, value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("Resolved configuration (with all overrides applied):");
|
||||
for (key, value) in result.config.iter() {
|
||||
println!(" {}: {:?}", key, value);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if overrides_only {
|
||||
output::print_output(&result.overrides, output_format)?;
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_config(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
llm_provider: Option<String>,
|
||||
llm_model: Option<String>,
|
||||
llm_api_key: Option<String>,
|
||||
llm_base_url: Option<String>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let mut updates: HashMap<String, serde_json::Value> = HashMap::new();
|
||||
|
||||
if let Some(provider) = llm_provider {
|
||||
updates.insert("llm_provider".to_string(), serde_json::Value::String(provider));
|
||||
}
|
||||
if let Some(model) = llm_model {
|
||||
updates.insert("llm_model".to_string(), serde_json::Value::String(model));
|
||||
}
|
||||
if let Some(api_key) = llm_api_key {
|
||||
updates.insert("llm_api_key".to_string(), serde_json::Value::String(api_key));
|
||||
}
|
||||
if let Some(base_url) = llm_base_url {
|
||||
updates.insert("llm_base_url".to_string(), serde_json::Value::String(base_url));
|
||||
}
|
||||
|
||||
if updates.is_empty() {
|
||||
return Err(anyhow!("No config updates provided. Use --llm-provider, --llm-model, --llm-api-key, or --llm-base-url".to_string()));
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Updating bank configuration..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.update_bank_config(bank_id, updates, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Configuration updated for bank '{}'", bank_id));
|
||||
println!("\nUpdated overrides:");
|
||||
for (key, value) in result.overrides.iter() {
|
||||
println!(" {}: {:?}", key, value);
|
||||
}
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset_config(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
yes: bool,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
if !yes && output_format == OutputFormat::Pretty {
|
||||
let confirmed = ui::prompt_confirmation(&format!(
|
||||
"Reset all configuration overrides for bank '{}'?",
|
||||
bank_id
|
||||
))?;
|
||||
|
||||
if !confirmed {
|
||||
ui::print_info("Operation cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Resetting bank configuration..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.reset_bank_config(bank_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Configuration reset to defaults for bank '{}'", bank_id));
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,8 +58,22 @@ fn format_error_message(err: &anyhow::Error, api_url: &str) -> String {
|
|||
);
|
||||
}
|
||||
|
||||
// 404 Not Found
|
||||
// 404 Not Found - check for disabled features first
|
||||
if err_str.contains("404") {
|
||||
if err_str.contains("Bank configuration API is disabled") {
|
||||
return format!(
|
||||
"{} {}\n\n{}\n {}\n\n{}\n {}\n\n{}\n {}",
|
||||
"✗".bright_red().bold(),
|
||||
"Bank configuration API is disabled".bright_red().bold(),
|
||||
"API URL:".bright_yellow(),
|
||||
api_url.bright_white(),
|
||||
"This feature is disabled by default for security.".bright_yellow(),
|
||||
"To enable, set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true on the API server".bright_white(),
|
||||
"Note:".bright_cyan(),
|
||||
"This allows per-bank LLM configuration overrides via API".bright_white()
|
||||
);
|
||||
}
|
||||
|
||||
return format!(
|
||||
"{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n\n{}\n {}",
|
||||
"✗".bright_red().bold(),
|
||||
|
|
@ -74,8 +88,8 @@ fn format_error_message(err: &anyhow::Error, api_url: &str) -> String {
|
|||
);
|
||||
}
|
||||
|
||||
// 401/403 Authentication
|
||||
if err_str.contains("401") || err_str.contains("403") {
|
||||
// 401 Authentication failed
|
||||
if err_str.contains("401") {
|
||||
return format!(
|
||||
"{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n\n{}\n {}",
|
||||
"✗".bright_red().bold(),
|
||||
|
|
@ -90,6 +104,22 @@ fn format_error_message(err: &anyhow::Error, api_url: &str) -> String {
|
|||
);
|
||||
}
|
||||
|
||||
// 403 Forbidden
|
||||
if err_str.contains("403") {
|
||||
return format!(
|
||||
"{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n\n{}\n {}",
|
||||
"✗".bright_red().bold(),
|
||||
"Permission denied (403)".bright_red().bold(),
|
||||
"API URL:".bright_yellow(),
|
||||
api_url.bright_white(),
|
||||
"Possible causes:".bright_yellow(),
|
||||
"This operation is not allowed".bright_white(),
|
||||
"The feature may be disabled on the server".bright_white(),
|
||||
"Try:".bright_green(),
|
||||
"Check server configuration or contact your administrator".bright_white()
|
||||
);
|
||||
}
|
||||
|
||||
// 500 Server Error
|
||||
if err_str.contains("500") || err_str.contains("502") || err_str.contains("503") {
|
||||
return format!(
|
||||
|
|
|
|||
|
|
@ -279,6 +279,48 @@ enum BankCommands {
|
|||
#[arg(short = 'y', long)]
|
||||
yes: bool,
|
||||
},
|
||||
|
||||
/// Get bank configuration (hierarchical overrides)
|
||||
Config {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Show only bank-specific overrides (not full resolved config)
|
||||
#[arg(long)]
|
||||
overrides_only: bool,
|
||||
},
|
||||
|
||||
/// Update bank configuration (set hierarchical overrides)
|
||||
SetConfig {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// LLM provider override
|
||||
#[arg(long)]
|
||||
llm_provider: Option<String>,
|
||||
|
||||
/// LLM model override
|
||||
#[arg(long)]
|
||||
llm_model: Option<String>,
|
||||
|
||||
/// LLM API key override
|
||||
#[arg(long)]
|
||||
llm_api_key: Option<String>,
|
||||
|
||||
/// LLM base URL override
|
||||
#[arg(long)]
|
||||
llm_base_url: Option<String>,
|
||||
},
|
||||
|
||||
/// Reset bank configuration to defaults (remove all overrides)
|
||||
ResetConfig {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short = 'y', long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
|
|
@ -776,6 +818,15 @@ fn run() -> Result<()> {
|
|||
BankCommands::ClearObservations { bank_id, yes } => {
|
||||
commands::bank::clear_observations(&client, &bank_id, yes, verbose, output_format)
|
||||
}
|
||||
BankCommands::Config { bank_id, overrides_only } => {
|
||||
commands::bank::config(&client, &bank_id, overrides_only, verbose, output_format)
|
||||
}
|
||||
BankCommands::SetConfig { bank_id, llm_provider, llm_model, llm_api_key, llm_base_url } => {
|
||||
commands::bank::set_config(&client, &bank_id, llm_provider, llm_model, llm_api_key, llm_base_url, verbose, output_format)
|
||||
}
|
||||
BankCommands::ResetConfig { bank_id, yes } => {
|
||||
commands::bank::reset_config(&client, &bank_id, yes, verbose, output_format)
|
||||
}
|
||||
},
|
||||
|
||||
// Memory commands
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ hindsight_client_api/models/__init__.py
|
|||
hindsight_client_api/models/add_background_request.py
|
||||
hindsight_client_api/models/async_operation_submit_response.py
|
||||
hindsight_client_api/models/background_response.py
|
||||
hindsight_client_api/models/bank_config_response.py
|
||||
hindsight_client_api/models/bank_config_update.py
|
||||
hindsight_client_api/models/bank_list_item.py
|
||||
hindsight_client_api/models/bank_list_response.py
|
||||
hindsight_client_api/models/bank_profile_response.py
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ from hindsight_client_api.exceptions import ApiException
|
|||
from hindsight_client_api.models.add_background_request import AddBackgroundRequest
|
||||
from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse
|
||||
from hindsight_client_api.models.background_response import BackgroundResponse
|
||||
from hindsight_client_api.models.bank_config_response import BankConfigResponse
|
||||
from hindsight_client_api.models.bank_config_update import BankConfigUpdate
|
||||
from hindsight_client_api.models.bank_list_item import BankListItem
|
||||
from hindsight_client_api.models.bank_list_response import BankListResponse
|
||||
from hindsight_client_api.models.bank_profile_response import BankProfileResponse
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ from pydantic import StrictStr
|
|||
from typing import Optional
|
||||
from hindsight_client_api.models.add_background_request import AddBackgroundRequest
|
||||
from hindsight_client_api.models.background_response import BackgroundResponse
|
||||
from hindsight_client_api.models.bank_config_response import BankConfigResponse
|
||||
from hindsight_client_api.models.bank_config_update import BankConfigUpdate
|
||||
from hindsight_client_api.models.bank_list_response import BankListResponse
|
||||
from hindsight_client_api.models.bank_profile_response import BankProfileResponse
|
||||
from hindsight_client_api.models.bank_stats_response import BankStatsResponse
|
||||
|
|
@ -1495,6 +1497,284 @@ class BanksApi:
|
|||
|
||||
|
||||
|
||||
@validate_call
|
||||
async def get_bank_config(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Tuple[
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Annotated[StrictFloat, Field(gt=0)]
|
||||
]
|
||||
] = None,
|
||||
_request_auth: Optional[Dict[StrictStr, Any]] = None,
|
||||
_content_type: Optional[StrictStr] = None,
|
||||
_headers: Optional[Dict[StrictStr, Any]] = None,
|
||||
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
||||
) -> BankConfigResponse:
|
||||
"""Get bank configuration
|
||||
|
||||
Get fully resolved configuration for a bank including all hierarchical overrides (global → tenant → bank). The 'config' field contains all resolved config values. The 'overrides' field shows only bank-specific overrides.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
number provided, it will be total request
|
||||
timeout. It can also be a pair (tuple) of
|
||||
(connection, read) timeouts.
|
||||
:type _request_timeout: int, tuple(int, int), optional
|
||||
:param _request_auth: set to override the auth_settings for an a single
|
||||
request; this effectively ignores the
|
||||
authentication in the spec for a single request.
|
||||
:type _request_auth: dict, optional
|
||||
:param _content_type: force content-type for the request.
|
||||
:type _content_type: str, Optional
|
||||
:param _headers: set to override the headers for a single
|
||||
request; this effectively ignores the headers
|
||||
in the spec for a single request.
|
||||
:type _headers: dict, optional
|
||||
:param _host_index: set to override the host_index for a single
|
||||
request; this effectively ignores the host_index
|
||||
in the spec for a single request.
|
||||
:type _host_index: int, optional
|
||||
:return: Returns the result object.
|
||||
""" # noqa: E501
|
||||
|
||||
_param = self._get_bank_config_serialize(
|
||||
bank_id=bank_id,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
_host_index=_host_index
|
||||
)
|
||||
|
||||
_response_types_map: Dict[str, Optional[str]] = {
|
||||
'200': "BankConfigResponse",
|
||||
'422': "HTTPValidationError",
|
||||
}
|
||||
response_data = await self.api_client.call_api(
|
||||
*_param,
|
||||
_request_timeout=_request_timeout
|
||||
)
|
||||
await response_data.read()
|
||||
return self.api_client.response_deserialize(
|
||||
response_data=response_data,
|
||||
response_types_map=_response_types_map,
|
||||
).data
|
||||
|
||||
|
||||
@validate_call
|
||||
async def get_bank_config_with_http_info(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Tuple[
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Annotated[StrictFloat, Field(gt=0)]
|
||||
]
|
||||
] = None,
|
||||
_request_auth: Optional[Dict[StrictStr, Any]] = None,
|
||||
_content_type: Optional[StrictStr] = None,
|
||||
_headers: Optional[Dict[StrictStr, Any]] = None,
|
||||
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
||||
) -> ApiResponse[BankConfigResponse]:
|
||||
"""Get bank configuration
|
||||
|
||||
Get fully resolved configuration for a bank including all hierarchical overrides (global → tenant → bank). The 'config' field contains all resolved config values. The 'overrides' field shows only bank-specific overrides.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
number provided, it will be total request
|
||||
timeout. It can also be a pair (tuple) of
|
||||
(connection, read) timeouts.
|
||||
:type _request_timeout: int, tuple(int, int), optional
|
||||
:param _request_auth: set to override the auth_settings for an a single
|
||||
request; this effectively ignores the
|
||||
authentication in the spec for a single request.
|
||||
:type _request_auth: dict, optional
|
||||
:param _content_type: force content-type for the request.
|
||||
:type _content_type: str, Optional
|
||||
:param _headers: set to override the headers for a single
|
||||
request; this effectively ignores the headers
|
||||
in the spec for a single request.
|
||||
:type _headers: dict, optional
|
||||
:param _host_index: set to override the host_index for a single
|
||||
request; this effectively ignores the host_index
|
||||
in the spec for a single request.
|
||||
:type _host_index: int, optional
|
||||
:return: Returns the result object.
|
||||
""" # noqa: E501
|
||||
|
||||
_param = self._get_bank_config_serialize(
|
||||
bank_id=bank_id,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
_host_index=_host_index
|
||||
)
|
||||
|
||||
_response_types_map: Dict[str, Optional[str]] = {
|
||||
'200': "BankConfigResponse",
|
||||
'422': "HTTPValidationError",
|
||||
}
|
||||
response_data = await self.api_client.call_api(
|
||||
*_param,
|
||||
_request_timeout=_request_timeout
|
||||
)
|
||||
await response_data.read()
|
||||
return self.api_client.response_deserialize(
|
||||
response_data=response_data,
|
||||
response_types_map=_response_types_map,
|
||||
)
|
||||
|
||||
|
||||
@validate_call
|
||||
async def get_bank_config_without_preload_content(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Tuple[
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Annotated[StrictFloat, Field(gt=0)]
|
||||
]
|
||||
] = None,
|
||||
_request_auth: Optional[Dict[StrictStr, Any]] = None,
|
||||
_content_type: Optional[StrictStr] = None,
|
||||
_headers: Optional[Dict[StrictStr, Any]] = None,
|
||||
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
||||
) -> RESTResponseType:
|
||||
"""Get bank configuration
|
||||
|
||||
Get fully resolved configuration for a bank including all hierarchical overrides (global → tenant → bank). The 'config' field contains all resolved config values. The 'overrides' field shows only bank-specific overrides.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
number provided, it will be total request
|
||||
timeout. It can also be a pair (tuple) of
|
||||
(connection, read) timeouts.
|
||||
:type _request_timeout: int, tuple(int, int), optional
|
||||
:param _request_auth: set to override the auth_settings for an a single
|
||||
request; this effectively ignores the
|
||||
authentication in the spec for a single request.
|
||||
:type _request_auth: dict, optional
|
||||
:param _content_type: force content-type for the request.
|
||||
:type _content_type: str, Optional
|
||||
:param _headers: set to override the headers for a single
|
||||
request; this effectively ignores the headers
|
||||
in the spec for a single request.
|
||||
:type _headers: dict, optional
|
||||
:param _host_index: set to override the host_index for a single
|
||||
request; this effectively ignores the host_index
|
||||
in the spec for a single request.
|
||||
:type _host_index: int, optional
|
||||
:return: Returns the result object.
|
||||
""" # noqa: E501
|
||||
|
||||
_param = self._get_bank_config_serialize(
|
||||
bank_id=bank_id,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
_host_index=_host_index
|
||||
)
|
||||
|
||||
_response_types_map: Dict[str, Optional[str]] = {
|
||||
'200': "BankConfigResponse",
|
||||
'422': "HTTPValidationError",
|
||||
}
|
||||
response_data = await self.api_client.call_api(
|
||||
*_param,
|
||||
_request_timeout=_request_timeout
|
||||
)
|
||||
return response_data.response
|
||||
|
||||
|
||||
def _get_bank_config_serialize(
|
||||
self,
|
||||
bank_id,
|
||||
authorization,
|
||||
_request_auth,
|
||||
_content_type,
|
||||
_headers,
|
||||
_host_index,
|
||||
) -> RequestSerialized:
|
||||
|
||||
_host = None
|
||||
|
||||
_collection_formats: Dict[str, str] = {
|
||||
}
|
||||
|
||||
_path_params: Dict[str, str] = {}
|
||||
_query_params: List[Tuple[str, str]] = []
|
||||
_header_params: Dict[str, Optional[str]] = _headers or {}
|
||||
_form_params: List[Tuple[str, str]] = []
|
||||
_files: Dict[
|
||||
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
|
||||
] = {}
|
||||
_body_params: Optional[bytes] = None
|
||||
|
||||
# process the path parameters
|
||||
if bank_id is not None:
|
||||
_path_params['bank_id'] = bank_id
|
||||
# process the query parameters
|
||||
# process the header parameters
|
||||
if authorization is not None:
|
||||
_header_params['authorization'] = authorization
|
||||
# process the form parameters
|
||||
# process the body parameter
|
||||
|
||||
|
||||
# set the HTTP header `Accept`
|
||||
if 'Accept' not in _header_params:
|
||||
_header_params['Accept'] = self.api_client.select_header_accept(
|
||||
[
|
||||
'application/json'
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# authentication setting
|
||||
_auth_settings: List[str] = [
|
||||
]
|
||||
|
||||
return self.api_client.param_serialize(
|
||||
method='GET',
|
||||
resource_path='/v1/default/banks/{bank_id}/config',
|
||||
path_params=_path_params,
|
||||
query_params=_query_params,
|
||||
header_params=_header_params,
|
||||
body=_body_params,
|
||||
post_params=_form_params,
|
||||
files=_files,
|
||||
auth_settings=_auth_settings,
|
||||
collection_formats=_collection_formats,
|
||||
_host=_host,
|
||||
_request_auth=_request_auth
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@validate_call
|
||||
async def get_bank_profile(
|
||||
self,
|
||||
|
|
@ -2036,6 +2316,284 @@ class BanksApi:
|
|||
|
||||
|
||||
|
||||
@validate_call
|
||||
async def reset_bank_config(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Tuple[
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Annotated[StrictFloat, Field(gt=0)]
|
||||
]
|
||||
] = None,
|
||||
_request_auth: Optional[Dict[StrictStr, Any]] = None,
|
||||
_content_type: Optional[StrictStr] = None,
|
||||
_headers: Optional[Dict[StrictStr, Any]] = None,
|
||||
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
||||
) -> BankConfigResponse:
|
||||
"""Reset bank configuration
|
||||
|
||||
Reset bank configuration to defaults by removing all bank-specific overrides. The bank will then use global and tenant-level configuration only.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
number provided, it will be total request
|
||||
timeout. It can also be a pair (tuple) of
|
||||
(connection, read) timeouts.
|
||||
:type _request_timeout: int, tuple(int, int), optional
|
||||
:param _request_auth: set to override the auth_settings for an a single
|
||||
request; this effectively ignores the
|
||||
authentication in the spec for a single request.
|
||||
:type _request_auth: dict, optional
|
||||
:param _content_type: force content-type for the request.
|
||||
:type _content_type: str, Optional
|
||||
:param _headers: set to override the headers for a single
|
||||
request; this effectively ignores the headers
|
||||
in the spec for a single request.
|
||||
:type _headers: dict, optional
|
||||
:param _host_index: set to override the host_index for a single
|
||||
request; this effectively ignores the host_index
|
||||
in the spec for a single request.
|
||||
:type _host_index: int, optional
|
||||
:return: Returns the result object.
|
||||
""" # noqa: E501
|
||||
|
||||
_param = self._reset_bank_config_serialize(
|
||||
bank_id=bank_id,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
_host_index=_host_index
|
||||
)
|
||||
|
||||
_response_types_map: Dict[str, Optional[str]] = {
|
||||
'200': "BankConfigResponse",
|
||||
'422': "HTTPValidationError",
|
||||
}
|
||||
response_data = await self.api_client.call_api(
|
||||
*_param,
|
||||
_request_timeout=_request_timeout
|
||||
)
|
||||
await response_data.read()
|
||||
return self.api_client.response_deserialize(
|
||||
response_data=response_data,
|
||||
response_types_map=_response_types_map,
|
||||
).data
|
||||
|
||||
|
||||
@validate_call
|
||||
async def reset_bank_config_with_http_info(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Tuple[
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Annotated[StrictFloat, Field(gt=0)]
|
||||
]
|
||||
] = None,
|
||||
_request_auth: Optional[Dict[StrictStr, Any]] = None,
|
||||
_content_type: Optional[StrictStr] = None,
|
||||
_headers: Optional[Dict[StrictStr, Any]] = None,
|
||||
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
||||
) -> ApiResponse[BankConfigResponse]:
|
||||
"""Reset bank configuration
|
||||
|
||||
Reset bank configuration to defaults by removing all bank-specific overrides. The bank will then use global and tenant-level configuration only.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
number provided, it will be total request
|
||||
timeout. It can also be a pair (tuple) of
|
||||
(connection, read) timeouts.
|
||||
:type _request_timeout: int, tuple(int, int), optional
|
||||
:param _request_auth: set to override the auth_settings for an a single
|
||||
request; this effectively ignores the
|
||||
authentication in the spec for a single request.
|
||||
:type _request_auth: dict, optional
|
||||
:param _content_type: force content-type for the request.
|
||||
:type _content_type: str, Optional
|
||||
:param _headers: set to override the headers for a single
|
||||
request; this effectively ignores the headers
|
||||
in the spec for a single request.
|
||||
:type _headers: dict, optional
|
||||
:param _host_index: set to override the host_index for a single
|
||||
request; this effectively ignores the host_index
|
||||
in the spec for a single request.
|
||||
:type _host_index: int, optional
|
||||
:return: Returns the result object.
|
||||
""" # noqa: E501
|
||||
|
||||
_param = self._reset_bank_config_serialize(
|
||||
bank_id=bank_id,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
_host_index=_host_index
|
||||
)
|
||||
|
||||
_response_types_map: Dict[str, Optional[str]] = {
|
||||
'200': "BankConfigResponse",
|
||||
'422': "HTTPValidationError",
|
||||
}
|
||||
response_data = await self.api_client.call_api(
|
||||
*_param,
|
||||
_request_timeout=_request_timeout
|
||||
)
|
||||
await response_data.read()
|
||||
return self.api_client.response_deserialize(
|
||||
response_data=response_data,
|
||||
response_types_map=_response_types_map,
|
||||
)
|
||||
|
||||
|
||||
@validate_call
|
||||
async def reset_bank_config_without_preload_content(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Tuple[
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Annotated[StrictFloat, Field(gt=0)]
|
||||
]
|
||||
] = None,
|
||||
_request_auth: Optional[Dict[StrictStr, Any]] = None,
|
||||
_content_type: Optional[StrictStr] = None,
|
||||
_headers: Optional[Dict[StrictStr, Any]] = None,
|
||||
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
||||
) -> RESTResponseType:
|
||||
"""Reset bank configuration
|
||||
|
||||
Reset bank configuration to defaults by removing all bank-specific overrides. The bank will then use global and tenant-level configuration only.
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
number provided, it will be total request
|
||||
timeout. It can also be a pair (tuple) of
|
||||
(connection, read) timeouts.
|
||||
:type _request_timeout: int, tuple(int, int), optional
|
||||
:param _request_auth: set to override the auth_settings for an a single
|
||||
request; this effectively ignores the
|
||||
authentication in the spec for a single request.
|
||||
:type _request_auth: dict, optional
|
||||
:param _content_type: force content-type for the request.
|
||||
:type _content_type: str, Optional
|
||||
:param _headers: set to override the headers for a single
|
||||
request; this effectively ignores the headers
|
||||
in the spec for a single request.
|
||||
:type _headers: dict, optional
|
||||
:param _host_index: set to override the host_index for a single
|
||||
request; this effectively ignores the host_index
|
||||
in the spec for a single request.
|
||||
:type _host_index: int, optional
|
||||
:return: Returns the result object.
|
||||
""" # noqa: E501
|
||||
|
||||
_param = self._reset_bank_config_serialize(
|
||||
bank_id=bank_id,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
_host_index=_host_index
|
||||
)
|
||||
|
||||
_response_types_map: Dict[str, Optional[str]] = {
|
||||
'200': "BankConfigResponse",
|
||||
'422': "HTTPValidationError",
|
||||
}
|
||||
response_data = await self.api_client.call_api(
|
||||
*_param,
|
||||
_request_timeout=_request_timeout
|
||||
)
|
||||
return response_data.response
|
||||
|
||||
|
||||
def _reset_bank_config_serialize(
|
||||
self,
|
||||
bank_id,
|
||||
authorization,
|
||||
_request_auth,
|
||||
_content_type,
|
||||
_headers,
|
||||
_host_index,
|
||||
) -> RequestSerialized:
|
||||
|
||||
_host = None
|
||||
|
||||
_collection_formats: Dict[str, str] = {
|
||||
}
|
||||
|
||||
_path_params: Dict[str, str] = {}
|
||||
_query_params: List[Tuple[str, str]] = []
|
||||
_header_params: Dict[str, Optional[str]] = _headers or {}
|
||||
_form_params: List[Tuple[str, str]] = []
|
||||
_files: Dict[
|
||||
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
|
||||
] = {}
|
||||
_body_params: Optional[bytes] = None
|
||||
|
||||
# process the path parameters
|
||||
if bank_id is not None:
|
||||
_path_params['bank_id'] = bank_id
|
||||
# process the query parameters
|
||||
# process the header parameters
|
||||
if authorization is not None:
|
||||
_header_params['authorization'] = authorization
|
||||
# process the form parameters
|
||||
# process the body parameter
|
||||
|
||||
|
||||
# set the HTTP header `Accept`
|
||||
if 'Accept' not in _header_params:
|
||||
_header_params['Accept'] = self.api_client.select_header_accept(
|
||||
[
|
||||
'application/json'
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# authentication setting
|
||||
_auth_settings: List[str] = [
|
||||
]
|
||||
|
||||
return self.api_client.param_serialize(
|
||||
method='DELETE',
|
||||
resource_path='/v1/default/banks/{bank_id}/config',
|
||||
path_params=_path_params,
|
||||
query_params=_query_params,
|
||||
header_params=_header_params,
|
||||
body=_body_params,
|
||||
post_params=_form_params,
|
||||
files=_files,
|
||||
auth_settings=_auth_settings,
|
||||
collection_formats=_collection_formats,
|
||||
_host=_host,
|
||||
_request_auth=_request_auth
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@validate_call
|
||||
async def trigger_consolidation(
|
||||
self,
|
||||
|
|
@ -2620,6 +3178,312 @@ class BanksApi:
|
|||
|
||||
|
||||
|
||||
@validate_call
|
||||
async def update_bank_config(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
bank_config_update: BankConfigUpdate,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Tuple[
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Annotated[StrictFloat, Field(gt=0)]
|
||||
]
|
||||
] = None,
|
||||
_request_auth: Optional[Dict[StrictStr, Any]] = None,
|
||||
_content_type: Optional[StrictStr] = None,
|
||||
_headers: Optional[Dict[StrictStr, Any]] = None,
|
||||
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
||||
) -> BankConfigResponse:
|
||||
"""Update bank configuration
|
||||
|
||||
Update configuration overrides for a bank. Only hierarchical fields can be overridden (LLM settings, retention parameters, etc.). Keys can be provided in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER).
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param bank_config_update: (required)
|
||||
:type bank_config_update: BankConfigUpdate
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
number provided, it will be total request
|
||||
timeout. It can also be a pair (tuple) of
|
||||
(connection, read) timeouts.
|
||||
:type _request_timeout: int, tuple(int, int), optional
|
||||
:param _request_auth: set to override the auth_settings for an a single
|
||||
request; this effectively ignores the
|
||||
authentication in the spec for a single request.
|
||||
:type _request_auth: dict, optional
|
||||
:param _content_type: force content-type for the request.
|
||||
:type _content_type: str, Optional
|
||||
:param _headers: set to override the headers for a single
|
||||
request; this effectively ignores the headers
|
||||
in the spec for a single request.
|
||||
:type _headers: dict, optional
|
||||
:param _host_index: set to override the host_index for a single
|
||||
request; this effectively ignores the host_index
|
||||
in the spec for a single request.
|
||||
:type _host_index: int, optional
|
||||
:return: Returns the result object.
|
||||
""" # noqa: E501
|
||||
|
||||
_param = self._update_bank_config_serialize(
|
||||
bank_id=bank_id,
|
||||
bank_config_update=bank_config_update,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
_host_index=_host_index
|
||||
)
|
||||
|
||||
_response_types_map: Dict[str, Optional[str]] = {
|
||||
'200': "BankConfigResponse",
|
||||
'422': "HTTPValidationError",
|
||||
}
|
||||
response_data = await self.api_client.call_api(
|
||||
*_param,
|
||||
_request_timeout=_request_timeout
|
||||
)
|
||||
await response_data.read()
|
||||
return self.api_client.response_deserialize(
|
||||
response_data=response_data,
|
||||
response_types_map=_response_types_map,
|
||||
).data
|
||||
|
||||
|
||||
@validate_call
|
||||
async def update_bank_config_with_http_info(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
bank_config_update: BankConfigUpdate,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Tuple[
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Annotated[StrictFloat, Field(gt=0)]
|
||||
]
|
||||
] = None,
|
||||
_request_auth: Optional[Dict[StrictStr, Any]] = None,
|
||||
_content_type: Optional[StrictStr] = None,
|
||||
_headers: Optional[Dict[StrictStr, Any]] = None,
|
||||
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
||||
) -> ApiResponse[BankConfigResponse]:
|
||||
"""Update bank configuration
|
||||
|
||||
Update configuration overrides for a bank. Only hierarchical fields can be overridden (LLM settings, retention parameters, etc.). Keys can be provided in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER).
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param bank_config_update: (required)
|
||||
:type bank_config_update: BankConfigUpdate
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
number provided, it will be total request
|
||||
timeout. It can also be a pair (tuple) of
|
||||
(connection, read) timeouts.
|
||||
:type _request_timeout: int, tuple(int, int), optional
|
||||
:param _request_auth: set to override the auth_settings for an a single
|
||||
request; this effectively ignores the
|
||||
authentication in the spec for a single request.
|
||||
:type _request_auth: dict, optional
|
||||
:param _content_type: force content-type for the request.
|
||||
:type _content_type: str, Optional
|
||||
:param _headers: set to override the headers for a single
|
||||
request; this effectively ignores the headers
|
||||
in the spec for a single request.
|
||||
:type _headers: dict, optional
|
||||
:param _host_index: set to override the host_index for a single
|
||||
request; this effectively ignores the host_index
|
||||
in the spec for a single request.
|
||||
:type _host_index: int, optional
|
||||
:return: Returns the result object.
|
||||
""" # noqa: E501
|
||||
|
||||
_param = self._update_bank_config_serialize(
|
||||
bank_id=bank_id,
|
||||
bank_config_update=bank_config_update,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
_host_index=_host_index
|
||||
)
|
||||
|
||||
_response_types_map: Dict[str, Optional[str]] = {
|
||||
'200': "BankConfigResponse",
|
||||
'422': "HTTPValidationError",
|
||||
}
|
||||
response_data = await self.api_client.call_api(
|
||||
*_param,
|
||||
_request_timeout=_request_timeout
|
||||
)
|
||||
await response_data.read()
|
||||
return self.api_client.response_deserialize(
|
||||
response_data=response_data,
|
||||
response_types_map=_response_types_map,
|
||||
)
|
||||
|
||||
|
||||
@validate_call
|
||||
async def update_bank_config_without_preload_content(
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
bank_config_update: BankConfigUpdate,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Tuple[
|
||||
Annotated[StrictFloat, Field(gt=0)],
|
||||
Annotated[StrictFloat, Field(gt=0)]
|
||||
]
|
||||
] = None,
|
||||
_request_auth: Optional[Dict[StrictStr, Any]] = None,
|
||||
_content_type: Optional[StrictStr] = None,
|
||||
_headers: Optional[Dict[StrictStr, Any]] = None,
|
||||
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
|
||||
) -> RESTResponseType:
|
||||
"""Update bank configuration
|
||||
|
||||
Update configuration overrides for a bank. Only hierarchical fields can be overridden (LLM settings, retention parameters, etc.). Keys can be provided in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER).
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param bank_config_update: (required)
|
||||
:type bank_config_update: BankConfigUpdate
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
number provided, it will be total request
|
||||
timeout. It can also be a pair (tuple) of
|
||||
(connection, read) timeouts.
|
||||
:type _request_timeout: int, tuple(int, int), optional
|
||||
:param _request_auth: set to override the auth_settings for an a single
|
||||
request; this effectively ignores the
|
||||
authentication in the spec for a single request.
|
||||
:type _request_auth: dict, optional
|
||||
:param _content_type: force content-type for the request.
|
||||
:type _content_type: str, Optional
|
||||
:param _headers: set to override the headers for a single
|
||||
request; this effectively ignores the headers
|
||||
in the spec for a single request.
|
||||
:type _headers: dict, optional
|
||||
:param _host_index: set to override the host_index for a single
|
||||
request; this effectively ignores the host_index
|
||||
in the spec for a single request.
|
||||
:type _host_index: int, optional
|
||||
:return: Returns the result object.
|
||||
""" # noqa: E501
|
||||
|
||||
_param = self._update_bank_config_serialize(
|
||||
bank_id=bank_id,
|
||||
bank_config_update=bank_config_update,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
_headers=_headers,
|
||||
_host_index=_host_index
|
||||
)
|
||||
|
||||
_response_types_map: Dict[str, Optional[str]] = {
|
||||
'200': "BankConfigResponse",
|
||||
'422': "HTTPValidationError",
|
||||
}
|
||||
response_data = await self.api_client.call_api(
|
||||
*_param,
|
||||
_request_timeout=_request_timeout
|
||||
)
|
||||
return response_data.response
|
||||
|
||||
|
||||
def _update_bank_config_serialize(
|
||||
self,
|
||||
bank_id,
|
||||
bank_config_update,
|
||||
authorization,
|
||||
_request_auth,
|
||||
_content_type,
|
||||
_headers,
|
||||
_host_index,
|
||||
) -> RequestSerialized:
|
||||
|
||||
_host = None
|
||||
|
||||
_collection_formats: Dict[str, str] = {
|
||||
}
|
||||
|
||||
_path_params: Dict[str, str] = {}
|
||||
_query_params: List[Tuple[str, str]] = []
|
||||
_header_params: Dict[str, Optional[str]] = _headers or {}
|
||||
_form_params: List[Tuple[str, str]] = []
|
||||
_files: Dict[
|
||||
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
|
||||
] = {}
|
||||
_body_params: Optional[bytes] = None
|
||||
|
||||
# process the path parameters
|
||||
if bank_id is not None:
|
||||
_path_params['bank_id'] = bank_id
|
||||
# process the query parameters
|
||||
# process the header parameters
|
||||
if authorization is not None:
|
||||
_header_params['authorization'] = authorization
|
||||
# process the form parameters
|
||||
# process the body parameter
|
||||
if bank_config_update is not None:
|
||||
_body_params = bank_config_update
|
||||
|
||||
|
||||
# set the HTTP header `Accept`
|
||||
if 'Accept' not in _header_params:
|
||||
_header_params['Accept'] = self.api_client.select_header_accept(
|
||||
[
|
||||
'application/json'
|
||||
]
|
||||
)
|
||||
|
||||
# set the HTTP header `Content-Type`
|
||||
if _content_type:
|
||||
_header_params['Content-Type'] = _content_type
|
||||
else:
|
||||
_default_content_type = (
|
||||
self.api_client.select_header_content_type(
|
||||
[
|
||||
'application/json'
|
||||
]
|
||||
)
|
||||
)
|
||||
if _default_content_type is not None:
|
||||
_header_params['Content-Type'] = _default_content_type
|
||||
|
||||
# authentication setting
|
||||
_auth_settings: List[str] = [
|
||||
]
|
||||
|
||||
return self.api_client.param_serialize(
|
||||
method='PATCH',
|
||||
resource_path='/v1/default/banks/{bank_id}/config',
|
||||
path_params=_path_params,
|
||||
query_params=_query_params,
|
||||
header_params=_header_params,
|
||||
body=_body_params,
|
||||
post_params=_form_params,
|
||||
files=_files,
|
||||
auth_settings=_auth_settings,
|
||||
collection_formats=_collection_formats,
|
||||
_host=_host,
|
||||
_request_auth=_request_auth
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@validate_call
|
||||
async def update_bank_disposition(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@
|
|||
from hindsight_client_api.models.add_background_request import AddBackgroundRequest
|
||||
from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse
|
||||
from hindsight_client_api.models.background_response import BackgroundResponse
|
||||
from hindsight_client_api.models.bank_config_response import BankConfigResponse
|
||||
from hindsight_client_api.models.bank_config_update import BankConfigUpdate
|
||||
from hindsight_client_api.models.bank_list_item import BankListItem
|
||||
from hindsight_client_api.models.bank_list_response import BankListResponse
|
||||
from hindsight_client_api.models.bank_profile_response import BankProfileResponse
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class BankConfigResponse(BaseModel):
|
||||
"""
|
||||
Response model for bank configuration.
|
||||
""" # noqa: E501
|
||||
bank_id: StrictStr = Field(description="Bank identifier")
|
||||
config: Dict[str, Any] = Field(description="Fully resolved configuration with all hierarchical overrides applied (Python field names)")
|
||||
overrides: Dict[str, Any] = Field(description="Bank-specific configuration overrides only (Python field names)")
|
||||
__properties: ClassVar[List[str]] = ["bank_id", "config", "overrides"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of BankConfigResponse from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of BankConfigResponse from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"bank_id": obj.get("bank_id"),
|
||||
"config": obj.get("config"),
|
||||
"overrides": obj.get("overrides")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Any, ClassVar, Dict, List
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class BankConfigUpdate(BaseModel):
|
||||
"""
|
||||
Request model for updating bank configuration.
|
||||
""" # noqa: E501
|
||||
updates: Dict[str, Any] = Field(description="Configuration overrides. Keys can be in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER). Only hierarchical fields can be overridden per-bank.")
|
||||
__properties: ClassVar[List[str]] = ["updates"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of BankConfigUpdate from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of BankConfigUpdate from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"updates": obj.get("updates")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
|
|
@ -29,7 +29,8 @@ class FeaturesInfo(BaseModel):
|
|||
observations: StrictBool = Field(description="Whether observations (auto-consolidation) are enabled")
|
||||
mcp: StrictBool = Field(description="Whether MCP (Model Context Protocol) server is enabled")
|
||||
worker: StrictBool = Field(description="Whether the background worker is enabled")
|
||||
__properties: ClassVar[List[str]] = ["observations", "mcp", "worker"]
|
||||
bank_config_api: StrictBool = Field(description="Whether per-bank configuration API is enabled")
|
||||
__properties: ClassVar[List[str]] = ["observations", "mcp", "worker", "bank_config_api"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
|
|
@ -84,7 +85,8 @@ class FeaturesInfo(BaseModel):
|
|||
_obj = cls.model_validate({
|
||||
"observations": obj.get("observations"),
|
||||
"mcp": obj.get("mcp"),
|
||||
"worker": obj.get("worker")
|
||||
"worker": obj.get("worker"),
|
||||
"bank_config_api": obj.get("bank_config_api")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ import type {
|
|||
GetAgentStatsData,
|
||||
GetAgentStatsErrors,
|
||||
GetAgentStatsResponses,
|
||||
GetBankConfigData,
|
||||
GetBankConfigErrors,
|
||||
GetBankConfigResponses,
|
||||
GetBankProfileData,
|
||||
GetBankProfileErrors,
|
||||
GetBankProfileResponses,
|
||||
|
|
@ -108,12 +111,18 @@ import type {
|
|||
RegenerateEntityObservationsData,
|
||||
RegenerateEntityObservationsErrors,
|
||||
RegenerateEntityObservationsResponses,
|
||||
ResetBankConfigData,
|
||||
ResetBankConfigErrors,
|
||||
ResetBankConfigResponses,
|
||||
RetainMemoriesData,
|
||||
RetainMemoriesErrors,
|
||||
RetainMemoriesResponses,
|
||||
TriggerConsolidationData,
|
||||
TriggerConsolidationErrors,
|
||||
TriggerConsolidationResponses,
|
||||
UpdateBankConfigData,
|
||||
UpdateBankConfigErrors,
|
||||
UpdateBankConfigResponses,
|
||||
UpdateBankData,
|
||||
UpdateBankDispositionData,
|
||||
UpdateBankDispositionErrors,
|
||||
|
|
@ -808,6 +817,55 @@ export const clearObservations = <ThrowOnError extends boolean = false>(
|
|||
ThrowOnError
|
||||
>({ url: "/v1/default/banks/{bank_id}/observations", ...options });
|
||||
|
||||
/**
|
||||
* Reset bank configuration
|
||||
*
|
||||
* Reset bank configuration to defaults by removing all bank-specific overrides. The bank will then use global and tenant-level configuration only.
|
||||
*/
|
||||
export const resetBankConfig = <ThrowOnError extends boolean = false>(
|
||||
options: Options<ResetBankConfigData, ThrowOnError>,
|
||||
) =>
|
||||
(options.client ?? client).delete<
|
||||
ResetBankConfigResponses,
|
||||
ResetBankConfigErrors,
|
||||
ThrowOnError
|
||||
>({ url: "/v1/default/banks/{bank_id}/config", ...options });
|
||||
|
||||
/**
|
||||
* Get bank configuration
|
||||
*
|
||||
* Get fully resolved configuration for a bank including all hierarchical overrides (global → tenant → bank). The 'config' field contains all resolved config values. The 'overrides' field shows only bank-specific overrides.
|
||||
*/
|
||||
export const getBankConfig = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetBankConfigData, ThrowOnError>,
|
||||
) =>
|
||||
(options.client ?? client).get<
|
||||
GetBankConfigResponses,
|
||||
GetBankConfigErrors,
|
||||
ThrowOnError
|
||||
>({ url: "/v1/default/banks/{bank_id}/config", ...options });
|
||||
|
||||
/**
|
||||
* Update bank configuration
|
||||
*
|
||||
* Update configuration overrides for a bank. Only hierarchical fields can be overridden (LLM settings, retention parameters, etc.). Keys can be provided in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER).
|
||||
*/
|
||||
export const updateBankConfig = <ThrowOnError extends boolean = false>(
|
||||
options: Options<UpdateBankConfigData, ThrowOnError>,
|
||||
) =>
|
||||
(options.client ?? client).patch<
|
||||
UpdateBankConfigResponses,
|
||||
UpdateBankConfigErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/v1/default/banks/{bank_id}/config",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Trigger consolidation
|
||||
*
|
||||
|
|
|
|||
|
|
@ -59,6 +59,52 @@ export type BackgroundResponse = {
|
|||
disposition?: DispositionTraits | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* BankConfigResponse
|
||||
*
|
||||
* Response model for bank configuration.
|
||||
*/
|
||||
export type BankConfigResponse = {
|
||||
/**
|
||||
* Bank Id
|
||||
*
|
||||
* Bank identifier
|
||||
*/
|
||||
bank_id: string;
|
||||
/**
|
||||
* Config
|
||||
*
|
||||
* Fully resolved configuration with all hierarchical overrides applied (Python field names)
|
||||
*/
|
||||
config: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* Overrides
|
||||
*
|
||||
* Bank-specific configuration overrides only (Python field names)
|
||||
*/
|
||||
overrides: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* BankConfigUpdate
|
||||
*
|
||||
* Request model for updating bank configuration.
|
||||
*/
|
||||
export type BankConfigUpdate = {
|
||||
/**
|
||||
* Updates
|
||||
*
|
||||
* Configuration overrides. Keys can be in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER). Only hierarchical fields can be overridden per-bank.
|
||||
*/
|
||||
updates: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* BankListItem
|
||||
*
|
||||
|
|
@ -816,6 +862,12 @@ export type FeaturesInfo = {
|
|||
* Whether the background worker is enabled
|
||||
*/
|
||||
worker: boolean;
|
||||
/**
|
||||
* Bank Config Api
|
||||
*
|
||||
* Whether per-bank configuration API is enabled
|
||||
*/
|
||||
bank_config_api: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -3426,6 +3478,119 @@ export type ClearObservationsResponses = {
|
|||
export type ClearObservationsResponse =
|
||||
ClearObservationsResponses[keyof ClearObservationsResponses];
|
||||
|
||||
export type ResetBankConfigData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
*/
|
||||
bank_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/config";
|
||||
};
|
||||
|
||||
export type ResetBankConfigErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type ResetBankConfigError =
|
||||
ResetBankConfigErrors[keyof ResetBankConfigErrors];
|
||||
|
||||
export type ResetBankConfigResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: BankConfigResponse;
|
||||
};
|
||||
|
||||
export type ResetBankConfigResponse =
|
||||
ResetBankConfigResponses[keyof ResetBankConfigResponses];
|
||||
|
||||
export type GetBankConfigData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
*/
|
||||
bank_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/config";
|
||||
};
|
||||
|
||||
export type GetBankConfigErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetBankConfigError = GetBankConfigErrors[keyof GetBankConfigErrors];
|
||||
|
||||
export type GetBankConfigResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: BankConfigResponse;
|
||||
};
|
||||
|
||||
export type GetBankConfigResponse =
|
||||
GetBankConfigResponses[keyof GetBankConfigResponses];
|
||||
|
||||
export type UpdateBankConfigData = {
|
||||
body: BankConfigUpdate;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
*/
|
||||
bank_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/config";
|
||||
};
|
||||
|
||||
export type UpdateBankConfigErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type UpdateBankConfigError =
|
||||
UpdateBankConfigErrors[keyof UpdateBankConfigErrors];
|
||||
|
||||
export type UpdateBankConfigResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: BankConfigResponse;
|
||||
};
|
||||
|
||||
export type UpdateBankConfigResponse =
|
||||
UpdateBankConfigResponses[keyof UpdateBankConfigResponses];
|
||||
|
||||
export type TriggerConsolidationData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { lowLevelClient, sdk } from "@/lib/hindsight-client";
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ bankId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { bankId } = await params;
|
||||
|
||||
const response = await sdk.getBankConfig({
|
||||
client: lowLevelClient,
|
||||
path: { bank_id: bankId },
|
||||
});
|
||||
|
||||
if (!response.data) {
|
||||
console.error("[Bank Config API] No data in response", { response, error: response.error });
|
||||
throw new Error(`API returned no data: ${JSON.stringify(response.error || "Unknown error")}`);
|
||||
}
|
||||
|
||||
return NextResponse.json(response.data, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error fetching bank config:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch bank config" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ bankId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { bankId } = await params;
|
||||
const body = await request.json();
|
||||
const { updates } = body;
|
||||
|
||||
const response = await sdk.updateBankConfig({
|
||||
client: lowLevelClient,
|
||||
path: { bank_id: bankId },
|
||||
body: { updates },
|
||||
});
|
||||
|
||||
if (!response.data) {
|
||||
console.error("[Bank Config API] No data in response", { response, error: response.error });
|
||||
throw new Error(`API returned no data: ${JSON.stringify(response.error || "Unknown error")}`);
|
||||
}
|
||||
|
||||
return NextResponse.json(response.data, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error updating bank config:", error);
|
||||
return NextResponse.json({ error: "Failed to update bank config" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ bankId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { bankId } = await params;
|
||||
|
||||
const response = await sdk.resetBankConfig({
|
||||
client: lowLevelClient,
|
||||
path: { bank_id: bankId },
|
||||
});
|
||||
|
||||
if (!response.data) {
|
||||
console.error("[Bank Config API] No data in response", { response, error: response.error });
|
||||
throw new Error(`API returned no data: ${JSON.stringify(response.error || "Unknown error")}`);
|
||||
}
|
||||
|
||||
return NextResponse.json(response.data, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error resetting bank config:", error);
|
||||
return NextResponse.json({ error: "Failed to reset bank config" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { BankSelector } from "@/components/bank-selector";
|
||||
import { Sidebar } from "@/components/sidebar";
|
||||
|
|
@ -9,22 +10,56 @@ import { EntitiesView } from "@/components/entities-view";
|
|||
import { ThinkView } from "@/components/think-view";
|
||||
import { SearchDebugView } from "@/components/search-debug-view";
|
||||
import { BankProfileView } from "@/components/bank-profile-view";
|
||||
import { BankConfigView } from "@/components/bank-config-view";
|
||||
import { BankStatsView } from "@/components/bank-stats-view";
|
||||
import { BankOperationsView } from "@/components/bank-operations-view";
|
||||
import { MentalModelsView } from "@/components/mental-models-view";
|
||||
import { useFeatures } from "@/lib/features-context";
|
||||
import { useBank } from "@/lib/bank-context";
|
||||
import { client } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Brain, Trash2, Loader2, MoreVertical, Pencil } from "lucide-react";
|
||||
|
||||
type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "profile";
|
||||
type DataSubTab = "world" | "experience" | "observations" | "mental-models";
|
||||
type BankConfigTab = "general" | "configuration";
|
||||
|
||||
export default function BankPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { features } = useFeatures();
|
||||
const { currentBank: bankId, setCurrentBank, loadBanks } = useBank();
|
||||
|
||||
const bankId = params.bankId as string;
|
||||
const view = (searchParams.get("view") || "profile") as NavItem;
|
||||
const subTab = (searchParams.get("subTab") || "world") as DataSubTab;
|
||||
const bankConfigTab = (searchParams.get("bankConfigTab") || "general") as BankConfigTab;
|
||||
const observationsEnabled = features?.observations ?? false;
|
||||
const bankConfigEnabled = features?.bank_config_api ?? false;
|
||||
|
||||
// Bank actions state
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showClearObservationsDialog, setShowClearObservationsDialog] = useState(false);
|
||||
const [isClearingObservations, setIsClearingObservations] = useState(false);
|
||||
const [isConsolidating, setIsConsolidating] = useState(false);
|
||||
|
||||
const handleTabChange = (tab: NavItem) => {
|
||||
router.push(`/banks/${bankId}?view=${tab}`);
|
||||
|
|
@ -34,6 +69,58 @@ export default function BankPage() {
|
|||
router.push(`/banks/${bankId}?view=data&subTab=${newSubTab}`);
|
||||
};
|
||||
|
||||
const handleBankConfigTabChange = (newTab: BankConfigTab) => {
|
||||
router.push(`/banks/${bankId}?view=profile&bankConfigTab=${newTab}`);
|
||||
};
|
||||
|
||||
const handleDeleteBank = async () => {
|
||||
if (!bankId) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await client.deleteBank(bankId);
|
||||
setShowDeleteDialog(false);
|
||||
setCurrentBank(null);
|
||||
await loadBanks();
|
||||
router.push("/");
|
||||
} catch (error) {
|
||||
console.error("Error deleting bank:", error);
|
||||
alert("Error deleting bank: " + (error as Error).message);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearObservations = async () => {
|
||||
if (!bankId) return;
|
||||
|
||||
setIsClearingObservations(true);
|
||||
try {
|
||||
const result = await client.clearObservations(bankId);
|
||||
setShowClearObservationsDialog(false);
|
||||
alert(result.message || "Observations cleared successfully");
|
||||
} catch (error) {
|
||||
console.error("Error clearing observations:", error);
|
||||
alert("Error clearing observations: " + (error as Error).message);
|
||||
} finally {
|
||||
setIsClearingObservations(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerConsolidation = async () => {
|
||||
if (!bankId) return;
|
||||
|
||||
setIsConsolidating(true);
|
||||
try {
|
||||
await client.triggerConsolidation(bankId);
|
||||
} catch (error) {
|
||||
console.error("Error triggering consolidation:", error);
|
||||
alert("Error triggering consolidation: " + (error as Error).message);
|
||||
} finally {
|
||||
setIsConsolidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<BankSelector />
|
||||
|
|
@ -43,15 +130,125 @@ export default function BankPage() {
|
|||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="p-6">
|
||||
{/* Profile Tab */}
|
||||
{/* Bank Configuration Tab */}
|
||||
{view === "profile" && (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2 text-foreground">Bank Profile</h1>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
View and edit the memory bank profile, disposition traits, and background
|
||||
information.
|
||||
</p>
|
||||
<BankProfileView />
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2 text-foreground">Bank Configuration</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage bank settings, profile, and operations.
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Actions
|
||||
<MoreVertical className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={handleTriggerConsolidation}
|
||||
disabled={isConsolidating || !observationsEnabled}
|
||||
title={
|
||||
!observationsEnabled ? "Observations feature is not enabled" : undefined
|
||||
}
|
||||
>
|
||||
{isConsolidating ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Brain className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isConsolidating ? "Consolidating..." : "Run Consolidation"}
|
||||
{!observationsEnabled && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">Off</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowClearObservationsDialog(true)}
|
||||
disabled={!observationsEnabled}
|
||||
className="text-amber-600 dark:text-amber-400 focus:text-amber-700 dark:focus:text-amber-300"
|
||||
title={
|
||||
!observationsEnabled ? "Observations feature is not enabled" : undefined
|
||||
}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Clear Observations
|
||||
{!observationsEnabled && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">Off</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className="text-red-600 dark:text-red-400 focus:text-red-700 dark:focus:text-red-300"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Bank
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Sub-tabs */}
|
||||
<div className="mb-6 border-b border-border">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => handleBankConfigTabChange("general")}
|
||||
className={`px-6 py-3 font-semibold text-sm transition-all relative ${
|
||||
bankConfigTab === "general"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
General
|
||||
{bankConfigTab === "general" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBankConfigTabChange("configuration")}
|
||||
className={`px-6 py-3 font-semibold text-sm transition-all relative ${
|
||||
bankConfigTab === "configuration"
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Configuration
|
||||
{bankConfigTab === "configuration" && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div>
|
||||
{bankConfigTab === "general" && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Overview statistics and background operations for this memory bank.
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
<BankStatsView />
|
||||
<BankOperationsView />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{bankConfigTab === "configuration" && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Configure disposition traits, mission, directives, and behavioral settings
|
||||
for this bank.
|
||||
</p>
|
||||
<div className="space-y-6">
|
||||
<BankProfileView />
|
||||
{bankConfigEnabled && <BankConfigView />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -242,6 +439,88 @@ export default function BankPage() {
|
|||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Delete Bank Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Memory Bank</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Are you sure you want to delete the memory bank{" "}
|
||||
<span className="font-semibold text-foreground">{bankId}</span>?
|
||||
</p>
|
||||
<p className="text-red-600 dark:text-red-400 font-medium">
|
||||
This action cannot be undone. All memories, entities, documents, and the bank
|
||||
profile will be permanently deleted.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteBank}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Bank
|
||||
</>
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Clear Observations Confirmation Dialog */}
|
||||
<AlertDialog open={showClearObservationsDialog} onOpenChange={setShowClearObservationsDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Clear Observations</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Are you sure you want to clear all observations for{" "}
|
||||
<span className="font-semibold text-foreground">{bankId}</span>?
|
||||
</p>
|
||||
<p className="text-amber-600 dark:text-amber-400 font-medium">
|
||||
This will delete all consolidated knowledge. Observations will be regenerated the
|
||||
next time consolidation runs.
|
||||
</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isClearingObservations}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleClearObservations}
|
||||
disabled={isClearingObservations}
|
||||
className="bg-amber-500 text-white hover:bg-amber-600"
|
||||
>
|
||||
{isClearingObservations ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Clearing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Clear Observations
|
||||
</>
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
480
hindsight-control-plane/src/components/bank-config-view.tsx
Normal file
480
hindsight-control-plane/src/components/bank-config-view.tsx
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useBank } from "@/lib/bank-context";
|
||||
import { client } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Loader2, AlertCircle, CheckCircle2, Pencil, RotateCcw, MoreVertical } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
|
||||
// Field metadata for UI rendering
|
||||
const FIELD_CATEGORIES = {
|
||||
retention: {
|
||||
title: "Retention Settings",
|
||||
description: "Control how memories are extracted and stored",
|
||||
fields: {
|
||||
retain_chunk_size: {
|
||||
label: "Chunk Size",
|
||||
type: "number",
|
||||
description: "Size of text chunks for processing (tokens)",
|
||||
min: 500,
|
||||
max: 8000,
|
||||
},
|
||||
retain_extraction_mode: {
|
||||
label: "Extraction Mode",
|
||||
type: "select",
|
||||
description: "How to extract facts from content",
|
||||
options: ["concise", "verbose", "custom"],
|
||||
},
|
||||
retain_custom_instructions: {
|
||||
label: "Custom Instructions",
|
||||
type: "textarea",
|
||||
description:
|
||||
"Custom instructions for fact extraction (requires retain_extraction_mode='custom')",
|
||||
placeholder: "Focus on technical details and implementation specifics...",
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
consolidation: {
|
||||
title: "Consolidation Settings",
|
||||
description: "Control observation synthesis",
|
||||
fields: {
|
||||
enable_observations: {
|
||||
label: "Enable Observations",
|
||||
type: "boolean",
|
||||
description: "Enable automatic consolidation of facts into observations",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function BankConfigView() {
|
||||
const { currentBank: bankId } = useBank();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [config, setConfig] = useState<Record<string, any>>({});
|
||||
const [overrides, setOverrides] = useState<Record<string, any>>({});
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (bankId) {
|
||||
loadConfig();
|
||||
}
|
||||
}, [bankId]);
|
||||
|
||||
const loadConfig = async () => {
|
||||
if (!bankId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await client.getBankConfig(bankId);
|
||||
setConfig(response.config);
|
||||
setOverrides(response.overrides);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to load config:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setShowResetDialog(true);
|
||||
};
|
||||
|
||||
const confirmReset = async () => {
|
||||
if (!bankId) return;
|
||||
|
||||
setResetting(true);
|
||||
try {
|
||||
await client.resetBankConfig(bankId);
|
||||
await loadConfig();
|
||||
setShowResetDialog(false);
|
||||
} catch (err: any) {
|
||||
console.error("Failed to reset config:", err);
|
||||
alert("Error resetting config: " + err.message);
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderReadOnlyField = (fieldKey: string, fieldMeta: any) => {
|
||||
const value = config[fieldKey];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={fieldKey}
|
||||
className="flex items-start justify-between gap-4 p-3 border border-border rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium font-mono">{fieldKey}</div>
|
||||
{fieldMeta.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{fieldMeta.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-foreground font-mono flex-shrink-0">
|
||||
{fieldMeta.type === "boolean" ? (
|
||||
<span className={value ? "text-green-600" : "text-muted-foreground"}>
|
||||
{value ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
) : fieldMeta.type === "textarea" ? (
|
||||
<span className="text-muted-foreground italic">
|
||||
{value ? `${value.substring(0, 50)}${value.length > 50 ? "..." : ""}` : "Not set"}
|
||||
</span>
|
||||
) : (
|
||||
value || <span className="text-muted-foreground italic">Not set</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!bankId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">No bank selected</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Configuration Settings</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Behavioral parameters for this memory bank
|
||||
</CardDescription>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" disabled={resetting}>
|
||||
{resetting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setShowEditDialog(true)}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleReset}>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Reset to Defaults
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{Object.entries(FIELD_CATEGORIES).map(([catKey, category]) => (
|
||||
<div key={catKey}>
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold">{category.title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{category.description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
|
||||
{Object.entries(category.fields).map(([fieldKey, fieldMeta]) =>
|
||||
renderReadOnlyField(fieldKey, fieldMeta)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{showEditDialog && (
|
||||
<ConfigEditDialog
|
||||
bankId={bankId}
|
||||
initialConfig={config}
|
||||
overrides={overrides}
|
||||
onClose={() => setShowEditDialog(false)}
|
||||
onSaved={() => {
|
||||
loadConfig();
|
||||
setShowEditDialog(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reset Configuration</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to reset all configuration overrides to defaults? This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={resetting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmReset} disabled={resetting}>
|
||||
{resetting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Resetting...
|
||||
</>
|
||||
) : (
|
||||
"Reset to Defaults"
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Edit dialog component
|
||||
function ConfigEditDialog({
|
||||
bankId,
|
||||
initialConfig,
|
||||
overrides,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
bankId: string;
|
||||
initialConfig: Record<string, any>;
|
||||
overrides: Record<string, any>;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [config, setConfig] = useState(initialConfig);
|
||||
|
||||
const handleFieldChange = (field: string, value: any) => {
|
||||
setConfig({ ...config, [field]: value });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updates: Record<string, any> = {};
|
||||
Object.keys(config).forEach((key) => {
|
||||
const isConfigurable = Object.values(FIELD_CATEGORIES).some((cat) =>
|
||||
Object.keys(cat.fields).includes(key)
|
||||
);
|
||||
if (isConfigurable) {
|
||||
updates[key] = config[key];
|
||||
}
|
||||
});
|
||||
|
||||
await client.updateBankConfig(bankId, updates);
|
||||
onSaved();
|
||||
} catch (err: any) {
|
||||
console.error("Failed to save config:", err);
|
||||
setError(err.message || "Failed to save configuration");
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (fieldKey: string, fieldMeta: any) => {
|
||||
const value = config[fieldKey];
|
||||
|
||||
if (fieldMeta.type === "boolean") {
|
||||
return (
|
||||
<div key={fieldKey} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor={fieldKey} className="font-mono">
|
||||
{fieldKey}
|
||||
</Label>
|
||||
{fieldMeta.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{fieldMeta.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleFieldChange(fieldKey, !value)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
value ? "bg-primary" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
value ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldMeta.type === "select") {
|
||||
return (
|
||||
<div key={fieldKey} className="space-y-2">
|
||||
<Label htmlFor={fieldKey} className="font-mono">
|
||||
{fieldKey}
|
||||
</Label>
|
||||
{fieldMeta.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{fieldMeta.description}</p>
|
||||
)}
|
||||
<Select
|
||||
value={value?.toString()}
|
||||
onValueChange={(val) => handleFieldChange(fieldKey, val)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldMeta.options.map((opt: string) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fieldMeta.type === "textarea") {
|
||||
return (
|
||||
<div key={fieldKey} className="space-y-2">
|
||||
<Label htmlFor={fieldKey} className="font-mono">
|
||||
{fieldKey}
|
||||
</Label>
|
||||
{fieldMeta.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{fieldMeta.description}</p>
|
||||
)}
|
||||
<Textarea
|
||||
id={fieldKey}
|
||||
value={value || ""}
|
||||
onChange={(e) => handleFieldChange(fieldKey, e.target.value || null)}
|
||||
placeholder={fieldMeta.placeholder}
|
||||
rows={fieldMeta.rows || 3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// number or text
|
||||
return (
|
||||
<div key={fieldKey} className="space-y-2">
|
||||
<Label htmlFor={fieldKey} className="font-mono">
|
||||
{fieldKey}
|
||||
</Label>
|
||||
{fieldMeta.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{fieldMeta.description}</p>
|
||||
)}
|
||||
<Input
|
||||
id={fieldKey}
|
||||
type={fieldMeta.type || "text"}
|
||||
value={value ?? ""}
|
||||
onChange={(e) =>
|
||||
handleFieldChange(
|
||||
fieldKey,
|
||||
fieldMeta.type === "number" ? parseFloat(e.target.value) : e.target.value
|
||||
)
|
||||
}
|
||||
min={fieldMeta.min}
|
||||
max={fieldMeta.max}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Customize behavioral settings for this bank. Changes only affect this bank and override
|
||||
global defaults.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{Object.entries(FIELD_CATEGORIES).map(([catKey, category]) => (
|
||||
<div key={catKey} className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{category.title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{category.description}</p>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
{Object.entries(category.fields).map(([fieldKey, fieldMeta]) =>
|
||||
renderField(fieldKey, fieldMeta)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose} variant="outline" disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save Changes"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
245
hindsight-control-plane/src/components/bank-operations-view.tsx
Normal file
245
hindsight-control-plane/src/components/bank-operations-view.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useBank } from "@/lib/bank-context";
|
||||
import { client } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { RefreshCw, Clock, AlertCircle, CheckCircle, Loader2, X } from "lucide-react";
|
||||
|
||||
interface Operation {
|
||||
id: string;
|
||||
task_type: string;
|
||||
items_count: number;
|
||||
document_id: string | null;
|
||||
created_at: string;
|
||||
status: string;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
export function BankOperationsView() {
|
||||
const { currentBank } = useBank();
|
||||
const [operations, setOperations] = useState<Operation[]>([]);
|
||||
const [totalOperations, setTotalOperations] = useState(0);
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [limit] = useState(10);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [cancellingOpId, setCancellingOpId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadOperations = async (
|
||||
newStatusFilter: string | null = statusFilter,
|
||||
newOffset: number = offset
|
||||
) => {
|
||||
if (!currentBank) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const opsData = await client.listOperations(currentBank, {
|
||||
status: newStatusFilter || undefined,
|
||||
limit,
|
||||
offset: newOffset,
|
||||
});
|
||||
setOperations(opsData.operations || []);
|
||||
setTotalOperations(opsData.total || 0);
|
||||
} catch (error) {
|
||||
console.error("Error loading operations:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (newFilter: string | null) => {
|
||||
setStatusFilter(newFilter);
|
||||
setOffset(0);
|
||||
loadOperations(newFilter, 0);
|
||||
};
|
||||
|
||||
const handlePageChange = (newOffset: number) => {
|
||||
setOffset(newOffset);
|
||||
loadOperations(statusFilter, newOffset);
|
||||
};
|
||||
|
||||
const handleCancelOperation = async (operationId: string) => {
|
||||
if (!currentBank) return;
|
||||
|
||||
setCancellingOpId(operationId);
|
||||
try {
|
||||
await client.cancelOperation(currentBank, operationId);
|
||||
await loadOperations();
|
||||
} catch (error) {
|
||||
console.error("Error cancelling operation:", error);
|
||||
alert("Error cancelling operation: " + (error as Error).message);
|
||||
} finally {
|
||||
setCancellingOpId(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentBank) {
|
||||
loadOperations();
|
||||
// Refresh operations every 5 seconds
|
||||
const interval = setInterval(() => loadOperations(), 5000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [currentBank]);
|
||||
|
||||
if (!currentBank) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">Background Operations</h3>
|
||||
<button
|
||||
onClick={() => loadOperations()}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
title="Refresh operations"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 text-muted-foreground hover:text-foreground ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{totalOperations} operation{totalOperations !== 1 ? "s" : ""}
|
||||
{statusFilter ? ` (${statusFilter})` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 bg-muted p-1 rounded-lg">
|
||||
{[
|
||||
{ value: null, label: "All" },
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
].map((filter) => (
|
||||
<button
|
||||
key={filter.value ?? "all"}
|
||||
onClick={() => handleFilterChange(filter.value)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
statusFilter === filter.value
|
||||
? "bg-background shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{operations.length > 0 ? (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-[80px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{operations.map((op) => (
|
||||
<TableRow key={op.id} className={op.status === "failed" ? "bg-red-500/5" : ""}>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{op.id.substring(0, 8)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{op.task_type}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(op.created_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{op.status === "pending" && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
|
||||
<Clock className="w-3 h-3" />
|
||||
pending
|
||||
</span>
|
||||
)}
|
||||
{op.status === "failed" && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20"
|
||||
title={op.error_message ?? undefined}
|
||||
>
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
failed
|
||||
</span>
|
||||
)}
|
||||
{op.status === "completed" && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
completed
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{op.status === "pending" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground hover:text-red-600 dark:hover:text-red-400"
|
||||
onClick={() => handleCancelOperation(op.id)}
|
||||
disabled={cancellingOpId === op.id}
|
||||
>
|
||||
{cancellingOpId === op.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
{cancellingOpId === op.id ? "" : "Cancel"}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
{totalOperations > limit && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {offset + 1}-{Math.min(offset + limit, totalOperations)} of{" "}
|
||||
{totalOperations}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(Math.max(0, offset - limit))}
|
||||
disabled={offset === 0}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(offset + limit)}
|
||||
disabled={offset + limit >= totalOperations}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-8 text-sm">
|
||||
No {statusFilter ? `${statusFilter} ` : ""}operations
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -151,66 +151,6 @@ const TRAIT_LABELS: Record<
|
|||
},
|
||||
};
|
||||
|
||||
function DispositionEditor({
|
||||
disposition,
|
||||
editMode,
|
||||
editDisposition,
|
||||
onEditChange,
|
||||
}: {
|
||||
disposition: DispositionTraits;
|
||||
editMode: boolean;
|
||||
editDisposition: DispositionTraits;
|
||||
onEditChange: (trait: keyof DispositionTraits, value: number) => void;
|
||||
}) {
|
||||
const data = editMode ? editDisposition : disposition;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{(Object.keys(TRAIT_LABELS) as Array<keyof DispositionTraits>).map((trait) => (
|
||||
<div key={trait} className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{TRAIT_LABELS[trait].label}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{TRAIT_LABELS[trait].description}</p>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-primary">{data[trait]}/5</span>
|
||||
</div>
|
||||
{editMode ? (
|
||||
<>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>{TRAIT_LABELS[trait].lowLabel}</span>
|
||||
<span>{TRAIT_LABELS[trait].highLabel}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="5"
|
||||
step="1"
|
||||
value={editDisposition[trait]}
|
||||
onChange={(e) => onEditChange(trait, parseInt(e.target.value))}
|
||||
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{TRAIT_LABELS[trait].lowLabel}</span>
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all"
|
||||
style={{ width: `${((data[trait] - 1) / 4) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{TRAIT_LABELS[trait].highLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BankProfileView() {
|
||||
const router = useRouter();
|
||||
const { currentBank, setCurrentBank, loadBanks } = useBank();
|
||||
|
|
@ -223,8 +163,8 @@ export function BankProfileView() {
|
|||
const [directives, setDirectives] = useState<Directive[]>([]);
|
||||
const [mentalModelsCount, setMentalModelsCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [showDispositionDialog, setShowDispositionDialog] = useState(false);
|
||||
const [showMissionDialog, setShowMissionDialog] = useState(false);
|
||||
|
||||
// Directive state
|
||||
const [showCreateDirective, setShowCreateDirective] = useState(false);
|
||||
|
|
@ -235,12 +175,6 @@ export function BankProfileView() {
|
|||
} | null>(null);
|
||||
const [deletingDirective, setDeletingDirective] = useState(false);
|
||||
|
||||
// Ref to track editMode for polling (avoids stale closure)
|
||||
const editModeRef = useRef(editMode);
|
||||
useEffect(() => {
|
||||
editModeRef.current = editMode;
|
||||
}, [editMode]);
|
||||
|
||||
// Delete state
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
|
@ -258,14 +192,6 @@ export function BankProfileView() {
|
|||
const [opsOffset, setOpsOffset] = useState(0);
|
||||
const [cancellingOpId, setCancellingOpId] = useState<string | null>(null);
|
||||
|
||||
// Edit state
|
||||
const [editMission, setEditMission] = useState("");
|
||||
const [editDisposition, setEditDisposition] = useState<DispositionTraits>({
|
||||
skepticism: 3,
|
||||
literalism: 3,
|
||||
empathy: 3,
|
||||
});
|
||||
|
||||
const loadOperations = async (
|
||||
statusFilter: string | null = opsStatusFilter,
|
||||
offset: number = opsOffset
|
||||
|
|
@ -319,12 +245,6 @@ export function BankProfileView() {
|
|||
setDirectives(directivesData.items || []);
|
||||
setMentalModelsCount(mentalModelsData.items?.length || 0);
|
||||
await loadOperations();
|
||||
|
||||
// Only initialize edit state when not in edit mode
|
||||
if (!editModeRef.current) {
|
||||
setEditMission(profileData.mission || "");
|
||||
setEditDisposition(profileData.disposition);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading bank profile:", error);
|
||||
alert("Error loading bank profile: " + (error as Error).message);
|
||||
|
|
@ -333,33 +253,6 @@ export function BankProfileView() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!currentBank) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await client.updateBankProfile(currentBank, {
|
||||
mission: editMission,
|
||||
disposition: editDisposition,
|
||||
});
|
||||
await loadData();
|
||||
setEditMode(false);
|
||||
} catch (error) {
|
||||
console.error("Error saving bank profile:", error);
|
||||
alert("Error saving bank profile: " + (error as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (profile) {
|
||||
setEditMission(profile.mission || "");
|
||||
setEditDisposition(profile.disposition);
|
||||
}
|
||||
setEditMode(false);
|
||||
};
|
||||
|
||||
const handleDeleteBank = async () => {
|
||||
if (!currentBank) return;
|
||||
|
||||
|
|
@ -501,238 +394,58 @@ export function BankProfileView() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with actions */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-foreground">{profile?.name || currentBank}</h2>
|
||||
<p className="text-sm text-muted-foreground font-mono">{currentBank}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{editMode ? (
|
||||
<>
|
||||
<Button onClick={handleCancel} variant="secondary" disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
Actions
|
||||
<MoreVertical className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => setEditMode(true)}>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
Edit Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleTriggerConsolidation}
|
||||
disabled={isConsolidating || !observationsEnabled}
|
||||
title={!observationsEnabled ? "Observations feature is not enabled" : undefined}
|
||||
>
|
||||
{isConsolidating ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Brain className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isConsolidating ? "Consolidating..." : "Run Consolidation"}
|
||||
{!observationsEnabled && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">Off</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowClearObservationsDialog(true)}
|
||||
disabled={!observationsEnabled}
|
||||
className="text-amber-600 dark:text-amber-400 focus:text-amber-700 dark:focus:text-amber-300"
|
||||
title={!observationsEnabled ? "Observations feature is not enabled" : undefined}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Clear Observations
|
||||
{!observationsEnabled && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">Off</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className="text-red-600 dark:text-red-400 focus:text-red-700 dark:focus:text-red-300"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Bank
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview - Compact cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-gradient-to-br from-blue-500/10 to-blue-600/5 border-blue-500/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-500/20">
|
||||
<Database className="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-medium">Memories</p>
|
||||
<p className="text-2xl font-bold text-foreground">{stats.total_nodes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-purple-500/10 to-purple-600/5 border-purple-500/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-purple-500/20">
|
||||
<Link2 className="w-5 h-5 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-medium">Links</p>
|
||||
<p className="text-2xl font-bold text-foreground">{stats.total_links}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-emerald-500/10 to-emerald-600/5 border-emerald-500/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/20">
|
||||
<FolderOpen className="w-5 h-5 text-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-medium">Documents</p>
|
||||
<p className="text-2xl font-bold text-foreground">{stats.total_documents}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={`bg-gradient-to-br ${stats.pending_operations > 0 ? "from-amber-500/10 to-amber-600/5 border-amber-500/20" : "from-slate-500/10 to-slate-600/5 border-slate-500/20"}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`p-2 rounded-lg ${stats.pending_operations > 0 ? "bg-amber-500/20" : "bg-slate-500/20"}`}
|
||||
>
|
||||
<Activity
|
||||
className={`w-5 h-5 ${stats.pending_operations > 0 ? "text-amber-500 animate-pulse" : "text-slate-500"}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-medium">Pending</p>
|
||||
<p className="text-2xl font-bold text-foreground">{stats.pending_operations}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Memory Type Breakdown */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 font-semibold uppercase tracking-wide">
|
||||
World Facts
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400 mt-1">
|
||||
{stats.nodes_by_fact_type?.world || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-purple-500/10 border border-purple-500/20 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-purple-600 dark:text-purple-400 font-semibold uppercase tracking-wide">
|
||||
Experience
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400 mt-1">
|
||||
{stats.nodes_by_fact_type?.experience || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-xl p-4 text-center ${
|
||||
observationsEnabled
|
||||
? "bg-amber-500/10 border border-amber-500/20"
|
||||
: "bg-muted/50 border border-muted"
|
||||
}`}
|
||||
title={!observationsEnabled ? "Observations feature is not enabled" : undefined}
|
||||
>
|
||||
<p
|
||||
className={`text-xs font-semibold uppercase tracking-wide ${
|
||||
observationsEnabled ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Observations
|
||||
{!observationsEnabled && <span className="ml-1 normal-case">(Off)</span>}
|
||||
</p>
|
||||
<p
|
||||
className={`text-2xl font-bold mt-1 ${
|
||||
observationsEnabled ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{observationsEnabled ? stats.total_mental_models || 0 : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-cyan-500/10 border border-cyan-500/20 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-cyan-600 dark:text-cyan-400 font-semibold uppercase tracking-wide">
|
||||
Mental Models
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-cyan-600 dark:text-cyan-400 mt-1">
|
||||
{mentalModelsCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-rose-500/10 border border-rose-500/20 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-rose-600 dark:text-rose-400 font-semibold uppercase tracking-wide">
|
||||
Directives
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-rose-600 dark:text-rose-400 mt-1">
|
||||
{directives.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Disposition Chart */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Brain className="w-5 h-5 text-primary" />
|
||||
Disposition Profile
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Traits that shape how observations are formed via Reflect
|
||||
</CardDescription>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Brain className="w-5 h-5 text-primary" />
|
||||
Disposition Profile
|
||||
</CardTitle>
|
||||
<CardDescription>Traits that shape the reasoning and perspective</CardDescription>
|
||||
</div>
|
||||
<Button onClick={() => setShowDispositionDialog(true)} variant="ghost" size="sm">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{profile && (
|
||||
<DispositionEditor
|
||||
disposition={profile.disposition}
|
||||
editMode={editMode}
|
||||
editDisposition={editDisposition}
|
||||
onEditChange={(trait, value) =>
|
||||
setEditDisposition((prev) => ({ ...prev, [trait]: value }))
|
||||
}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
{(Object.keys(TRAIT_LABELS) as Array<keyof DispositionTraits>).map((trait) => (
|
||||
<div key={trait} className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{TRAIT_LABELS[trait].label}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{TRAIT_LABELS[trait].description}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-primary">
|
||||
{profile.disposition[trait]}/5
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{TRAIT_LABELS[trait].lowLabel}
|
||||
</span>
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all"
|
||||
style={{ width: `${((profile.disposition[trait] - 1) / 4) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{TRAIT_LABELS[trait].highLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -740,30 +453,26 @@ export function BankProfileView() {
|
|||
{/* Mission */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Target className="w-5 h-5 text-primary" />
|
||||
Mission
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Who the agent is and what they're trying to accomplish. Used for mental models
|
||||
and reflect.
|
||||
</CardDescription>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Target className="w-5 h-5 text-primary" />
|
||||
Mission
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Affects how observations, reflect, and mental models are created
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button onClick={() => setShowMissionDialog(true)} variant="ghost" size="sm">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{editMode ? (
|
||||
<Textarea
|
||||
value={editMission}
|
||||
onChange={(e) => setEditMission(e.target.value)}
|
||||
placeholder="e.g., I am a PM for the engineering team. I help coordinate sprints and track project progress..."
|
||||
rows={6}
|
||||
className="resize-none"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
|
||||
{profile?.mission ||
|
||||
"No mission set. Set a mission to derive structural mental models and personalize reflect responses."}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
|
||||
{profile?.mission ||
|
||||
"No mission set. Set a mission to derive structural mental models and personalize reflect responses."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -841,158 +550,6 @@ export function BankProfileView() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Operations Section */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Activity className="w-5 h-5 text-primary" />
|
||||
Background Operations
|
||||
<button
|
||||
onClick={() => loadOperations()}
|
||||
className="p-1 rounded hover:bg-muted transition-colors"
|
||||
title="Refresh operations"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{totalOperations} operation{totalOperations !== 1 ? "s" : ""}
|
||||
{opsStatusFilter ? ` (${opsStatusFilter})` : ""}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-1 bg-muted p-1 rounded-lg">
|
||||
{[
|
||||
{ value: null, label: "All" },
|
||||
{ value: "pending", label: "Pending" },
|
||||
{ value: "completed", label: "Completed" },
|
||||
{ value: "failed", label: "Failed" },
|
||||
].map((filter) => (
|
||||
<button
|
||||
key={filter.value ?? "all"}
|
||||
onClick={() => handleOpsFilterChange(filter.value)}
|
||||
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
opsStatusFilter === filter.value
|
||||
? "bg-background shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{operations.length > 0 ? (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-[80px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{operations.map((op) => (
|
||||
<TableRow
|
||||
key={op.id}
|
||||
className={op.status === "failed" ? "bg-red-500/5" : ""}
|
||||
>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{op.id.substring(0, 8)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{op.task_type}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(op.created_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{op.status === "pending" && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
|
||||
<Clock className="w-3 h-3" />
|
||||
pending
|
||||
</span>
|
||||
)}
|
||||
{op.status === "failed" && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20"
|
||||
title={op.error_message ?? undefined}
|
||||
>
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
failed
|
||||
</span>
|
||||
)}
|
||||
{op.status === "completed" && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
completed
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{op.status === "pending" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground hover:text-red-600 dark:hover:text-red-400"
|
||||
onClick={() => handleCancelOperation(op.id)}
|
||||
disabled={cancellingOpId === op.id}
|
||||
>
|
||||
{cancellingOpId === op.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
{cancellingOpId === op.id ? "" : "Cancel"}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
{totalOperations > opsLimit && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {opsOffset + 1}-{Math.min(opsOffset + opsLimit, totalOperations)} of{" "}
|
||||
{totalOperations}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOpsPageChange(Math.max(0, opsOffset - opsLimit))}
|
||||
disabled={opsOffset === 0}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOpsPageChange(opsOffset + opsLimit)}
|
||||
disabled={opsOffset + opsLimit >= totalOperations}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-8 text-sm">
|
||||
No {opsStatusFilter ? `${opsStatusFilter} ` : ""}operations
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
|
|
@ -1142,10 +699,197 @@ export function BankProfileView() {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Disposition Edit Dialog */}
|
||||
{showDispositionDialog && profile && (
|
||||
<DispositionEditDialog
|
||||
disposition={profile.disposition}
|
||||
onClose={() => setShowDispositionDialog(false)}
|
||||
onSaved={async () => {
|
||||
await loadData();
|
||||
setShowDispositionDialog(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mission Edit Dialog */}
|
||||
{showMissionDialog && profile && (
|
||||
<MissionEditDialog
|
||||
mission={profile.mission || ""}
|
||||
onClose={() => setShowMissionDialog(false)}
|
||||
onSaved={async () => {
|
||||
await loadData();
|
||||
setShowMissionDialog(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============= DISPOSITION EDIT DIALOG =============
|
||||
|
||||
function DispositionEditDialog({
|
||||
disposition,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
disposition: DispositionTraits;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { currentBank } = useBank();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editDisposition, setEditDisposition] = useState<DispositionTraits>(disposition);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!currentBank) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await client.updateBankProfile(currentBank, {
|
||||
disposition: editDisposition,
|
||||
});
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
console.error("Error saving disposition:", error);
|
||||
alert("Error saving disposition: " + (error as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Disposition Traits</DialogTitle>
|
||||
<DialogDescription>Traits that shape the reasoning and perspective</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{(Object.keys(TRAIT_LABELS) as Array<keyof DispositionTraits>).map((trait) => (
|
||||
<div key={trait} className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{TRAIT_LABELS[trait].label}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{TRAIT_LABELS[trait].description}</p>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-primary">{editDisposition[trait]}/5</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>{TRAIT_LABELS[trait].lowLabel}</span>
|
||||
<span>{TRAIT_LABELS[trait].highLabel}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="5"
|
||||
step="1"
|
||||
value={editDisposition[trait]}
|
||||
onChange={(e) =>
|
||||
setEditDisposition((prev) => ({ ...prev, [trait]: parseInt(e.target.value) }))
|
||||
}
|
||||
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose} variant="outline" disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save Changes"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ============= MISSION EDIT DIALOG =============
|
||||
|
||||
function MissionEditDialog({
|
||||
mission,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
mission: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { currentBank } = useBank();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editMission, setEditMission] = useState(mission);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!currentBank) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await client.updateBankProfile(currentBank, {
|
||||
mission: editMission,
|
||||
});
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
console.error("Error saving mission:", error);
|
||||
alert("Error saving mission: " + (error as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Mission</DialogTitle>
|
||||
<DialogDescription>
|
||||
Affects how observations, reflect, and mental models are created
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 py-4">
|
||||
<Textarea
|
||||
value={editMission}
|
||||
onChange={(e) => setEditMission(e.target.value)}
|
||||
placeholder="e.g., I am a PM for the engineering team. I help coordinate sprints and track project progress..."
|
||||
rows={8}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={onClose} variant="outline" disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
"Save Changes"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ============= DIRECTIVE FORM DIALOG (CREATE/EDIT) =============
|
||||
|
||||
function DirectiveFormDialog({
|
||||
|
|
|
|||
208
hindsight-control-plane/src/components/bank-stats-view.tsx
Normal file
208
hindsight-control-plane/src/components/bank-stats-view.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useBank } from "@/lib/bank-context";
|
||||
import { useFeatures } from "@/lib/features-context";
|
||||
import { client } from "@/lib/api";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Database, Link2, FolderOpen, Activity, Clock } from "lucide-react";
|
||||
|
||||
interface BankStats {
|
||||
bank_id: string;
|
||||
total_nodes: number;
|
||||
total_links: number;
|
||||
total_documents: number;
|
||||
nodes_by_fact_type: {
|
||||
world?: number;
|
||||
experience?: number;
|
||||
opinion?: number;
|
||||
};
|
||||
links_by_link_type: {
|
||||
temporal?: number;
|
||||
semantic?: number;
|
||||
entity?: number;
|
||||
};
|
||||
pending_operations: number;
|
||||
failed_operations: number;
|
||||
last_consolidated_at: string | null;
|
||||
pending_consolidation: number;
|
||||
total_mental_models: number;
|
||||
}
|
||||
|
||||
export function BankStatsView() {
|
||||
const { currentBank } = useBank();
|
||||
const { features } = useFeatures();
|
||||
const observationsEnabled = features?.observations ?? false;
|
||||
const [stats, setStats] = useState<BankStats | null>(null);
|
||||
const [mentalModelsCount, setMentalModelsCount] = useState(0);
|
||||
const [directivesCount, setDirectivesCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadData = async () => {
|
||||
if (!currentBank) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statsData, mentalModelsData, directivesData] = await Promise.all([
|
||||
client.getBankStats(currentBank),
|
||||
client.listMentalModels(currentBank),
|
||||
client.listDirectives(currentBank),
|
||||
]);
|
||||
setStats(statsData as BankStats);
|
||||
setMentalModelsCount(mentalModelsData.items?.length || 0);
|
||||
setDirectivesCount(directivesData.items?.length || 0);
|
||||
} catch (error) {
|
||||
console.error("Error loading bank stats:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentBank) {
|
||||
loadData();
|
||||
// Refresh stats every 5 seconds
|
||||
const interval = setInterval(loadData, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [currentBank]);
|
||||
|
||||
if (loading && !stats) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Clock className="w-12 h-12 mx-auto mb-3 text-muted-foreground animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Overview - Compact cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-gradient-to-br from-blue-500/10 to-blue-600/5 border-blue-500/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-500/20">
|
||||
<Database className="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-medium">Memories</p>
|
||||
<p className="text-2xl font-bold text-foreground">{stats.total_nodes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-purple-500/10 to-purple-600/5 border-purple-500/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-purple-500/20">
|
||||
<Link2 className="w-5 h-5 text-purple-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-medium">Links</p>
|
||||
<p className="text-2xl font-bold text-foreground">{stats.total_links}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gradient-to-br from-emerald-500/10 to-emerald-600/5 border-emerald-500/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/20">
|
||||
<FolderOpen className="w-5 h-5 text-emerald-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-medium">Documents</p>
|
||||
<p className="text-2xl font-bold text-foreground">{stats.total_documents}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={`bg-gradient-to-br ${stats.pending_operations > 0 ? "from-amber-500/10 to-amber-600/5 border-amber-500/20" : "from-slate-500/10 to-slate-600/5 border-slate-500/20"}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`p-2 rounded-lg ${stats.pending_operations > 0 ? "bg-amber-500/20" : "bg-slate-500/20"}`}
|
||||
>
|
||||
<Activity
|
||||
className={`w-5 h-5 ${stats.pending_operations > 0 ? "text-amber-500 animate-pulse" : "text-slate-500"}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground font-medium">Pending</p>
|
||||
<p className="text-2xl font-bold text-foreground">{stats.pending_operations}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Memory Type Breakdown */}
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 font-semibold uppercase tracking-wide">
|
||||
World Facts
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-blue-600 dark:text-blue-400 mt-1">
|
||||
{stats.nodes_by_fact_type?.world || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-purple-500/10 border border-purple-500/20 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-purple-600 dark:text-purple-400 font-semibold uppercase tracking-wide">
|
||||
Experience
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-purple-600 dark:text-purple-400 mt-1">
|
||||
{stats.nodes_by_fact_type?.experience || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-xl p-4 text-center ${
|
||||
observationsEnabled
|
||||
? "bg-amber-500/10 border border-amber-500/20"
|
||||
: "bg-muted/50 border border-muted"
|
||||
}`}
|
||||
title={!observationsEnabled ? "Observations feature is not enabled" : undefined}
|
||||
>
|
||||
<p
|
||||
className={`text-xs font-semibold uppercase tracking-wide ${
|
||||
observationsEnabled ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
Observations
|
||||
{!observationsEnabled && <span className="ml-1 normal-case">(Off)</span>}
|
||||
</p>
|
||||
<p
|
||||
className={`text-2xl font-bold mt-1 ${
|
||||
observationsEnabled ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{observationsEnabled ? stats.total_mental_models || 0 : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-cyan-500/10 border border-cyan-500/20 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-cyan-600 dark:text-cyan-400 font-semibold uppercase tracking-wide">
|
||||
Mental Models
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-cyan-600 dark:text-cyan-400 mt-1">
|
||||
{mentalModelsCount}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-rose-500/10 border border-rose-500/20 rounded-xl p-4 text-center">
|
||||
<p className="text-xs text-rose-600 dark:text-rose-400 font-semibold uppercase tracking-wide">
|
||||
Directives
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-rose-600 dark:text-rose-400 mt-1">
|
||||
{directivesCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { useBank } from "@/lib/bank-context";
|
||||
import { useFeatures } from "@/lib/features-context";
|
||||
import {
|
||||
Search,
|
||||
Sparkles,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Box,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Link from "next/link";
|
||||
|
|
@ -24,6 +26,7 @@ interface SidebarProps {
|
|||
|
||||
export function Sidebar({ currentTab, onTabChange }: SidebarProps) {
|
||||
const { currentBank } = useBank();
|
||||
const { features } = useFeatures();
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
|
||||
if (!currentBank) {
|
||||
|
|
@ -36,7 +39,7 @@ export function Sidebar({ currentTab, onTabChange }: SidebarProps) {
|
|||
{ id: "reflect" as NavItem, label: "Reflect", icon: Sparkles },
|
||||
{ id: "documents" as NavItem, label: "Documents", icon: FileText },
|
||||
{ id: "entities" as NavItem, label: "Entities", icon: Users },
|
||||
{ id: "profile" as NavItem, label: "Memory Bank", icon: Box },
|
||||
{ id: "profile" as NavItem, label: "Bank Configuration", icon: Settings },
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
|
|||
49
hindsight-control-plane/src/components/ui/alert.tsx
Normal file
49
hindsight-control-plane/src/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
|
|
@ -684,9 +684,48 @@ export class ControlPlaneClient {
|
|||
observations: boolean;
|
||||
mcp: boolean;
|
||||
worker: boolean;
|
||||
bank_config_api: boolean;
|
||||
};
|
||||
}>("/api/version");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bank configuration (resolved with hierarchy)
|
||||
*/
|
||||
async getBankConfig(bankId: string) {
|
||||
return this.fetchApi<{
|
||||
bank_id: string;
|
||||
config: Record<string, any>;
|
||||
overrides: Record<string, any>;
|
||||
}>(`/api/banks/${bankId}/config`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update bank configuration overrides
|
||||
*/
|
||||
async updateBankConfig(bankId: string, updates: Record<string, any>) {
|
||||
return this.fetchApi<{
|
||||
bank_id: string;
|
||||
config: Record<string, any>;
|
||||
overrides: Record<string, any>;
|
||||
}>(`/api/banks/${bankId}/config`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ updates }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset bank configuration to defaults
|
||||
*/
|
||||
async resetBankConfig(bankId: string) {
|
||||
return this.fetchApi<{
|
||||
bank_id: string;
|
||||
config: Record<string, any>;
|
||||
overrides: Record<string, any>;
|
||||
}>(`/api/banks/${bankId}/config`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ interface Features {
|
|||
observations: boolean;
|
||||
mcp: boolean;
|
||||
worker: boolean;
|
||||
bank_config_api: boolean;
|
||||
}
|
||||
|
||||
interface FeaturesContextType {
|
||||
|
|
@ -19,6 +20,7 @@ const defaultFeatures: Features = {
|
|||
observations: false,
|
||||
mcp: false,
|
||||
worker: false,
|
||||
bank_config_api: false,
|
||||
};
|
||||
|
||||
const FeaturesContext = createContext<FeaturesContextType | undefined>(undefined);
|
||||
|
|
|
|||
|
|
@ -657,6 +657,127 @@ The Control Plane is the web UI for managing memory banks.
|
|||
export HINDSIGHT_CP_DATAPLANE_API_URL=http://api.example.com:8888
|
||||
```
|
||||
|
||||
### Hierarchical Configuration
|
||||
|
||||
Hindsight supports per-bank configuration overrides through a hierarchical system: **Global (env vars) → Tenant → Bank**.
|
||||
|
||||
#### Type-Safe Config Access
|
||||
|
||||
To prevent accidentally using global defaults when bank-specific overrides exist, Hindsight enforces type-safe config access:
|
||||
|
||||
**In Application Code:**
|
||||
```python
|
||||
from hindsight_api.config import get_config
|
||||
|
||||
# ✅ Access static (infrastructure) fields
|
||||
config = get_config()
|
||||
host = config.host # OK - static field
|
||||
port = config.port # OK - static field
|
||||
|
||||
# ❌ Attempting to access bank-configurable fields raises an error
|
||||
chunk_size = config.retain_chunk_size # ConfigFieldAccessError!
|
||||
```
|
||||
|
||||
**Error Message:**
|
||||
```
|
||||
ConfigFieldAccessError: Field 'retain_chunk_size' is bank-configurable and cannot
|
||||
be accessed from global config. Use ConfigResolver.resolve_full_config(bank_id, context)
|
||||
to get bank-specific config.
|
||||
```
|
||||
|
||||
**For Bank-Specific Config:**
|
||||
```python
|
||||
# Internal code that needs bank-specific settings
|
||||
from hindsight_api.config_resolver import ConfigResolver
|
||||
|
||||
# Resolve full config for a specific bank
|
||||
config = await config_resolver.resolve_full_config(bank_id, request_context)
|
||||
chunk_size = config.retain_chunk_size # ✅ Uses bank-specific value
|
||||
```
|
||||
|
||||
This design prevents bugs where global defaults are used instead of bank overrides, making it impossible to make this mistake at compile/development time.
|
||||
|
||||
#### Security Model
|
||||
|
||||
Configuration fields are categorized for security:
|
||||
|
||||
1. **Configurable Fields** - Safe behavioral settings that can be customized per-bank:
|
||||
- Retention: `retain_chunk_size`, `retain_extraction_mode`, `retain_custom_instructions`
|
||||
- Consolidation: `enable_observations`
|
||||
|
||||
2. **Credential Fields** - NEVER exposed or configurable via API:
|
||||
- API keys: `*_api_key` (all LLM API keys)
|
||||
- Infrastructure: `*_base_url` (all base URLs)
|
||||
|
||||
3. **Static Fields** - Server-level only, cannot be overridden:
|
||||
- Infrastructure: `database_url`, `port`, `host`, `worker_count`
|
||||
- Provider/Model selection: `llm_provider`, `llm_model` (requires presets - not yet implemented)
|
||||
- Performance tuning: `llm_max_concurrent`, `llm_timeout`, retrieval settings, optimization flags
|
||||
|
||||
#### Enabling the API
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_ENABLE_BANK_CONFIG_API` | Enable per-bank config API | `false` |
|
||||
|
||||
**Important:** The bank config API is **disabled by default** for security. Enable it explicitly:
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true
|
||||
```
|
||||
|
||||
#### API Endpoints
|
||||
|
||||
- `GET /v1/default/banks/{bank_id}/config` - View resolved config (filtered by permissions)
|
||||
- `PATCH /v1/default/banks/{bank_id}/config` - Update bank overrides (only allowed fields)
|
||||
- `DELETE /v1/default/banks/{bank_id}/config` - Reset to defaults
|
||||
|
||||
#### Permission System
|
||||
|
||||
Tenant extensions can control which fields banks are allowed to modify via `get_allowed_config_fields()`:
|
||||
|
||||
```python
|
||||
class CustomTenantExtension(TenantExtension):
|
||||
async def get_allowed_config_fields(self, context, bank_id):
|
||||
# Option 1: Allow all configurable fields
|
||||
return None
|
||||
|
||||
# Option 2: Allow specific fields only
|
||||
return {"retain_chunk_size", "retain_custom_instructions"}
|
||||
|
||||
# Option 3: Read-only (no modifications)
|
||||
return set()
|
||||
```
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
# Update retention settings for a bank
|
||||
curl -X PATCH http://localhost:8888/v1/default/banks/my-bank/config \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"updates": {
|
||||
"retain_chunk_size": 4000,
|
||||
"retain_extraction_mode": "custom",
|
||||
"retain_custom_instructions": "Focus on technical details and implementation specifics"
|
||||
}
|
||||
}'
|
||||
|
||||
# Note: retain_extraction_mode must be "custom" to use retain_custom_instructions
|
||||
|
||||
# View resolved config (respects permissions)
|
||||
curl http://localhost:8888/v1/default/banks/my-bank/config
|
||||
|
||||
# Reset to defaults
|
||||
curl -X DELETE http://localhost:8888/v1/default/banks/my-bank/config
|
||||
```
|
||||
|
||||
**Security Notes:**
|
||||
- Credentials (API keys, base URLs) are never returned in responses
|
||||
- Only configurable fields can be modified
|
||||
- Responses are filtered by tenant permissions
|
||||
- Attempting to set credentials returns 400 error
|
||||
|
||||
### Reverse Proxy / Subpath Deployment
|
||||
|
||||
To deploy Hindsight under a subpath (e.g., `example.com/hindsight/`):
|
||||
|
|
@ -721,7 +842,6 @@ See `docker/compose-examples/` directory for:
|
|||
- Docker Compose setups (`docker-compose.yml`, `reverse-proxy-only.yml`)
|
||||
- Traefik and other reverse proxy examples
|
||||
- Full deployment documentation
|
||||
|
||||
---
|
||||
|
||||
## Example .env File
|
||||
|
|
|
|||
|
|
@ -2749,6 +2749,189 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/v1/default/banks/{bank_id}/config": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Banks"
|
||||
],
|
||||
"summary": "Get bank configuration",
|
||||
"description": "Get fully resolved configuration for a bank including all hierarchical overrides (global \u2192 tenant \u2192 bank). The 'config' field contains all resolved config values. The 'overrides' field shows only bank-specific overrides.",
|
||||
"operationId": "get_bank_config",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "bank_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Bank Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "authorization",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Authorization"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BankConfigResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"Banks"
|
||||
],
|
||||
"summary": "Update bank configuration",
|
||||
"description": "Update configuration overrides for a bank. Only hierarchical fields can be overridden (LLM settings, retention parameters, etc.). Keys can be provided in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER).",
|
||||
"operationId": "update_bank_config",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "bank_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Bank Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "authorization",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Authorization"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BankConfigUpdate"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BankConfigResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Banks"
|
||||
],
|
||||
"summary": "Reset bank configuration",
|
||||
"description": "Reset bank configuration to defaults by removing all bank-specific overrides. The bank will then use global and tenant-level configuration only.",
|
||||
"operationId": "reset_bank_config",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "bank_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"title": "Bank Id"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "authorization",
|
||||
"in": "header",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Authorization"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BankConfigResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/default/banks/{bank_id}/consolidate": {
|
||||
"post": {
|
||||
"tags": [
|
||||
|
|
@ -3042,6 +3225,70 @@
|
|||
"mission": "I was born in Texas. I am a software engineer with 10 years of experience."
|
||||
}
|
||||
},
|
||||
"BankConfigResponse": {
|
||||
"properties": {
|
||||
"bank_id": {
|
||||
"type": "string",
|
||||
"title": "Bank Id",
|
||||
"description": "Bank identifier"
|
||||
},
|
||||
"config": {
|
||||
"additionalProperties": true,
|
||||
"type": "object",
|
||||
"title": "Config",
|
||||
"description": "Fully resolved configuration with all hierarchical overrides applied (Python field names)"
|
||||
},
|
||||
"overrides": {
|
||||
"additionalProperties": true,
|
||||
"type": "object",
|
||||
"title": "Overrides",
|
||||
"description": "Bank-specific configuration overrides only (Python field names)"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"bank_id",
|
||||
"config",
|
||||
"overrides"
|
||||
],
|
||||
"title": "BankConfigResponse",
|
||||
"description": "Response model for bank configuration.",
|
||||
"example": {
|
||||
"bank_id": "my-bank",
|
||||
"config": {
|
||||
"llm_model": "gpt-4",
|
||||
"llm_provider": "openai",
|
||||
"retain_extraction_mode": "verbose"
|
||||
},
|
||||
"overrides": {
|
||||
"llm_model": "gpt-4",
|
||||
"retain_extraction_mode": "verbose"
|
||||
}
|
||||
}
|
||||
},
|
||||
"BankConfigUpdate": {
|
||||
"properties": {
|
||||
"updates": {
|
||||
"additionalProperties": true,
|
||||
"type": "object",
|
||||
"title": "Updates",
|
||||
"description": "Configuration overrides. Keys can be in Python field format (llm_provider) or environment variable format (HINDSIGHT_API_LLM_PROVIDER). Only hierarchical fields can be overridden per-bank."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"updates"
|
||||
],
|
||||
"title": "BankConfigUpdate",
|
||||
"description": "Request model for updating bank configuration.",
|
||||
"example": {
|
||||
"updates": {
|
||||
"llm_model": "claude-sonnet-4-5",
|
||||
"retain_custom_instructions": "Extract technical details carefully",
|
||||
"retain_extraction_mode": "verbose"
|
||||
}
|
||||
}
|
||||
},
|
||||
"BankListItem": {
|
||||
"properties": {
|
||||
"bank_id": {
|
||||
|
|
@ -4243,13 +4490,19 @@
|
|||
"type": "boolean",
|
||||
"title": "Worker",
|
||||
"description": "Whether the background worker is enabled"
|
||||
},
|
||||
"bank_config_api": {
|
||||
"type": "boolean",
|
||||
"title": "Bank Config Api",
|
||||
"description": "Whether per-bank configuration API is enabled"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"observations",
|
||||
"mcp",
|
||||
"worker"
|
||||
"worker",
|
||||
"bank_config_api"
|
||||
],
|
||||
"title": "FeaturesInfo",
|
||||
"description": "Feature flags indicating which capabilities are enabled."
|
||||
|
|
@ -6259,6 +6512,7 @@
|
|||
"example": {
|
||||
"api_version": "0.4.0",
|
||||
"features": {
|
||||
"bank_config_api": false,
|
||||
"mcp": true,
|
||||
"observations": false,
|
||||
"worker": true
|
||||
|
|
|
|||
Loading…
Reference in a new issue