* feat(retain): add verbatim extraction mode Adds retain_extraction_mode="verbatim" that stores each chunk as-is without LLM summarization. The LLM still runs to extract entities, temporal info, and location for full indexability — only the fact text is replaced with the original chunk content (one memory per chunk). Useful for RAG-style indexing and benchmarks where original text must be preserved in memory. - Add "verbatim" to RETAIN_EXTRACTION_MODES in config.py - Add VERBATIM_FACT_EXTRACTION_PROMPT with instructions to preserve text - Add _collapse_to_verbatim() post-processing to enforce 1 fact/chunk - Expose in bank config UI dropdown with updated description - Update configuration.md docs with verbatim mode description - Add unit test for _collapse_to_verbatim and integration test via LLM - Fix pre-existing main.py CLI override missing new reranker fields - Fix pre-existing cross_encoder.py ty type error via setattr * refactor(retain): verbatim mode skips 'what' field entirely Instead of asking the LLM to echo the chunk text back into 'what' and then discarding it, verbatim mode now uses a dedicated schema (VerbatimExtractedFact) that omits the 'what' field altogether. The LLM only returns metadata (entities, temporal info, location, who), saving output tokens and avoiding any risk of paraphrasing before the backfill. - Add VerbatimExtractedFact / VerbatimFactExtractionResponse models - Verbatim mode skips causal-relations section (nothing to relate causally) - _extract_facts_from_chunk: allow missing 'what' in verbatim mode, set combined_text="" (backfilled by _collapse_to_verbatim) - Update verbatim prompt to say DO NOT include 'what' * feat(retain): add index_only extraction mode Zero-LLM retain mode: chunks are stored as-is with no LLM call, no entity extraction, and no temporal indexing. Embeddings still run for semantic search. User-provided entities via RetainContent.entities are the sole source of entity data. Early return placed before the batch-API check so no LLM queue or concurrency locks are acquired. - Add "index_only" to RETAIN_EXTRACTION_MODES - Add _extract_facts_index_only() with pure Python chunking path - Add to UI dropdown and update description - Update configuration.md with index_only docs and table entry - Add unit test asserting zero token usage and exact text preservation * feat(retain): add named retain strategies Allows mixing extraction modes in a single bank via named strategies. Each strategy is a set of hierarchical config overrides (extraction_mode, chunk_size, entity_labels, entities_allow_free_form, etc.) applied on top of the resolved bank config at retain time. - retain_strategies: dict of strategy_name → config overrides (bank config) - retain_default_strategy: default strategy when none specified (bank config) - strategy field on /retain request: per-call override - apply_strategy() in config_resolver applies overrides via dataclasses.replace() - strategy propagates through retain_batch_async → _retain_batch_async_internal and through the async worker task payload - Any hierarchical field is overridable per strategy, including entity_labels and entities_allow_free_form - Docs updated with strategy configuration example and RRF fairness note - Unit test for apply_strategy covering overrides, unknown strategy, and non-hierarchical field filtering * feat(retain): add per-item strategy and strategy tests - Add `strategy` field to `MemoryItem` so individual items in a retain request can override the request-level strategy - Add `strategy` field to `FileRetainMetadata` for per-file strategy override in file retain requests - Group memory items by effective strategy in `api_retain`; each group is processed as a separate batch, results are aggregated - Thread strategy through `submit_async_file_retain` → `_handle_file_convert_retain` → retain task payload - Add `operation_ids` to `RetainResponse` for async requests with mixed per-item strategies - Add `test_strategy_overrides_extraction_mode_for_index_only`: unit test verifying a named strategy with index_only bypasses the LLM - Add `test_retain_request_per_item_strategy_field`: unit test for per-item strategy grouping logic * feat(ui): add retain strategies and default strategy to bank config UI - Add StrategiesEditor component: per-strategy cards with name input and JSON overrides textarea; supports add/remove; validates JSON inline - Add Default Strategy text input (retain_default_strategy) - Update RetainEdits type and retainSlice() to include both new fields - Regenerate OpenAPI spec (retain_strategies, retain_default_strategy, per-item strategy on MemoryItem/FileRetainMetadata, operation_ids on RetainResponse) * refactor(ui): move retain strategies into its own dedicated config section * feat(ui): improve retain strategies UX and add strategy to document dialog - Strategy form now includes entity section (free form toggle + entity labels editor) - Default strategy selector moved outside tab panel, above strategy chips - Strategy tabs redesigned with underline indicator style for clarity - Remove strategy confirms with AlertDialog - Fix tab re-render bug when typing strategy name (skipSyncRef) - Add strategy field to Add New Document dialog (text + per-file for uploads) - File upload collapsible uses same Document/Tags/Source tabbed layout - API: validate empty strategy names in config_resolver - api.ts: add strategy field to retain and uploadFiles types * fix: forward strategy through HTTP layer and SDK; add integration test - route.ts: extract and forward `strategy` from request body to retainBatch - TypeScript SDK: accept and forward `strategy` in retainBatch options and per-item - config_resolver.py: validate empty strategy name keys on update - bank-config-view.tsx: merge entity fields into RetainStrategyForm, redesign strategy tabs with underline style, add confirmation dialog for removal, fix tab-reset-on-typing with skipSyncRef, move default strategy selector outside panel - bank-selector.tsx: add strategy field to Add Document dialog (per-file in tabbed collapsible) - test_retain.py: add end-to-end integration test verifying named strategy application (index_only = 0 LLM tokens) * fix: regenerate TypeScript client with strategy field in RetainRequest/MemoryItem - Regenerate OpenAPI spec to include strategy field in RetainRequest and MemoryItem - Regenerate TypeScript client from updated spec - Add strategy to MemoryItemInput interface - Remove (item as any) cast now that strategy is properly typed * rename: index_only extraction mode → chunks * remove top-level strategy from RetainRequest; strategy is per-item only * fix(clients): update Go and Python generated clients with strategy/operation_ids fields * fix(ci): update hierarchical field count, add strategy to Rust MemoryItem initializers * fix(go-client): minimal targeted YAML updates for strategy/operation_ids fields
315 lines
13 KiB
Python
315 lines
13 KiB
Python
"""
|
|
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, replace
|
|
from typing import Any
|
|
|
|
import asyncpg
|
|
|
|
from hindsight_api.config import HindsightConfig, _get_raw_config, normalize_config_dict
|
|
from hindsight_api.engine.memory_engine import fq_table
|
|
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(
|
|
f"""
|
|
SELECT config FROM {fq_table("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)
|
|
|
|
# Validate retain_strategies: reject empty string keys
|
|
if "retain_strategies" in normalized_updates and normalized_updates["retain_strategies"]:
|
|
empty_keys = [k for k in normalized_updates["retain_strategies"] if not str(k).strip()]
|
|
if empty_keys:
|
|
raise ValueError(
|
|
"Strategy names must not be empty strings. Remove entries with empty names before saving."
|
|
)
|
|
|
|
# Merge with existing config (JSONB || operator)
|
|
async with self.pool.acquire() as conn:
|
|
await conn.execute(
|
|
f"""
|
|
UPDATE {fq_table("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(
|
|
f"""
|
|
UPDATE {fq_table("banks")}
|
|
SET config = '{{}}'::jsonb,
|
|
updated_at = now()
|
|
WHERE bank_id = $1
|
|
""",
|
|
bank_id,
|
|
)
|
|
|
|
logger.info(f"Reset bank config for {bank_id} to defaults")
|
|
|
|
|
|
def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConfig:
|
|
"""
|
|
Apply a named retain strategy's overrides on top of a resolved config.
|
|
|
|
A strategy is a named set of hierarchical field overrides stored in
|
|
config.retain_strategies. Any field in _HIERARCHICAL_FIELDS can be
|
|
overridden, including retain_extraction_mode, retain_chunk_size,
|
|
entity_labels, entities_allow_free_form, etc.
|
|
|
|
Unknown strategy names log a warning and return config unchanged.
|
|
Unknown or non-hierarchical fields in the strategy are silently ignored.
|
|
"""
|
|
strategies = config.retain_strategies or {}
|
|
if strategy_name not in strategies:
|
|
logger.warning(f"Unknown retain strategy '{strategy_name}', using resolved config as-is")
|
|
return config
|
|
|
|
overrides = strategies[strategy_name]
|
|
if not isinstance(overrides, dict):
|
|
logger.warning(f"Retain strategy '{strategy_name}' is not a dict, skipping")
|
|
return config
|
|
|
|
configurable = HindsightConfig.get_configurable_fields()
|
|
filtered = {k: v for k, v in overrides.items() if k in configurable}
|
|
|
|
if not filtered:
|
|
return config
|
|
|
|
logger.debug(f"Applying retain strategy '{strategy_name}': {list(filtered.keys())}")
|
|
return replace(config, **filtered)
|