diff --git a/hindsight-api/hindsight_api/config.py b/hindsight-api/hindsight_api/config.py index 0a26c16f..82de0bba 100644 --- a/hindsight-api/hindsight_api/config.py +++ b/hindsight-api/hindsight_api/config.py @@ -252,6 +252,9 @@ ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID" ENV_LLM_VERTEXAI_REGION = "HINDSIGHT_API_LLM_VERTEXAI_REGION" ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY" +# Gemini safety settings +ENV_LLM_GEMINI_SAFETY_SETTINGS = "HINDSIGHT_API_LLM_GEMINI_SAFETY_SETTINGS" + # Retain settings ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS" ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE" @@ -353,6 +356,9 @@ DEFAULT_LLM_VERTEXAI_PROJECT_ID = None # Required for Vertex AI DEFAULT_LLM_VERTEXAI_REGION = "us-central1" DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = None # Optional, uses ADC if not set +# Gemini safety settings defaults +DEFAULT_LLM_GEMINI_SAFETY_SETTINGS = None # None = use Gemini default safety settings + DEFAULT_EMBEDDINGS_PROVIDER = "local" DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5" DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS) @@ -567,6 +573,9 @@ class HindsightConfig: llm_vertexai_region: str llm_vertexai_service_account_key: str | None + # Gemini safety settings (None = use Gemini defaults; list of dicts with category/threshold) + llm_gemini_safety_settings: list | None + # Per-operation LLM configuration (None = use default LLM config) retain_llm_provider: str | None retain_llm_api_key: str | None @@ -792,6 +801,8 @@ class HindsightConfig: "disposition_skepticism", "disposition_literalism", "disposition_empathy", + # Gemini safety settings (controls content filtering for Gemini/VertexAI providers) + "llm_gemini_safety_settings", } @property @@ -912,6 +923,8 @@ class HindsightConfig: llm_vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION, DEFAULT_LLM_VERTEXAI_REGION), llm_vertexai_service_account_key=os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY) or DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY, + # Gemini safety settings (JSON-encoded list of {category, threshold} dicts) + llm_gemini_safety_settings=json.loads(os.getenv(ENV_LLM_GEMINI_SAFETY_SETTINGS, "null")), # Per-operation LLM config (None = use default) retain_llm_provider=os.getenv(ENV_RETAIN_LLM_PROVIDER) or None, retain_llm_api_key=os.getenv(ENV_RETAIN_LLM_API_KEY) or None, diff --git a/hindsight-api/hindsight_api/engine/consolidation/consolidator.py b/hindsight-api/hindsight_api/engine/consolidation/consolidator.py index f5e8a845..e8bca7f3 100644 --- a/hindsight-api/hindsight_api/engine/consolidation/consolidator.py +++ b/hindsight-api/hindsight_api/engine/consolidation/consolidator.py @@ -126,6 +126,12 @@ async def run_consolidation_job( """ # Resolve bank-specific config with hierarchical overrides config = await memory_engine._config_resolver.resolve_full_config(bank_id, request_context) + + # Apply bank-specific Gemini safety settings for this request context + from ..providers.gemini_llm import set_gemini_safety_settings + + set_gemini_safety_settings(config.llm_gemini_safety_settings) + perf = ConsolidationPerfLog(bank_id) max_memories_per_batch = config.consolidation_batch_size llm_batch_size = max(1, config.consolidation_llm_batch_size) diff --git a/hindsight-api/hindsight_api/engine/llm_wrapper.py b/hindsight-api/hindsight_api/engine/llm_wrapper.py index 86805070..2ec45d39 100644 --- a/hindsight-api/hindsight_api/engine/llm_wrapper.py +++ b/hindsight-api/hindsight_api/engine/llm_wrapper.py @@ -124,6 +124,7 @@ def create_llm_provider( vertexai_project_id: str | None = None, vertexai_region: str | None = None, vertexai_credentials: Any = None, + gemini_safety_settings: list | None = None, ) -> Any: # Returns LLMInterface """ Factory function to create the appropriate LLM provider implementation. @@ -192,6 +193,7 @@ def create_llm_provider( vertexai_project_id=vertexai_project_id, vertexai_region=vertexai_region, vertexai_credentials=vertexai_credentials, + gemini_safety_settings=gemini_safety_settings, ) elif provider_lower == "anthropic": @@ -234,6 +236,7 @@ class LLMProvider: reasoning_effort: str = "low", groq_service_tier: str | None = None, openai_service_tier: str | None = None, + gemini_safety_settings: list | None = None, ): """ Initialize LLM provider. @@ -246,6 +249,7 @@ class LLMProvider: reasoning_effort: Reasoning effort level for supported providers. groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config. openai_service_tier: OpenAI service tier (None or "flex") - from config. + gemini_safety_settings: Safety settings for Gemini/VertexAI providers. """ self.provider = provider.lower() self.api_key = api_key @@ -255,6 +259,8 @@ class LLMProvider: # Service tiers from hierarchical config (not env vars) self.groq_service_tier = groq_service_tier self.openai_service_tier = openai_service_tier + # Gemini safety settings (instance default; can be overridden per-request via context var) + self.gemini_safety_settings = gemini_safety_settings # Validate provider valid_providers = [ @@ -323,6 +329,18 @@ class LLMProvider: f"model={self.model}, auth={'service_account' if service_account_key else 'ADC'}" ) + # For Gemini/VertexAI providers: read safety settings from global config if not explicitly provided + # Use _get_raw_config() to bypass StaticConfigProxy (which blocks configurable fields), + # since LLMProvider initialization legitimately needs the server-level default. + if self.provider in ("gemini", "vertexai") and self.gemini_safety_settings is None: + from ..config import _get_raw_config + + try: + raw_config = _get_raw_config() + self.gemini_safety_settings = raw_config.llm_gemini_safety_settings + except Exception: + pass # Config may not be initialized in test environments + # Create provider implementation using factory self._provider_impl = create_llm_provider( provider=self.provider, @@ -335,6 +353,7 @@ class LLMProvider: vertexai_project_id=vertexai_project_id, vertexai_region=vertexai_region, vertexai_credentials=vertexai_credentials, + gemini_safety_settings=self.gemini_safety_settings, ) # Backward compatibility: Keep mock provider properties diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py index 0251f306..1dc7362a 100644 --- a/hindsight-api/hindsight_api/engine/memory_engine.py +++ b/hindsight-api/hindsight_api/engine/memory_engine.py @@ -1831,6 +1831,11 @@ class MemoryEngine(MemoryEngineInterface): # Resolve bank-specific config for this operation resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context) + # Apply bank-specific Gemini safety settings for this request context + from .providers.gemini_llm import set_gemini_safety_settings + + set_gemini_safety_settings(resolved_config.llm_gemini_safety_settings) + # Create parent span for retain operation with create_operation_span("retain", bank_id): return await orchestrator.retain_batch( @@ -4482,6 +4487,12 @@ class MemoryEngine(MemoryEngineInterface): # The agent can call lookup() to list available models if needed. # This is critical for banks with many mental models to avoid huge prompts. + # Apply bank-specific Gemini safety settings for this request context + resolved_reflect_config = await self._config_resolver.resolve_full_config(bank_id, request_context) + from .providers.gemini_llm import set_gemini_safety_settings + + set_gemini_safety_settings(resolved_reflect_config.llm_gemini_safety_settings) + # Compute max iterations based on budget config = get_config() base_max_iterations = config.reflect_max_iterations diff --git a/hindsight-api/hindsight_api/engine/providers/gemini_llm.py b/hindsight-api/hindsight_api/engine/providers/gemini_llm.py index f078c792..52a96274 100644 --- a/hindsight-api/hindsight_api/engine/providers/gemini_llm.py +++ b/hindsight-api/hindsight_api/engine/providers/gemini_llm.py @@ -11,6 +11,7 @@ import json import logging import os import time +from contextvars import ContextVar from typing import Any from google import genai @@ -24,6 +25,25 @@ from hindsight_api.metrics import get_metrics_collector logger = logging.getLogger(__name__) +# Context variable for per-request Gemini safety settings override (supports per-bank configuration) +_safety_settings_ctx: ContextVar[list | None] = ContextVar("gemini_safety_settings", default=None) + + +def set_gemini_safety_settings(settings: list | None) -> None: + """ + Set Gemini safety settings for the current async context. + + This allows per-bank safety settings to be applied without changing + the LLM provider interface. Call this before making LLM calls within + an operation that has resolved bank-specific configuration. + + Args: + settings: List of safety setting dicts with 'category' and 'threshold' keys, + or None to use the instance default (from env var). + """ + _safety_settings_ctx.set(settings) + + # Vertex AI imports (optional) try: import google.auth @@ -58,6 +78,9 @@ class GeminiLLM(LLMInterface): self._client = None self._is_vertexai = self.provider == "vertexai" + # Safety settings: None means use Gemini's defaults + self._safety_settings: list | None = kwargs.get("gemini_safety_settings") + if self._is_vertexai: self._init_vertexai(**kwargs) else: @@ -216,6 +239,16 @@ class GeminiLLM(LLMInterface): if temperature is not None: config_kwargs["temperature"] = temperature + # Apply safety settings: context var (per-request bank override) takes precedence over instance default + effective_safety_settings = _safety_settings_ctx.get() + if effective_safety_settings is None: + effective_safety_settings = self._safety_settings + if effective_safety_settings is not None: + config_kwargs["safety_settings"] = [ + genai_types.SafetySetting(category=s["category"], threshold=s["threshold"]) + for s in effective_safety_settings + ] + generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None last_exception = None @@ -489,6 +522,16 @@ class GeminiLLM(LLMInterface): ) # "auto" is the default (no tool_config needed) + # Apply safety settings: context var (per-request bank override) takes precedence over instance default + effective_safety_settings = _safety_settings_ctx.get() + if effective_safety_settings is None: + effective_safety_settings = self._safety_settings + if effective_safety_settings is not None: + config_kwargs["safety_settings"] = [ + genai_types.SafetySetting(category=s["category"], threshold=s["threshold"]) + for s in effective_safety_settings + ] + config = genai_types.GenerateContentConfig(**config_kwargs) last_exception = None diff --git a/hindsight-api/hindsight_api/main.py b/hindsight-api/hindsight_api/main.py index 635a1caf..fb6bfeda 100644 --- a/hindsight-api/hindsight_api/main.py +++ b/hindsight-api/hindsight_api/main.py @@ -171,6 +171,7 @@ def main(): llm_vertexai_project_id=config.llm_vertexai_project_id, llm_vertexai_region=config.llm_vertexai_region, llm_vertexai_service_account_key=config.llm_vertexai_service_account_key, + llm_gemini_safety_settings=config.llm_gemini_safety_settings, retain_llm_provider=config.retain_llm_provider, retain_llm_api_key=config.retain_llm_api_key, retain_llm_model=config.retain_llm_model, diff --git a/hindsight-api/tests/test_gemini_safety_settings.py b/hindsight-api/tests/test_gemini_safety_settings.py new file mode 100644 index 00000000..a8f8f984 --- /dev/null +++ b/hindsight-api/tests/test_gemini_safety_settings.py @@ -0,0 +1,325 @@ +""" +Tests for Gemini safety settings feature. + +Verifies that: +- Safety settings are read from env var and stored on GeminiLLM instances +- Settings are applied to GenerateContentConfig in call() and call_with_tools() +- The context variable override allows per-bank settings at request time +- None (unset) means Gemini's default safety settings are used (no override) +""" + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +pytest.importorskip("google.genai") + + +SAMPLE_SAFETY_SETTINGS = [ + {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}, + {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}, + {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"}, + {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"}, +] + + +# ─── Config / env var parsing ───────────────────────────────────────────────── + + +def test_gemini_safety_settings_parsed_from_env(): + """Safety settings JSON from env var is parsed into HindsightConfig.""" + import json + + from hindsight_api.config import ENV_LLM_GEMINI_SAFETY_SETTINGS, HindsightConfig, clear_config_cache + + settings_json = json.dumps(SAMPLE_SAFETY_SETTINGS) + with patch.dict(os.environ, {ENV_LLM_GEMINI_SAFETY_SETTINGS: settings_json}, clear=False): + clear_config_cache() + config = HindsightConfig.from_env() + assert config.llm_gemini_safety_settings == SAMPLE_SAFETY_SETTINGS + clear_config_cache() + + +def test_gemini_safety_settings_default_is_none(): + """When env var is not set, llm_gemini_safety_settings defaults to None.""" + from hindsight_api.config import ENV_LLM_GEMINI_SAFETY_SETTINGS, HindsightConfig, clear_config_cache + + env = {k: v for k, v in os.environ.items() if k != ENV_LLM_GEMINI_SAFETY_SETTINGS} + with patch.dict(os.environ, env, clear=True): + clear_config_cache() + config = HindsightConfig.from_env() + assert config.llm_gemini_safety_settings is None + clear_config_cache() + + +def test_gemini_safety_settings_is_configurable_field(): + """llm_gemini_safety_settings appears in configurable (per-bank) fields.""" + from hindsight_api.config import HindsightConfig + + assert "llm_gemini_safety_settings" in HindsightConfig.get_configurable_fields() + + +def test_gemini_safety_settings_not_in_credential_fields(): + """llm_gemini_safety_settings is NOT a credential — it is safe to expose via API.""" + from hindsight_api.config import HindsightConfig + + assert "llm_gemini_safety_settings" not in HindsightConfig.get_credential_fields() + + +# ─── GeminiLLM instance ─────────────────────────────────────────────────────── + + +def _make_gemini_provider(safety_settings=None): + """Return a GeminiLLM instance with a mocked genai.Client.""" + with patch("google.genai.Client") as mock_client_cls: + mock_client_cls.return_value = MagicMock() + from hindsight_api.engine.providers.gemini_llm import GeminiLLM + + provider = GeminiLLM( + provider="gemini", + api_key="fake-api-key", + base_url="", + model="gemini-2.5-flash", + gemini_safety_settings=safety_settings, + ) + # Replace client with a fresh mock so we can inspect calls + provider._client = MagicMock() + return provider + + +def test_gemini_llm_stores_safety_settings(): + """GeminiLLM stores safety settings passed at construction.""" + provider = _make_gemini_provider(safety_settings=SAMPLE_SAFETY_SETTINGS) + assert provider._safety_settings == SAMPLE_SAFETY_SETTINGS + + +def test_gemini_llm_no_safety_settings_is_none(): + """GeminiLLM._safety_settings is None when not provided.""" + provider = _make_gemini_provider(safety_settings=None) + assert provider._safety_settings is None + + +# ─── call() applies safety settings ────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_call_applies_safety_settings(): + """call() includes safety_settings in GenerateContentConfig when configured.""" + from google.genai import types as genai_types + + provider = _make_gemini_provider(safety_settings=SAMPLE_SAFETY_SETTINGS) + + # Build a fake successful response + fake_response = MagicMock() + fake_response.text = "hello" + fake_response.candidates = [MagicMock(finish_reason="STOP")] + fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=2) + + provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response) + + await provider.call( + messages=[{"role": "user", "content": "hi"}], + scope="test", + ) + + # Inspect the config passed to generate_content + call_args = provider._client.aio.models.generate_content.call_args + config_arg = call_args.kwargs.get("config") or call_args.args[0] if call_args.args else None + # config may be in kwargs or positional; grab from kwargs + config_arg = call_args.kwargs.get("config") + + assert config_arg is not None, "GenerateContentConfig should have been passed" + assert hasattr(config_arg, "safety_settings"), "Config should have safety_settings" + assert config_arg.safety_settings is not None + + categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings] + assert "HARM_CATEGORY_HARASSMENT" in categories + assert "HARM_CATEGORY_HATE_SPEECH" in categories + assert "HARM_CATEGORY_SEXUALLY_EXPLICIT" in categories + assert "HARM_CATEGORY_DANGEROUS_CONTENT" in categories + + thresholds = [s.threshold.value if hasattr(s.threshold, "value") else str(s.threshold) for s in config_arg.safety_settings] + assert all(t == "BLOCK_NONE" for t in thresholds) + + +@pytest.mark.asyncio +async def test_call_no_safety_settings_omits_key(): + """call() does NOT add safety_settings to GenerateContentConfig when none configured.""" + provider = _make_gemini_provider(safety_settings=None) + + fake_response = MagicMock() + fake_response.text = "hello" + fake_response.candidates = [MagicMock(finish_reason="STOP")] + fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=2) + + provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response) + + await provider.call( + messages=[{"role": "user", "content": "hi"}], + scope="test", + ) + + call_args = provider._client.aio.models.generate_content.call_args + config_arg = call_args.kwargs.get("config") + + # When no safety settings, config is either None or lacks safety_settings + if config_arg is not None: + assert not hasattr(config_arg, "safety_settings") or config_arg.safety_settings is None + + +# ─── call_with_tools() applies safety settings ──────────────────────────────── + + +@pytest.mark.asyncio +async def test_call_with_tools_applies_safety_settings(): + """call_with_tools() includes safety_settings in GenerateContentConfig.""" + provider = _make_gemini_provider(safety_settings=SAMPLE_SAFETY_SETTINGS) + + # Build a fake tool-use response (no tool calls, just text) + fake_part = MagicMock() + fake_part.text = "answer" + fake_part.function_call = None + + fake_candidate = MagicMock() + fake_candidate.content = MagicMock(parts=[fake_part]) + + fake_response = MagicMock() + fake_response.candidates = [fake_candidate] + fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=3) + + provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response) + + tools = [ + { + "type": "function", + "function": { + "name": "test_tool", + "description": "A test tool", + "parameters": {"type": "object", "properties": {}, "required": []}, + }, + } + ] + + await provider.call_with_tools( + messages=[{"role": "user", "content": "hi"}], + tools=tools, + scope="test", + ) + + call_args = provider._client.aio.models.generate_content.call_args + config_arg = call_args.kwargs.get("config") + + assert config_arg is not None + assert config_arg.safety_settings is not None + categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings] + assert "HARM_CATEGORY_HARASSMENT" in categories + + +# ─── Context variable override ──────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_context_var_overrides_instance_settings(): + """The context var safety settings take precedence over instance defaults.""" + from hindsight_api.engine.providers.gemini_llm import set_gemini_safety_settings + + # Instance has settings, but we'll override via context var with different settings + instance_settings = [{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_ONLY_HIGH"}] + ctx_settings = [{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}] + + provider = _make_gemini_provider(safety_settings=instance_settings) + + fake_response = MagicMock() + fake_response.text = "hello" + fake_response.candidates = [MagicMock(finish_reason="STOP")] + fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=2) + + provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response) + + # Set context var override + set_gemini_safety_settings(ctx_settings) + try: + await provider.call( + messages=[{"role": "user", "content": "hi"}], + scope="test", + ) + finally: + set_gemini_safety_settings(None) # Reset context + + call_args = provider._client.aio.models.generate_content.call_args + config_arg = call_args.kwargs.get("config") + + assert config_arg is not None + assert config_arg.safety_settings is not None + + # Should use ctx_settings (HATE_SPEECH/BLOCK_NONE), not instance_settings (HARASSMENT/BLOCK_ONLY_HIGH) + categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings] + assert "HARM_CATEGORY_HATE_SPEECH" in categories + assert "HARM_CATEGORY_HARASSMENT" not in categories + + +@pytest.mark.asyncio +async def test_context_var_none_falls_back_to_instance(): + """When context var is None (not set), instance settings are used.""" + from hindsight_api.engine.providers.gemini_llm import set_gemini_safety_settings + + instance_settings = [{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}] + provider = _make_gemini_provider(safety_settings=instance_settings) + + fake_response = MagicMock() + fake_response.text = "hello" + fake_response.candidates = [MagicMock(finish_reason="STOP")] + fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=2) + + provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response) + + # Explicitly set context var to None (fallback) + set_gemini_safety_settings(None) + + await provider.call( + messages=[{"role": "user", "content": "hi"}], + scope="test", + ) + + call_args = provider._client.aio.models.generate_content.call_args + config_arg = call_args.kwargs.get("config") + + assert config_arg is not None + assert config_arg.safety_settings is not None + categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings] + assert "HARM_CATEGORY_HARASSMENT" in categories + + +# ─── LLMProvider reads safety settings from config ──────────────────────────── + + +def test_llm_provider_reads_safety_settings_from_config(): + """LLMProvider reads llm_gemini_safety_settings from global config for Gemini provider.""" + import json + + from hindsight_api.config import ENV_LLM_GEMINI_SAFETY_SETTINGS, clear_config_cache + + settings_json = json.dumps(SAMPLE_SAFETY_SETTINGS) + env_overrides = { + "HINDSIGHT_API_LLM_PROVIDER": "gemini", + "HINDSIGHT_API_LLM_API_KEY": "fake-key", + ENV_LLM_GEMINI_SAFETY_SETTINGS: settings_json, + } + + with patch.dict(os.environ, env_overrides, clear=False): + clear_config_cache() + with patch("google.genai.Client") as mock_client_cls: + mock_client_cls.return_value = MagicMock() + from hindsight_api.engine.llm_wrapper import LLMProvider + + provider = LLMProvider( + provider="gemini", + api_key="fake-key", + base_url="", + model="gemini-2.5-flash", + ) + + assert provider.gemini_safety_settings == SAMPLE_SAFETY_SETTINGS + + clear_config_cache() diff --git a/hindsight-api/tests/test_hierarchical_config.py b/hindsight-api/tests/test_hierarchical_config.py index e1597152..126909b2 100644 --- a/hindsight-api/tests/test_hierarchical_config.py +++ b/hindsight-api/tests/test_hierarchical_config.py @@ -86,7 +86,7 @@ async def test_hierarchical_fields_categorization(): assert "entity_labels" in configurable # Verify count is correct - assert len(configurable) == 13 + assert len(configurable) == 14 # Verify credential fields (NEVER exposed) assert "llm_api_key" in credentials diff --git a/hindsight-control-plane/src/app/banks/[bankId]/page.tsx b/hindsight-control-plane/src/app/banks/[bankId]/page.tsx index c3c57c88..c8a8a326 100644 --- a/hindsight-control-plane/src/app/banks/[bankId]/page.tsx +++ b/hindsight-control-plane/src/app/banks/[bankId]/page.tsx @@ -197,10 +197,15 @@ export default function BankPage() { setShowResetConfigDialog(true)} + disabled={!bankConfigEnabled} className="text-amber-600 dark:text-amber-400 focus:text-amber-700 dark:focus:text-amber-300" + title={!bankConfigEnabled ? "Bank Config API is disabled" : undefined} > Reset Configuration + {!bankConfigEnabled && ( + Off + )} )} - + {bankConfigEnabled && ( + + )} @@ -260,7 +267,7 @@ export default function BankPage() { )} - {bankConfigTab === "configuration" && ( + {bankConfigTab === "configuration" && bankConfigEnabled && (
diff --git a/hindsight-control-plane/src/components/bank-config-view.tsx b/hindsight-control-plane/src/components/bank-config-view.tsx index 3f4ee894..bcda2eab 100644 --- a/hindsight-control-plane/src/components/bank-config-view.tsx +++ b/hindsight-control-plane/src/components/bank-config-view.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, type ReactNode } from "react"; import { useBank } from "@/lib/bank-context"; +import { useFeatures } from "@/lib/features-context"; import { client } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -60,6 +61,38 @@ type MCPEdits = { mcp_enabled_tools: string[] | null; }; +type GeminiSafetySetting = { + category: string; + threshold: string; +}; + +type GeminiEdits = { + llm_gemini_safety_settings: GeminiSafetySetting[] | null; +}; + +// ─── Gemini safety settings catalogue ──────────────────────────────────────── + +const GEMINI_HARM_CATEGORIES = [ + { value: "HARM_CATEGORY_HARASSMENT", label: "Harassment" }, + { value: "HARM_CATEGORY_HATE_SPEECH", label: "Hate Speech" }, + { value: "HARM_CATEGORY_SEXUALLY_EXPLICIT", label: "Sexually Explicit" }, + { value: "HARM_CATEGORY_DANGEROUS_CONTENT", label: "Dangerous Content" }, +] as const; + +const GEMINI_THRESHOLDS = [ + { value: "HARM_BLOCK_THRESHOLD_UNSPECIFIED", label: "Unspecified (use Gemini default)" }, + { value: "OFF", label: "Off (filter disabled)" }, + { value: "BLOCK_NONE", label: "Block none" }, + { value: "BLOCK_LOW_AND_ABOVE", label: "Block low & above" }, + { value: "BLOCK_MEDIUM_AND_ABOVE", label: "Block medium & above" }, + { value: "BLOCK_ONLY_HIGH", label: "Block only high" }, +] as const; + +const DEFAULT_GEMINI_SAFETY_SETTINGS: GeminiSafetySetting[] = GEMINI_HARM_CATEGORIES.map((c) => ({ + category: c.value, + threshold: "BLOCK_NONE", +})); + // ─── MCP tool catalogue ─────────────────────────────────────────────────────── const MCP_TOOL_GROUPS: { label: string; tools: string[] }[] = [ @@ -134,6 +167,12 @@ function mcpSlice(config: Record): MCPEdits { }; } +function geminiSlice(config: Record): GeminiEdits { + return { + llm_gemini_safety_settings: config.llm_gemini_safety_settings ?? null, + }; +} + const DEFAULT_PROFILE: ProfileData = { reflect_mission: "", disposition_skepticism: 3, @@ -145,6 +184,8 @@ const DEFAULT_PROFILE: ProfileData = { export function BankConfigView() { const { currentBank: bankId } = useBank(); + const { features } = useFeatures(); + const bankConfigEnabled = features?.bank_config_api ?? true; // optimistic default while loading const [loading, setLoading] = useState(true); // Source of truth @@ -161,6 +202,7 @@ export function BankConfigView() { ); const [reflectEdits, setReflectEdits] = useState(DEFAULT_PROFILE); const [mcpEdits, setMcpEdits] = useState(mcpSlice({})); + const [geminiEdits, setGeminiEdits] = useState(geminiSlice({})); // Per-section saving/error state const [retainSaving, setRetainSaving] = useState(false); @@ -168,11 +210,13 @@ export function BankConfigView() { const [entityLabelsSaving, setEntityLabelsSaving] = useState(false); const [reflectSaving, setReflectSaving] = useState(false); const [mcpSaving, setMcpSaving] = useState(false); + const [geminiSaving, setGeminiSaving] = useState(false); const [retainError, setRetainError] = useState(null); const [observationsError, setObservationsError] = useState(null); const [entityLabelsError, setEntityLabelsError] = useState(null); const [reflectError, setReflectError] = useState(null); const [mcpError, setMcpError] = useState(null); + const [geminiError, setGeminiError] = useState(null); // Reset dialog @@ -197,6 +241,10 @@ export function BankConfigView() { () => JSON.stringify(mcpEdits) !== JSON.stringify(mcpSlice(baseConfig)), [mcpEdits, baseConfig] ); + const geminiDirty = useMemo( + () => JSON.stringify(geminiEdits) !== JSON.stringify(geminiSlice(baseConfig)), + [geminiEdits, baseConfig] + ); useEffect(() => { if (bankId) loadAll(); @@ -226,6 +274,7 @@ export function BankConfigView() { setEntityLabelsEdits(entityLabelsSlice(cfg)); setReflectEdits(prof); setMcpEdits(mcpSlice(cfg)); + setGeminiEdits(geminiSlice(cfg)); } catch (err) { console.error("Failed to load bank data:", err); } finally { @@ -312,6 +361,20 @@ export function BankConfigView() { } }; + const saveGemini = async () => { + if (!bankId) return; + setGeminiSaving(true); + setGeminiError(null); + try { + await client.updateBankConfig(bankId, geminiEdits); + setBaseConfig((prev) => ({ ...prev, ...geminiEdits })); + } catch (err: any) { + setGeminiError(err.message || "Failed to save Gemini settings"); + } finally { + setGeminiSaving(false); + } + }; + if (!bankId) { return (
@@ -320,6 +383,21 @@ export function BankConfigView() { ); } + if (!bankConfigEnabled) { + return ( +
+

Bank configuration is disabled

+

+ Set{" "} + + HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true + {" "} + to enable per-bank configuration. +

+
+ ); + } + if (loading) { return (
@@ -551,6 +629,62 @@ export function BankConfigView() { /> )} + + {/* Models Section */} + + {/* Gemini subsection */} +
+

Gemini / Vertex AI

+
+ + When off, Gemini's default safety thresholds are used. When on, configure + thresholds per harm category.{" "} + + Learn more + + + } + > +
+ + setGeminiEdits({ + llm_gemini_safety_settings: enabled + ? [...DEFAULT_GEMINI_SAFETY_SETTINGS] + : null, + }) + } + /> + +
+
+ {geminiEdits.llm_gemini_safety_settings !== null && ( + setGeminiEdits({ llm_gemini_safety_settings: settings })} + /> + )} +
+
+
); @@ -712,7 +846,7 @@ function FieldRow({ children, }: { label: string; - description?: string; + description?: ReactNode; children: ReactNode; }) { return ( @@ -1029,3 +1163,64 @@ function EntityLabelsEditor({
); } + +// ─── GeminiSafetyEditor ─────────────────────────────────────────────────────── + +function GeminiSafetyEditor({ + value, + onChange, +}: { + value: GeminiSafetySetting[]; + onChange: (settings: GeminiSafetySetting[]) => void; +}) { + const getThreshold = (category: string): string => { + return value.find((s) => s.category === category)?.threshold ?? "BLOCK_MEDIUM_AND_ABOVE"; + }; + + const setThreshold = (category: string, threshold: string) => { + const next = GEMINI_HARM_CATEGORIES.map((c) => ({ + category: c.value, + threshold: c.value === category ? threshold : getThreshold(c.value), + })); + onChange(next); + }; + + return ( +
+

+ Set the blocking threshold for each harm category. "Off" disables the filter entirely + (default for Gemini 2.5+). Lower thresholds block more content.{" "} + + Learn more + +

+
+ {GEMINI_HARM_CATEGORIES.map((cat) => ( +
+ {cat.label} + +
+ ))} +
+
+ ); +} diff --git a/hindsight-docs/examples/api/documents.mjs b/hindsight-docs/examples/api/documents.mjs index 0f725b48..2892d473 100644 --- a/hindsight-docs/examples/api/documents.mjs +++ b/hindsight-docs/examples/api/documents.mjs @@ -86,8 +86,6 @@ console.log(`Page items: ${page.items.length}`); // [docs:document-get] -const apiClient = createClient(createConfig({ baseUrl: 'http://localhost:8888' })); - // Get document to expand context from recall results const { data: doc, error } = await sdk.getDocument({ client: apiClient, diff --git a/hindsight-docs/examples/api/documents.py b/hindsight-docs/examples/api/documents.py index 3d9d1010..2dc12a29 100644 --- a/hindsight-docs/examples/api/documents.py +++ b/hindsight-docs/examples/api/documents.py @@ -91,12 +91,12 @@ async def list_documents_example(): result = await api.list_documents(bank_id="my-bank", limit=20, offset=40) print(f"Page items: {len(result.items)}") +import asyncio asyncio.run(list_documents_example()) # [/docs:document-list] # [docs:document-get] -import asyncio from hindsight_client_api import ApiClient, Configuration from hindsight_client_api.api import DocumentsApi