diff --git a/CLAUDE.md b/CLAUDE.md index b056ef5b..64a3be57 100644 --- a/CLAUDE.md +++ b/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) diff --git a/hindsight-api/hindsight_api/alembic/versions/x9s0t1u2v3w4_add_bank_config_column.py b/hindsight-api/hindsight_api/alembic/versions/x9s0t1u2v3w4_add_bank_config_column.py new file mode 100644 index 00000000..bbdf7b73 --- /dev/null +++ b/hindsight-api/hindsight_api/alembic/versions/x9s0t1u2v3w4_add_bank_config_column.py @@ -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 + """) diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py index ec028fe2..2d351d28 100644 --- a/hindsight-api/hindsight_api/api/http.py +++ b/hindsight-api/hindsight_api/api/http.py @@ -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, diff --git a/hindsight-api/hindsight_api/config.py b/hindsight-api/hindsight_api/config.py index a4ced268..18e1bea5 100644 --- a/hindsight-api/hindsight_api/config.py +++ b/hindsight-api/hindsight_api/config.py @@ -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() diff --git a/hindsight-api/hindsight_api/config_resolver.py b/hindsight-api/hindsight_api/config_resolver.py new file mode 100644 index 00000000..2f70f216 --- /dev/null +++ b/hindsight-api/hindsight_api/config_resolver.py @@ -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") diff --git a/hindsight-api/hindsight_api/engine/consolidation/consolidator.py b/hindsight-api/hindsight_api/engine/consolidation/consolidator.py index 41d2e8dc..f45271a1 100644 --- a/hindsight-api/hindsight_api/engine/consolidation/consolidator.py +++ b/hindsight-api/hindsight_api/engine/consolidation/consolidator.py @@ -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 diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py index 0d49139d..fef75afe 100644 --- a/hindsight-api/hindsight_api/engine/memory_engine.py +++ b/hindsight-api/hindsight_api/engine/memory_engine.py @@ -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( diff --git a/hindsight-api/hindsight_api/engine/retain/fact_extraction.py b/hindsight-api/hindsight_api/engine/retain/fact_extraction.py index 9c693b50..3db10225 100644 --- a/hindsight-api/hindsight_api/engine/retain/fact_extraction.py +++ b/hindsight-api/hindsight_api/engine/retain/fact_extraction.py @@ -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) diff --git a/hindsight-api/hindsight_api/engine/retain/orchestrator.py b/hindsight-api/hindsight_api/engine/retain/orchestrator.py index 95736762..87a4cdee 100644 --- a/hindsight-api/hindsight_api/engine/retain/orchestrator.py +++ b/hindsight-api/hindsight_api/engine/retain/orchestrator.py @@ -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" ) diff --git a/hindsight-api/hindsight_api/engine/utils.py b/hindsight-api/hindsight_api/engine/utils.py index 12f0d697..0451424d 100644 --- a/hindsight-api/hindsight_api/engine/utils.py +++ b/hindsight-api/hindsight_api/engine/utils.py @@ -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: diff --git a/hindsight-api/hindsight_api/extensions/tenant.py b/hindsight-api/hindsight_api/extensions/tenant.py index 689237e5..cc55ebd3 100644 --- a/hindsight-api/hindsight_api/extensions/tenant.py +++ b/hindsight-api/hindsight_api/extensions/tenant.py @@ -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. diff --git a/hindsight-api/hindsight_api/main.py b/hindsight-api/hindsight_api/main.py index c0bd8051..b69d891b 100644 --- a/hindsight-api/hindsight_api/main.py +++ b/hindsight-api/hindsight_api/main.py @@ -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, diff --git a/hindsight-api/tests/test_causal_relations.py b/hindsight-api/tests/test_causal_relations.py index 2b096ddb..74624b34 100644 --- a/hindsight-api/tests/test_causal_relations.py +++ b/hindsight-api/tests/test_causal_relations.py @@ -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 diff --git a/hindsight-api/tests/test_causal_relationships.py b/hindsight-api/tests/test_causal_relationships.py index 88720764..24845b86 100644 --- a/hindsight-api/tests/test_causal_relationships.py +++ b/hindsight-api/tests/test_causal_relationships.py @@ -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): diff --git a/hindsight-api/tests/test_consolidation.py b/hindsight-api/tests/test_consolidation.py index 1ff9e7e2..3403c66b 100644 --- a/hindsight-api/tests/test_consolidation.py +++ b/hindsight-api/tests/test_consolidation.py @@ -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) diff --git a/hindsight-api/tests/test_fact_extraction_analysis.py b/hindsight-api/tests/test_fact_extraction_analysis.py index fb1f4a57..9f655889 100644 --- a/hindsight-api/tests/test_fact_extraction_analysis.py +++ b/hindsight-api/tests/test_fact_extraction_analysis.py @@ -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 diff --git a/hindsight-api/tests/test_fact_extraction_output_ratio.py b/hindsight-api/tests/test_fact_extraction_output_ratio.py index 647fb20f..29210fa8 100644 --- a/hindsight-api/tests/test_fact_extraction_output_ratio.py +++ b/hindsight-api/tests/test_fact_extraction_output_ratio.py @@ -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) diff --git a/hindsight-api/tests/test_fact_extraction_quality.py b/hindsight-api/tests/test_fact_extraction_quality.py index 9faa55e5..633acb28 100644 --- a/hindsight-api/tests/test_fact_extraction_quality.py +++ b/hindsight-api/tests/test_fact_extraction_quality.py @@ -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" diff --git a/hindsight-api/tests/test_hierarchical_config.py b/hindsight-api/tests/test_hierarchical_config.py new file mode 100644 index 00000000..affc7697 --- /dev/null +++ b/hindsight-api/tests/test_hierarchical_config.py @@ -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) diff --git a/hindsight-api/tests/test_link_expansion_retrieval.py b/hindsight-api/tests/test_link_expansion_retrieval.py index bdeeaed3..7ca6471d 100644 --- a/hindsight-api/tests/test_link_expansion_retrieval.py +++ b/hindsight-api/tests/test_link_expansion_retrieval.py @@ -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 diff --git a/hindsight-api/tests/test_main_module.py b/hindsight-api/tests/test_main_module.py index 453ee563..e5428f8e 100644 --- a/hindsight-api/tests/test_main_module.py +++ b/hindsight-api/tests/test_main_module.py @@ -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): diff --git a/hindsight-api/tests/test_observations.py b/hindsight-api/tests/test_observations.py index ae7fb1db..57b0465e 100644 --- a/hindsight-api/tests/test_observations.py +++ b/hindsight-api/tests/test_observations.py @@ -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 diff --git a/hindsight-api/tests/test_retain.py b/hindsight-api/tests/test_retain.py index 2672f4ee..cc573036 100644 --- a/hindsight-api/tests/test_retain.py +++ b/hindsight-api/tests/test_retain.py @@ -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):") diff --git a/hindsight-cli/src/api.rs b/hindsight-cli/src/api.rs index 967d8f9d..b700272e 100644 --- a/hindsight-cli/src/api.rs +++ b/hindsight-cli/src/api.rs @@ -387,6 +387,43 @@ impl ApiClient { }) } + pub fn get_bank_config( + &self, + bank_id: &str, + _verbose: bool, + ) -> Result { + 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, + _verbose: bool, + ) -> Result { + self.runtime.block_on(async { + // Convert HashMap to serde_json::Map + let updates_map: serde_json::Map = 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 { + 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( diff --git a/hindsight-cli/src/commands/bank.rs b/hindsight-cli/src/commands/bank.rs index b41e33da..facfec95 100644 --- a/hindsight-cli/src/commands/bank.rs +++ b/hindsight-cli/src/commands/bank.rs @@ -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, + llm_model: Option, + llm_api_key: Option, + llm_base_url: Option, + verbose: bool, + output_format: OutputFormat, +) -> Result<()> { + use std::collections::HashMap; + + let mut updates: HashMap = 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), + } +} diff --git a/hindsight-cli/src/errors.rs b/hindsight-cli/src/errors.rs index 9a830b6c..3f53d371 100644 --- a/hindsight-cli/src/errors.rs +++ b/hindsight-cli/src/errors.rs @@ -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!( diff --git a/hindsight-cli/src/main.rs b/hindsight-cli/src/main.rs index 2b652068..5d28213c 100644 --- a/hindsight-cli/src/main.rs +++ b/hindsight-cli/src/main.rs @@ -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, + + /// LLM model override + #[arg(long)] + llm_model: Option, + + /// LLM API key override + #[arg(long)] + llm_api_key: Option, + + /// LLM base URL override + #[arg(long)] + llm_base_url: Option, + }, + + /// 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 diff --git a/hindsight-clients/python/.openapi-generator/FILES b/hindsight-clients/python/.openapi-generator/FILES index cb0373a9..596e70ad 100644 --- a/hindsight-clients/python/.openapi-generator/FILES +++ b/hindsight-clients/python/.openapi-generator/FILES @@ -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 diff --git a/hindsight-clients/python/hindsight_client_api/__init__.py b/hindsight-clients/python/hindsight_client_api/__init__.py index 6b722dbb..b60be2ee 100644 --- a/hindsight-clients/python/hindsight_client_api/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/__init__.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 diff --git a/hindsight-clients/python/hindsight_client_api/api/banks_api.py b/hindsight-clients/python/hindsight_client_api/api/banks_api.py index 05899b33..59747f5c 100644 --- a/hindsight-clients/python/hindsight_client_api/api/banks_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/banks_api.py @@ -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, diff --git a/hindsight-clients/python/hindsight_client_api/models/__init__.py b/hindsight-clients/python/hindsight_client_api/models/__init__.py index 146a0d85..91f6c01f 100644 --- a/hindsight-clients/python/hindsight_client_api/models/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/models/__init__.py @@ -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 diff --git a/hindsight-clients/python/hindsight_client_api/models/bank_config_response.py b/hindsight-clients/python/hindsight_client_api/models/bank_config_response.py new file mode 100644 index 00000000..4f57cc2a --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/bank_config_response.py @@ -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 + + diff --git a/hindsight-clients/python/hindsight_client_api/models/bank_config_update.py b/hindsight-clients/python/hindsight_client_api/models/bank_config_update.py new file mode 100644 index 00000000..c11579f0 --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/bank_config_update.py @@ -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 + + diff --git a/hindsight-clients/python/hindsight_client_api/models/features_info.py b/hindsight-clients/python/hindsight_client_api/models/features_info.py index c25df77a..b504f32e 100644 --- a/hindsight-clients/python/hindsight_client_api/models/features_info.py +++ b/hindsight-clients/python/hindsight_client_api/models/features_info.py @@ -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 diff --git a/hindsight-clients/typescript/generated/sdk.gen.ts b/hindsight-clients/typescript/generated/sdk.gen.ts index 9ecabdb1..365935be 100644 --- a/hindsight-clients/typescript/generated/sdk.gen.ts +++ b/hindsight-clients/typescript/generated/sdk.gen.ts @@ -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 >({ 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 = ( + options: Options, +) => + (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 = ( + options: Options, +) => + (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 = ( + options: Options, +) => + (options.client ?? client).patch< + UpdateBankConfigResponses, + UpdateBankConfigErrors, + ThrowOnError + >({ + url: "/v1/default/banks/{bank_id}/config", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + /** * Trigger consolidation * diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index b8b03ba2..2312d9d7 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -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?: { diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/config/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/config/route.ts new file mode 100644 index 00000000..20096dfd --- /dev/null +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/config/route.ts @@ -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 }); + } +} diff --git a/hindsight-control-plane/src/app/banks/[bankId]/page.tsx b/hindsight-control-plane/src/app/banks/[bankId]/page.tsx index ece86974..fa1e7d8a 100644 --- a/hindsight-control-plane/src/app/banks/[bankId]/page.tsx +++ b/hindsight-control-plane/src/app/banks/[bankId]/page.tsx @@ -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 (
@@ -43,15 +130,125 @@ export default function BankPage() {
- {/* Profile Tab */} + {/* Bank Configuration Tab */} {view === "profile" && (
-

Bank Profile

-

- View and edit the memory bank profile, disposition traits, and background - information. -

- +
+
+

Bank Configuration

+

+ Manage bank settings, profile, and operations. +

+
+ + + + + + + {isConsolidating ? ( + + ) : ( + + )} + {isConsolidating ? "Consolidating..." : "Run Consolidation"} + {!observationsEnabled && ( + Off + )} + + 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 + } + > + + Clear Observations + {!observationsEnabled && ( + Off + )} + + + setShowDeleteDialog(true)} + className="text-red-600 dark:text-red-400 focus:text-red-700 dark:focus:text-red-300" + > + + Delete Bank + + + +
+ + {/* Sub-tabs */} +
+
+ + +
+
+ + {/* Tab content */} +
+ {bankConfigTab === "general" && ( +
+

+ Overview statistics and background operations for this memory bank. +

+
+ + +
+
+ )} + {bankConfigTab === "configuration" && ( +
+

+ Configure disposition traits, mission, directives, and behavioral settings + for this bank. +

+
+ + {bankConfigEnabled && } +
+
+ )} +
)} @@ -242,6 +439,88 @@ export default function BankPage() {
+ + {/* Delete Bank Confirmation Dialog */} + + + + Delete Memory Bank + +
+

+ Are you sure you want to delete the memory bank{" "} + {bankId}? +

+

+ This action cannot be undone. All memories, entities, documents, and the bank + profile will be permanently deleted. +

+
+
+
+ + Cancel + + {isDeleting ? ( + <> + + Deleting... + + ) : ( + <> + + Delete Bank + + )} + + +
+
+ + {/* Clear Observations Confirmation Dialog */} + + + + Clear Observations + +
+

+ Are you sure you want to clear all observations for{" "} + {bankId}? +

+

+ This will delete all consolidated knowledge. Observations will be regenerated the + next time consolidation runs. +

+
+
+
+ + Cancel + + {isClearingObservations ? ( + <> + + Clearing... + + ) : ( + <> + + Clear Observations + + )} + + +
+
); } diff --git a/hindsight-control-plane/src/components/bank-config-view.tsx b/hindsight-control-plane/src/components/bank-config-view.tsx new file mode 100644 index 00000000..05873830 --- /dev/null +++ b/hindsight-control-plane/src/components/bank-config-view.tsx @@ -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>({}); + const [overrides, setOverrides] = useState>({}); + 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 ( +
+
+
{fieldKey}
+ {fieldMeta.description && ( +

{fieldMeta.description}

+ )} +
+
+ {fieldMeta.type === "boolean" ? ( + + {value ? "Enabled" : "Disabled"} + + ) : fieldMeta.type === "textarea" ? ( + + {value ? `${value.substring(0, 50)}${value.length > 50 ? "..." : ""}` : "Not set"} + + ) : ( + value || Not set + )} +
+
+ ); + }; + + if (!bankId) { + return ( +
+

No bank selected

+
+ ); + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( + <> + + +
+
+ Configuration Settings + + Behavioral parameters for this memory bank + +
+ + + + + + setShowEditDialog(true)}> + + Edit + + + + Reset to Defaults + + + +
+
+ + {Object.entries(FIELD_CATEGORIES).map(([catKey, category]) => ( +
+
+

{category.title}

+

{category.description}

+
+
+ {Object.entries(category.fields).map(([fieldKey, fieldMeta]) => + renderReadOnlyField(fieldKey, fieldMeta) + )} +
+
+ ))} +
+
+ + {showEditDialog && ( + setShowEditDialog(false)} + onSaved={() => { + loadConfig(); + setShowEditDialog(false); + }} + /> + )} + + + + + Reset Configuration + + Are you sure you want to reset all configuration overrides to defaults? This action + cannot be undone. + + + + Cancel + + {resetting ? ( + <> + + Resetting... + + ) : ( + "Reset to Defaults" + )} + + + + + + ); +} + +// Edit dialog component +function ConfigEditDialog({ + bankId, + initialConfig, + overrides, + onClose, + onSaved, +}: { + bankId: string; + initialConfig: Record; + overrides: Record; + onClose: () => void; + onSaved: () => void; +}) { + const [saving, setSaving] = useState(false); + const [error, setError] = useState(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 = {}; + 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 ( +
+
+
+ + {fieldMeta.description && ( +

{fieldMeta.description}

+ )} +
+ +
+
+ ); + } + + if (fieldMeta.type === "select") { + return ( +
+ + {fieldMeta.description && ( +

{fieldMeta.description}

+ )} + +
+ ); + } + + if (fieldMeta.type === "textarea") { + return ( +
+ + {fieldMeta.description && ( +

{fieldMeta.description}

+ )} +