feat: add HINDSIGHT_API_DEFAULT_BANK_TEMPLATE env var (#966)
* feat: add HINDSIGHT_API_DEFAULT_BANK_TEMPLATE env var Server-level default bank template applied automatically to every newly-created bank. Holds an inline JSON BankTemplateManifest with the same shape as the /import endpoint body. Fields set by the template become per-bank overrides so they take precedence over equivalent HINDSIGHT_API_* env defaults. The template is applied once on first creation and never reapplied, so user overrides via PATCH /config are never clobbered. Malformed manifests are logged and ignored so a broken server-level setting cannot wedge bank creation. * chore: regenerate docs skill * test: update async_retain test mock for renamed bank_profile helper
This commit is contained in:
parent
576016f5dc
commit
fc941d5cae
9 changed files with 537 additions and 143 deletions
|
|
@ -1800,6 +1800,150 @@ class BankTemplateImportResponse(BaseModel):
|
||||||
dry_run: bool = Field(default=False, description="True if this was a validation-only run")
|
dry_run: bool = Field(default=False, description="True if this was a validation-only run")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_bank_template(manifest: "BankTemplateManifest") -> list[str]:
|
||||||
|
"""Validate a parsed manifest beyond Pydantic's structural checks.
|
||||||
|
|
||||||
|
Returns a list of human-readable error strings (e.g. invalid
|
||||||
|
extraction mode values, conflicting settings).
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
if manifest.bank:
|
||||||
|
bank = manifest.bank
|
||||||
|
if bank.retain_extraction_mode is not None:
|
||||||
|
valid_modes = ("concise", "verbose", "custom", "chunks")
|
||||||
|
if bank.retain_extraction_mode not in valid_modes:
|
||||||
|
errors.append(
|
||||||
|
f"bank.retain_extraction_mode: must be one of {valid_modes}, got '{bank.retain_extraction_mode}'"
|
||||||
|
)
|
||||||
|
if bank.retain_custom_instructions and bank.retain_extraction_mode != "custom":
|
||||||
|
errors.append("bank.retain_custom_instructions: requires retain_extraction_mode='custom'")
|
||||||
|
if manifest.mental_models:
|
||||||
|
for i, mm in enumerate(manifest.mental_models):
|
||||||
|
if not mm.name.strip():
|
||||||
|
errors.append(f"mental_models[{i}].name: must not be empty")
|
||||||
|
if not mm.source_query.strip():
|
||||||
|
errors.append(f"mental_models[{i}].source_query: must not be empty")
|
||||||
|
if manifest.directives:
|
||||||
|
for i, d in enumerate(manifest.directives):
|
||||||
|
if not d.name.strip():
|
||||||
|
errors.append(f"directives[{i}].name: must not be empty")
|
||||||
|
if not d.content.strip():
|
||||||
|
errors.append(f"directives[{i}].content: must not be empty")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_bank_template_manifest(
|
||||||
|
memory,
|
||||||
|
bank_id: str,
|
||||||
|
manifest: "BankTemplateManifest",
|
||||||
|
request_context: "RequestContext",
|
||||||
|
) -> "BankTemplateImportResponse":
|
||||||
|
"""Apply a validated BankTemplateManifest to an existing bank.
|
||||||
|
|
||||||
|
Shared by the /import endpoint and the default-template-on-create hook
|
||||||
|
driven by HINDSIGHT_API_DEFAULT_BANK_TEMPLATE. The bank MUST already
|
||||||
|
exist; caller is responsible for validation (Pydantic + validate_bank_template).
|
||||||
|
"""
|
||||||
|
config_applied = False
|
||||||
|
if manifest.bank:
|
||||||
|
config_updates = manifest.bank.get_config_updates()
|
||||||
|
if config_updates:
|
||||||
|
await memory._config_resolver.update_bank_config(bank_id, config_updates, request_context)
|
||||||
|
config_applied = True
|
||||||
|
|
||||||
|
created_ids: list[str] = []
|
||||||
|
updated_ids: list[str] = []
|
||||||
|
operation_ids: list[str] = []
|
||||||
|
|
||||||
|
if manifest.mental_models:
|
||||||
|
# Fetch existing mental models to decide create vs update
|
||||||
|
existing = await memory.list_mental_models(bank_id=bank_id, request_context=request_context)
|
||||||
|
existing_by_id = {m["id"]: m for m in existing}
|
||||||
|
|
||||||
|
for mm in manifest.mental_models:
|
||||||
|
if mm.id in existing_by_id:
|
||||||
|
await memory.update_mental_model(
|
||||||
|
bank_id=bank_id,
|
||||||
|
mental_model_id=mm.id,
|
||||||
|
name=mm.name,
|
||||||
|
source_query=mm.source_query,
|
||||||
|
max_tokens=mm.max_tokens,
|
||||||
|
tags=mm.tags if mm.tags else None,
|
||||||
|
trigger=mm.trigger.model_dump() if mm.trigger else None,
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
result = await memory.submit_async_refresh_mental_model(
|
||||||
|
bank_id=bank_id,
|
||||||
|
mental_model_id=mm.id,
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
operation_ids.append(result["operation_id"])
|
||||||
|
updated_ids.append(mm.id)
|
||||||
|
else:
|
||||||
|
mental_model = await memory.create_mental_model(
|
||||||
|
bank_id=bank_id,
|
||||||
|
name=mm.name,
|
||||||
|
source_query=mm.source_query,
|
||||||
|
content="Generating content...",
|
||||||
|
mental_model_id=mm.id,
|
||||||
|
tags=mm.tags if mm.tags else None,
|
||||||
|
max_tokens=mm.max_tokens,
|
||||||
|
trigger=mm.trigger.model_dump() if mm.trigger else None,
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
result = await memory.submit_async_refresh_mental_model(
|
||||||
|
bank_id=bank_id,
|
||||||
|
mental_model_id=mental_model["id"],
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
operation_ids.append(result["operation_id"])
|
||||||
|
created_ids.append(mm.id)
|
||||||
|
|
||||||
|
directives_created: list[str] = []
|
||||||
|
directives_updated: list[str] = []
|
||||||
|
|
||||||
|
if manifest.directives:
|
||||||
|
existing_directives = await memory.list_directives(
|
||||||
|
bank_id=bank_id, active_only=False, request_context=request_context
|
||||||
|
)
|
||||||
|
existing_by_name = {d["name"]: d for d in existing_directives}
|
||||||
|
|
||||||
|
for directive in manifest.directives:
|
||||||
|
if directive.name in existing_by_name:
|
||||||
|
await memory.update_directive(
|
||||||
|
bank_id=bank_id,
|
||||||
|
directive_id=existing_by_name[directive.name]["id"],
|
||||||
|
content=directive.content,
|
||||||
|
priority=directive.priority,
|
||||||
|
is_active=directive.is_active,
|
||||||
|
tags=directive.tags if directive.tags else None,
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
directives_updated.append(directive.name)
|
||||||
|
else:
|
||||||
|
await memory.create_directive(
|
||||||
|
bank_id=bank_id,
|
||||||
|
name=directive.name,
|
||||||
|
content=directive.content,
|
||||||
|
priority=directive.priority,
|
||||||
|
is_active=directive.is_active,
|
||||||
|
tags=directive.tags if directive.tags else None,
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
directives_created.append(directive.name)
|
||||||
|
|
||||||
|
return BankTemplateImportResponse(
|
||||||
|
bank_id=bank_id,
|
||||||
|
config_applied=config_applied,
|
||||||
|
mental_models_created=created_ids,
|
||||||
|
mental_models_updated=updated_ids,
|
||||||
|
directives_created=directives_created,
|
||||||
|
directives_updated=directives_updated,
|
||||||
|
operation_ids=operation_ids,
|
||||||
|
dry_run=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OperationResponse(BaseModel):
|
class OperationResponse(BaseModel):
|
||||||
"""Response model for a single async operation."""
|
"""Response model for a single async operation."""
|
||||||
|
|
||||||
|
|
@ -4377,38 +4521,6 @@ def _register_routes(app: FastAPI):
|
||||||
# Bank Template Import / Export
|
# Bank Template Import / Export
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|
||||||
def _validate_template(manifest: BankTemplateManifest) -> list[str]:
|
|
||||||
"""Validate a parsed manifest beyond Pydantic's structural checks.
|
|
||||||
|
|
||||||
Returns a list of human-readable error strings (e.g. invalid
|
|
||||||
extraction mode values, conflicting settings).
|
|
||||||
"""
|
|
||||||
errors: list[str] = []
|
|
||||||
if manifest.bank:
|
|
||||||
bank = manifest.bank
|
|
||||||
if bank.retain_extraction_mode is not None:
|
|
||||||
valid_modes = ("concise", "verbose", "custom", "chunks")
|
|
||||||
if bank.retain_extraction_mode not in valid_modes:
|
|
||||||
errors.append(
|
|
||||||
f"bank.retain_extraction_mode: must be one of {valid_modes}, "
|
|
||||||
f"got '{bank.retain_extraction_mode}'"
|
|
||||||
)
|
|
||||||
if bank.retain_custom_instructions and bank.retain_extraction_mode != "custom":
|
|
||||||
errors.append("bank.retain_custom_instructions: requires retain_extraction_mode='custom'")
|
|
||||||
if manifest.mental_models:
|
|
||||||
for i, mm in enumerate(manifest.mental_models):
|
|
||||||
if not mm.name.strip():
|
|
||||||
errors.append(f"mental_models[{i}].name: must not be empty")
|
|
||||||
if not mm.source_query.strip():
|
|
||||||
errors.append(f"mental_models[{i}].source_query: must not be empty")
|
|
||||||
if manifest.directives:
|
|
||||||
for i, d in enumerate(manifest.directives):
|
|
||||||
if not d.name.strip():
|
|
||||||
errors.append(f"directives[{i}].name: must not be empty")
|
|
||||||
if not d.content.strip():
|
|
||||||
errors.append(f"directives[{i}].content: must not be empty")
|
|
||||||
return errors
|
|
||||||
|
|
||||||
@app.post(
|
@app.post(
|
||||||
"/v1/default/banks/{bank_id}/import",
|
"/v1/default/banks/{bank_id}/import",
|
||||||
response_model=BankTemplateImportResponse,
|
response_model=BankTemplateImportResponse,
|
||||||
|
|
@ -4444,7 +4556,7 @@ def _register_routes(app: FastAPI):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Semantic validation beyond Pydantic structural checks
|
# Semantic validation beyond Pydantic structural checks
|
||||||
validation_errors = _validate_template(body)
|
validation_errors = validate_bank_template(body)
|
||||||
if validation_errors:
|
if validation_errors:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|
@ -4462,107 +4574,11 @@ def _register_routes(app: FastAPI):
|
||||||
# Ensure bank exists (auto-creates with defaults if needed)
|
# Ensure bank exists (auto-creates with defaults if needed)
|
||||||
await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
|
await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
|
||||||
|
|
||||||
config_applied = False
|
return await apply_bank_template_manifest(
|
||||||
if body.bank:
|
memory=app.state.memory,
|
||||||
config_updates = body.bank.get_config_updates()
|
|
||||||
if config_updates:
|
|
||||||
await app.state.memory._config_resolver.update_bank_config(bank_id, config_updates, request_context)
|
|
||||||
config_applied = True
|
|
||||||
|
|
||||||
created_ids: list[str] = []
|
|
||||||
updated_ids: list[str] = []
|
|
||||||
operation_ids: list[str] = []
|
|
||||||
|
|
||||||
if body.mental_models:
|
|
||||||
# Fetch existing mental models to decide create vs update
|
|
||||||
existing = await app.state.memory.list_mental_models(bank_id=bank_id, request_context=request_context)
|
|
||||||
existing_by_id = {m["id"]: m for m in existing}
|
|
||||||
|
|
||||||
for mm in body.mental_models:
|
|
||||||
if mm.id in existing_by_id:
|
|
||||||
# Update existing mental model metadata
|
|
||||||
await app.state.memory.update_mental_model(
|
|
||||||
bank_id=bank_id,
|
|
||||||
mental_model_id=mm.id,
|
|
||||||
name=mm.name,
|
|
||||||
source_query=mm.source_query,
|
|
||||||
max_tokens=mm.max_tokens,
|
|
||||||
tags=mm.tags if mm.tags else None,
|
|
||||||
trigger=mm.trigger.model_dump() if mm.trigger else None,
|
|
||||||
request_context=request_context,
|
|
||||||
)
|
|
||||||
# Schedule a refresh to regenerate content with updated query
|
|
||||||
result = await app.state.memory.submit_async_refresh_mental_model(
|
|
||||||
bank_id=bank_id,
|
|
||||||
mental_model_id=mm.id,
|
|
||||||
request_context=request_context,
|
|
||||||
)
|
|
||||||
operation_ids.append(result["operation_id"])
|
|
||||||
updated_ids.append(mm.id)
|
|
||||||
else:
|
|
||||||
# Create new mental model
|
|
||||||
mental_model = await app.state.memory.create_mental_model(
|
|
||||||
bank_id=bank_id,
|
|
||||||
name=mm.name,
|
|
||||||
source_query=mm.source_query,
|
|
||||||
content="Generating content...",
|
|
||||||
mental_model_id=mm.id,
|
|
||||||
tags=mm.tags if mm.tags else None,
|
|
||||||
max_tokens=mm.max_tokens,
|
|
||||||
trigger=mm.trigger.model_dump() if mm.trigger else None,
|
|
||||||
request_context=request_context,
|
|
||||||
)
|
|
||||||
result = await app.state.memory.submit_async_refresh_mental_model(
|
|
||||||
bank_id=bank_id,
|
|
||||||
mental_model_id=mental_model["id"],
|
|
||||||
request_context=request_context,
|
|
||||||
)
|
|
||||||
operation_ids.append(result["operation_id"])
|
|
||||||
created_ids.append(mm.id)
|
|
||||||
|
|
||||||
directives_created: list[str] = []
|
|
||||||
directives_updated: list[str] = []
|
|
||||||
|
|
||||||
if body.directives:
|
|
||||||
# Fetch existing directives to decide create vs update (matched by name)
|
|
||||||
existing_directives = await app.state.memory.list_directives(
|
|
||||||
bank_id=bank_id, active_only=False, request_context=request_context
|
|
||||||
)
|
|
||||||
existing_by_name = {d["name"]: d for d in existing_directives}
|
|
||||||
|
|
||||||
for directive in body.directives:
|
|
||||||
if directive.name in existing_by_name:
|
|
||||||
await app.state.memory.update_directive(
|
|
||||||
bank_id=bank_id,
|
|
||||||
directive_id=existing_by_name[directive.name]["id"],
|
|
||||||
content=directive.content,
|
|
||||||
priority=directive.priority,
|
|
||||||
is_active=directive.is_active,
|
|
||||||
tags=directive.tags if directive.tags else None,
|
|
||||||
request_context=request_context,
|
|
||||||
)
|
|
||||||
directives_updated.append(directive.name)
|
|
||||||
else:
|
|
||||||
await app.state.memory.create_directive(
|
|
||||||
bank_id=bank_id,
|
|
||||||
name=directive.name,
|
|
||||||
content=directive.content,
|
|
||||||
priority=directive.priority,
|
|
||||||
is_active=directive.is_active,
|
|
||||||
tags=directive.tags if directive.tags else None,
|
|
||||||
request_context=request_context,
|
|
||||||
)
|
|
||||||
directives_created.append(directive.name)
|
|
||||||
|
|
||||||
return BankTemplateImportResponse(
|
|
||||||
bank_id=bank_id,
|
bank_id=bank_id,
|
||||||
config_applied=config_applied,
|
manifest=body,
|
||||||
mental_models_created=created_ids,
|
request_context=request_context,
|
||||||
mental_models_updated=updated_ids,
|
|
||||||
directives_created=directives_created,
|
|
||||||
directives_updated=directives_updated,
|
|
||||||
operation_ids=operation_ids,
|
|
||||||
dry_run=False,
|
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
@ -4964,7 +4980,9 @@ def _register_routes(app: FastAPI):
|
||||||
from hindsight_api.engine.retain import bank_utils
|
from hindsight_api.engine.retain import bank_utils
|
||||||
|
|
||||||
# Ensure the bank row exists before inserting into webhooks (FK constraint).
|
# Ensure the bank row exists before inserting into webhooks (FK constraint).
|
||||||
await bank_utils.get_bank_profile(pool, bank_id)
|
_, created = await bank_utils.get_or_create_bank_profile(pool, bank_id)
|
||||||
|
if created:
|
||||||
|
await app.state.memory._apply_default_bank_template(bank_id, request_context)
|
||||||
|
|
||||||
webhook_id = uuid.uuid4()
|
webhook_id = uuid.uuid4()
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,7 @@ ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
|
||||||
ENV_MCP_ENABLED_TOOLS = "HINDSIGHT_API_MCP_ENABLED_TOOLS"
|
ENV_MCP_ENABLED_TOOLS = "HINDSIGHT_API_MCP_ENABLED_TOOLS"
|
||||||
ENV_MCP_STATELESS = "HINDSIGHT_API_MCP_STATELESS"
|
ENV_MCP_STATELESS = "HINDSIGHT_API_MCP_STATELESS"
|
||||||
ENV_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API"
|
ENV_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API"
|
||||||
|
ENV_DEFAULT_BANK_TEMPLATE = "HINDSIGHT_API_DEFAULT_BANK_TEMPLATE"
|
||||||
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
|
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
|
||||||
ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
|
ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
|
||||||
ENV_RECALL_CONNECTION_BUDGET = "HINDSIGHT_API_RECALL_CONNECTION_BUDGET"
|
ENV_RECALL_CONNECTION_BUDGET = "HINDSIGHT_API_RECALL_CONNECTION_BUDGET"
|
||||||
|
|
@ -502,6 +503,7 @@ DEFAULT_MCP_ENABLED = True
|
||||||
DEFAULT_MCP_ENABLED_TOOLS: list[str] | None = None # None = all tools enabled
|
DEFAULT_MCP_ENABLED_TOOLS: list[str] | None = None # None = all tools enabled
|
||||||
DEFAULT_MCP_STATELESS = False # False = stateful (supports SSE/GET); True = stateless (POST-only)
|
DEFAULT_MCP_STATELESS = False # False = stateful (supports SSE/GET); True = stateless (POST-only)
|
||||||
DEFAULT_ENABLE_BANK_CONFIG_API = True
|
DEFAULT_ENABLE_BANK_CONFIG_API = True
|
||||||
|
DEFAULT_DEFAULT_BANK_TEMPLATE: dict | None = None # BankTemplateManifest dict applied to newly-created banks
|
||||||
DEFAULT_GRAPH_RETRIEVER = "link_expansion"
|
DEFAULT_GRAPH_RETRIEVER = "link_expansion"
|
||||||
DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker
|
DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker
|
||||||
DEFAULT_RECALL_CONNECTION_BUDGET = 4 # Max concurrent DB connections per recall operation
|
DEFAULT_RECALL_CONNECTION_BUDGET = 4 # Max concurrent DB connections per recall operation
|
||||||
|
|
@ -676,6 +678,26 @@ def _get_default_model_for_provider(provider: str) -> str:
|
||||||
return PROVIDER_DEFAULT_MODELS.get(provider.lower(), DEFAULT_LLM_MODEL)
|
return PROVIDER_DEFAULT_MODELS.get(provider.lower(), DEFAULT_LLM_MODEL)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_default_bank_template(raw: str | None) -> dict | None:
|
||||||
|
"""
|
||||||
|
Parse HINDSIGHT_API_DEFAULT_BANK_TEMPLATE as JSON.
|
||||||
|
|
||||||
|
The env var holds a BankTemplateManifest (JSON object) applied verbatim to
|
||||||
|
every newly-created bank. Full Pydantic validation is deferred to bank
|
||||||
|
creation time (to avoid pulling API models into config.py), but we fail
|
||||||
|
fast here if the value is not valid JSON or not a JSON object.
|
||||||
|
"""
|
||||||
|
if raw is None or raw.strip() == "":
|
||||||
|
return DEFAULT_DEFAULT_BANK_TEMPLATE
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ValueError(f"Invalid {ENV_DEFAULT_BANK_TEMPLATE}: expected a JSON object, got invalid JSON: {e}") from e
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError(f"Invalid {ENV_DEFAULT_BANK_TEMPLATE}: expected a JSON object, got {type(parsed).__name__}")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class HindsightConfig:
|
class HindsightConfig:
|
||||||
"""Configuration container for Hindsight API."""
|
"""Configuration container for Hindsight API."""
|
||||||
|
|
@ -820,6 +842,9 @@ class HindsightConfig:
|
||||||
mcp_enabled_tools: list[str] | None # None = all tools; explicit list = allowlist
|
mcp_enabled_tools: list[str] | None # None = all tools; explicit list = allowlist
|
||||||
mcp_stateless: bool # True = stateless HTTP (POST-only); False = stateful (supports GET/SSE)
|
mcp_stateless: bool # True = stateless HTTP (POST-only); False = stateful (supports GET/SSE)
|
||||||
enable_bank_config_api: bool
|
enable_bank_config_api: bool
|
||||||
|
# Default bank template (static, server-level only). When set, the manifest is applied
|
||||||
|
# to every newly-created bank, overriding the env/config defaults for any fields it sets.
|
||||||
|
default_bank_template: dict | None
|
||||||
|
|
||||||
# Recall
|
# Recall
|
||||||
graph_retriever: str
|
graph_retriever: str
|
||||||
|
|
@ -1346,6 +1371,7 @@ class HindsightConfig:
|
||||||
mcp_stateless=os.getenv(ENV_MCP_STATELESS, str(DEFAULT_MCP_STATELESS)).lower() == "true",
|
mcp_stateless=os.getenv(ENV_MCP_STATELESS, str(DEFAULT_MCP_STATELESS)).lower() == "true",
|
||||||
enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower()
|
enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower()
|
||||||
== "true",
|
== "true",
|
||||||
|
default_bank_template=_parse_default_bank_template(os.getenv(ENV_DEFAULT_BANK_TEMPLATE)),
|
||||||
# Recall
|
# Recall
|
||||||
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
|
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
|
||||||
recall_max_concurrent=int(os.getenv(ENV_RECALL_MAX_CONCURRENT, str(DEFAULT_RECALL_MAX_CONCURRENT))),
|
recall_max_concurrent=int(os.getenv(ENV_RECALL_MAX_CONCURRENT, str(DEFAULT_RECALL_MAX_CONCURRENT))),
|
||||||
|
|
|
||||||
|
|
@ -5153,7 +5153,13 @@ class MemoryEngine(MemoryEngineInterface):
|
||||||
ctx = BankReadContext(bank_id=bank_id, operation="get_bank_profile", request_context=request_context)
|
ctx = BankReadContext(bank_id=bank_id, operation="get_bank_profile", request_context=request_context)
|
||||||
await self._validate_operation(self._operation_validator.validate_bank_read(ctx))
|
await self._validate_operation(self._operation_validator.validate_bank_read(ctx))
|
||||||
pool = await self._get_pool()
|
pool = await self._get_pool()
|
||||||
profile = await bank_utils.get_bank_profile(pool, bank_id)
|
profile, created = await bank_utils.get_or_create_bank_profile(pool, bank_id)
|
||||||
|
|
||||||
|
# Apply HINDSIGHT_API_DEFAULT_BANK_TEMPLATE to freshly-created banks. Done
|
||||||
|
# before reading the resolved config below so the template's overrides
|
||||||
|
# (e.g. reflect_mission, dispositions) are visible on this very call.
|
||||||
|
if created:
|
||||||
|
await self._apply_default_bank_template(bank_id, request_context)
|
||||||
|
|
||||||
# reflect_mission and disposition in config take precedence over the legacy DB columns
|
# reflect_mission and disposition in config take precedence over the legacy DB columns
|
||||||
config_dict = await self._config_resolver.get_bank_config(bank_id, request_context)
|
config_dict = await self._config_resolver.get_bank_config(bank_id, request_context)
|
||||||
|
|
@ -5178,6 +5184,62 @@ class MemoryEngine(MemoryEngineInterface):
|
||||||
"mission": mission,
|
"mission": mission,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def _apply_default_bank_template(
|
||||||
|
self,
|
||||||
|
bank_id: str,
|
||||||
|
request_context: "RequestContext",
|
||||||
|
) -> None:
|
||||||
|
"""Apply HINDSIGHT_API_DEFAULT_BANK_TEMPLATE to a freshly-created bank.
|
||||||
|
|
||||||
|
No-op if the env var is unset. A malformed default template is logged
|
||||||
|
and swallowed here rather than raised, so a bad server-level setting
|
||||||
|
cannot wedge bank creation across all callers. Misconfiguration is
|
||||||
|
still surfaced loudly via `logger.error`.
|
||||||
|
"""
|
||||||
|
from ..config import get_config
|
||||||
|
|
||||||
|
template_dict = get_config().default_bank_template
|
||||||
|
if not template_dict:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Lazy import to avoid a cycle (http.py imports memory_engine).
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from hindsight_api.api.http import (
|
||||||
|
BankTemplateManifest,
|
||||||
|
apply_bank_template_manifest,
|
||||||
|
validate_bank_template,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest = BankTemplateManifest.model_validate(template_dict)
|
||||||
|
except ValidationError as e:
|
||||||
|
errors = [f"{'.'.join(str(loc) for loc in err['loc'])}: {err['msg']}" for err in e.errors()]
|
||||||
|
logger.error(
|
||||||
|
"HINDSIGHT_API_DEFAULT_BANK_TEMPLATE failed schema validation "
|
||||||
|
f"and will be ignored for bank '{bank_id}': {'; '.join(errors)}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
semantic_errors = validate_bank_template(manifest)
|
||||||
|
if semantic_errors:
|
||||||
|
logger.error(
|
||||||
|
"HINDSIGHT_API_DEFAULT_BANK_TEMPLATE failed semantic validation "
|
||||||
|
f"and will be ignored for bank '{bank_id}': {'; '.join(semantic_errors)}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await apply_bank_template_manifest(
|
||||||
|
memory=self,
|
||||||
|
bank_id=bank_id,
|
||||||
|
manifest=manifest,
|
||||||
|
request_context=request_context,
|
||||||
|
)
|
||||||
|
logger.info(f"Applied HINDSIGHT_API_DEFAULT_BANK_TEMPLATE to newly-created bank '{bank_id}'")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to apply HINDSIGHT_API_DEFAULT_BANK_TEMPLATE to bank '{bank_id}': {e}")
|
||||||
|
|
||||||
async def update_bank_disposition(
|
async def update_bank_disposition(
|
||||||
self,
|
self,
|
||||||
bank_id: str,
|
bank_id: str,
|
||||||
|
|
@ -7789,7 +7851,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||||
|
|
||||||
# Ensure the bank row exists before inserting async_operations (which now has a FK).
|
# Ensure the bank row exists before inserting async_operations (which now has a FK).
|
||||||
# Banks are created lazily on first retain, but the FK requires the row to exist first.
|
# Banks are created lazily on first retain, but the FK requires the row to exist first.
|
||||||
await bank_utils.get_bank_profile(pool, bank_id)
|
_, created = await bank_utils.get_or_create_bank_profile(pool, bank_id)
|
||||||
|
if created:
|
||||||
|
await self._apply_default_bank_template(bank_id, request_context)
|
||||||
|
|
||||||
# Create typed metadata for parent operation
|
# Create typed metadata for parent operation
|
||||||
parent_metadata = BatchRetainParentMetadata(
|
parent_metadata = BatchRetainParentMetadata(
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,22 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
|
||||||
Returns:
|
Returns:
|
||||||
BankProfile with name, typed DispositionTraits, and mission
|
BankProfile with name, typed DispositionTraits, and mission
|
||||||
"""
|
"""
|
||||||
|
profile, _ = await get_or_create_bank_profile(pool, bank_id)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
async def get_or_create_bank_profile(pool, bank_id: str) -> tuple[BankProfile, bool]:
|
||||||
|
"""
|
||||||
|
Get bank profile, auto-creating with defaults if it doesn't exist.
|
||||||
|
|
||||||
|
Same as get_bank_profile, but also returns a flag indicating whether the
|
||||||
|
bank was freshly created on this call. Used by the memory engine to apply
|
||||||
|
the HINDSIGHT_API_DEFAULT_BANK_TEMPLATE hook on first bank creation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (BankProfile, created) where created is True if the bank
|
||||||
|
did not exist before this call.
|
||||||
|
"""
|
||||||
async with acquire_with_retry(pool) as conn:
|
async with acquire_with_retry(pool) as conn:
|
||||||
# Try to get existing bank
|
# Try to get existing bank
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
|
|
@ -129,10 +145,13 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
|
||||||
if isinstance(disposition_data, str):
|
if isinstance(disposition_data, str):
|
||||||
disposition_data = json.loads(disposition_data)
|
disposition_data = json.loads(disposition_data)
|
||||||
|
|
||||||
return BankProfile(
|
return (
|
||||||
name=row["name"],
|
BankProfile(
|
||||||
disposition=DispositionTraits(**disposition_data),
|
name=row["name"],
|
||||||
mission=row["mission"] or "",
|
disposition=DispositionTraits(**disposition_data),
|
||||||
|
mission=row["mission"] or "",
|
||||||
|
),
|
||||||
|
False,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bank doesn't exist, create with defaults.
|
# Bank doesn't exist, create with defaults.
|
||||||
|
|
@ -153,11 +172,15 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
|
||||||
internal_id,
|
internal_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if inserted:
|
created = inserted is not None
|
||||||
|
if created:
|
||||||
# Fresh insert — create per-bank vector indexes (instant on empty bank)
|
# Fresh insert — create per-bank vector indexes (instant on empty bank)
|
||||||
await create_bank_vector_indexes(conn, bank_id, str(internal_id))
|
await create_bank_vector_indexes(conn, bank_id, str(internal_id))
|
||||||
|
|
||||||
return BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), mission="")
|
return (
|
||||||
|
BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), mission=""),
|
||||||
|
created,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def update_bank_disposition(pool, bank_id: str, disposition: dict[str, int]) -> None:
|
async def update_bank_disposition(pool, bank_id: str, disposition: dict[str, int]) -> None:
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,12 @@ async def test_submit_async_retain_includes_document_tags_in_task_payload():
|
||||||
contents = [{"content": "Async retain payload test."}]
|
contents = [{"content": "Async retain payload test."}]
|
||||||
document_tags = ["scope:tools", "user:alice"]
|
document_tags = ["scope:tools", "user:alice"]
|
||||||
|
|
||||||
with patch("hindsight_api.engine.memory_engine.bank_utils.get_bank_profile", new_callable=AsyncMock):
|
# Return (profile, created=False) so the default-template-on-create hook is skipped.
|
||||||
|
with patch(
|
||||||
|
"hindsight_api.engine.memory_engine.bank_utils.get_or_create_bank_profile",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=(MagicMock(), False),
|
||||||
|
):
|
||||||
result = await MemoryEngine.submit_async_retain(
|
result = await MemoryEngine.submit_async_retain(
|
||||||
engine,
|
engine,
|
||||||
bank_id="bank-1",
|
bank_id="bank-1",
|
||||||
|
|
|
||||||
|
|
@ -598,3 +598,182 @@ class TestExport:
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["version"] == "1"
|
assert data["version"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultBankTemplateEnvVar:
|
||||||
|
"""Tests for HINDSIGHT_API_DEFAULT_BANK_TEMPLATE — a server-level env var
|
||||||
|
whose manifest is applied automatically to every newly-created bank."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def default_template(self):
|
||||||
|
return {
|
||||||
|
"version": "1",
|
||||||
|
"bank": {
|
||||||
|
"reflect_mission": "default-env-mission",
|
||||||
|
"retain_extraction_mode": "verbose",
|
||||||
|
"disposition_empathy": 5,
|
||||||
|
"disposition_skepticism": 1,
|
||||||
|
},
|
||||||
|
"mental_models": [
|
||||||
|
{
|
||||||
|
"id": "default-env-model",
|
||||||
|
"name": "Default Env Model",
|
||||||
|
"source_query": "What is the default?",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"directives": [
|
||||||
|
{
|
||||||
|
"name": "Default Env Directive",
|
||||||
|
"content": "Follow the default behavior.",
|
||||||
|
"priority": 7,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _patched_default_template(self, monkeypatch, default_template):
|
||||||
|
"""Install the default template on the already-initialized global config.
|
||||||
|
|
||||||
|
We can't rely on env-var resolution here: MemoryEngine (and its
|
||||||
|
ConfigResolver) snapshot the global config at fixture init time.
|
||||||
|
Patching the field directly keeps the test deterministic while still
|
||||||
|
exercising the same code path that reads `get_config().default_bank_template`.
|
||||||
|
"""
|
||||||
|
from hindsight_api.config import _get_raw_config
|
||||||
|
|
||||||
|
raw = _get_raw_config()
|
||||||
|
monkeypatch.setattr(raw, "default_bank_template", default_template)
|
||||||
|
yield default_template
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_default_template_applied_on_new_bank(
|
||||||
|
self, api_client, bank_id, _patched_default_template
|
||||||
|
):
|
||||||
|
"""Creating a new bank applies the default template (config + mental models + directives)."""
|
||||||
|
# Trigger bank auto-creation via GET profile
|
||||||
|
resp = await api_client.put(f"/v1/default/banks/{bank_id}", json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Config from template should be present as bank overrides
|
||||||
|
config_resp = await api_client.get(f"/v1/default/banks/{bank_id}/config")
|
||||||
|
assert config_resp.status_code == 200
|
||||||
|
overrides = config_resp.json()["overrides"]
|
||||||
|
assert overrides["reflect_mission"] == "default-env-mission"
|
||||||
|
assert overrides["retain_extraction_mode"] == "verbose"
|
||||||
|
assert overrides["disposition_empathy"] == 5
|
||||||
|
assert overrides["disposition_skepticism"] == 1
|
||||||
|
|
||||||
|
# Mental model from template should exist
|
||||||
|
mm_resp = await api_client.get(f"/v1/default/banks/{bank_id}/mental-models/default-env-model")
|
||||||
|
assert mm_resp.status_code == 200
|
||||||
|
assert mm_resp.json()["name"] == "Default Env Model"
|
||||||
|
|
||||||
|
# Directive from template should exist
|
||||||
|
dir_resp = await api_client.get(f"/v1/default/banks/{bank_id}/directives")
|
||||||
|
assert dir_resp.status_code == 200
|
||||||
|
names = [d["name"] for d in dir_resp.json()["items"]]
|
||||||
|
assert "Default Env Directive" in names
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_default_template_overrides_env_config_defaults(
|
||||||
|
self, api_client, bank_id, monkeypatch, default_template
|
||||||
|
):
|
||||||
|
"""Fields set by the default template override server-level env-var defaults.
|
||||||
|
|
||||||
|
We point both HINDSIGHT_API_RETAIN_EXTRACTION_MODE (env) and the
|
||||||
|
default template at different values, then confirm the template wins
|
||||||
|
via the per-bank config overrides layer (highest precedence).
|
||||||
|
"""
|
||||||
|
from hindsight_api.config import _get_raw_config
|
||||||
|
|
||||||
|
raw = _get_raw_config()
|
||||||
|
# Simulate an env-level default of "concise", overridden by a template that sets "verbose".
|
||||||
|
monkeypatch.setattr(raw, "retain_extraction_mode", "concise")
|
||||||
|
monkeypatch.setattr(raw, "default_bank_template", default_template)
|
||||||
|
|
||||||
|
resp = await api_client.put(f"/v1/default/banks/{bank_id}", json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
config_resp = await api_client.get(f"/v1/default/banks/{bank_id}/config")
|
||||||
|
overrides = config_resp.json()["overrides"]
|
||||||
|
# Template value wins at the bank-override layer.
|
||||||
|
assert overrides["retain_extraction_mode"] == "verbose"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_default_template_not_reapplied_on_existing_bank(
|
||||||
|
self, api_client, bank_id, _patched_default_template
|
||||||
|
):
|
||||||
|
"""Template only applies on FIRST creation; subsequent puts are no-ops."""
|
||||||
|
# First hit creates the bank and applies the template
|
||||||
|
resp = await api_client.put(f"/v1/default/banks/{bank_id}", json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# User explicitly overrides a template-set field
|
||||||
|
patch_resp = await api_client.patch(
|
||||||
|
f"/v1/default/banks/{bank_id}/config",
|
||||||
|
json={"updates": {"reflect_mission": "user-override"}},
|
||||||
|
)
|
||||||
|
assert patch_resp.status_code == 200
|
||||||
|
|
||||||
|
# Second put — template must NOT be reapplied (would clobber the override)
|
||||||
|
resp = await api_client.put(f"/v1/default/banks/{bank_id}", json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
config_resp = await api_client.get(f"/v1/default/banks/{bank_id}/config")
|
||||||
|
assert config_resp.json()["overrides"]["reflect_mission"] == "user-override"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_default_template_unset_is_noop(self, api_client, bank_id):
|
||||||
|
"""With the env var unset (fixture default), bank creation behaves as before."""
|
||||||
|
resp = await api_client.put(f"/v1/default/banks/{bank_id}", json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# No template = no overrides
|
||||||
|
config_resp = await api_client.get(f"/v1/default/banks/{bank_id}/config")
|
||||||
|
assert config_resp.json()["overrides"] == {}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_default_template_malformed_is_swallowed(
|
||||||
|
self, api_client, bank_id, monkeypatch
|
||||||
|
):
|
||||||
|
"""A malformed default template is logged and ignored — bank creation still succeeds."""
|
||||||
|
from hindsight_api.config import _get_raw_config
|
||||||
|
|
||||||
|
raw = _get_raw_config()
|
||||||
|
# Wrong version number fails Pydantic validation.
|
||||||
|
monkeypatch.setattr(raw, "default_bank_template", {"version": "999"})
|
||||||
|
|
||||||
|
resp = await api_client.put(f"/v1/default/banks/{bank_id}", json={})
|
||||||
|
# Bank creation must not fail even though the template is broken.
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_parse_default_bank_template_valid_json(self, monkeypatch):
|
||||||
|
"""_parse_default_bank_template parses a valid JSON object env var."""
|
||||||
|
from hindsight_api.config import _parse_default_bank_template
|
||||||
|
|
||||||
|
parsed = _parse_default_bank_template('{"version": "1", "bank": {"disposition_empathy": 4}}')
|
||||||
|
assert parsed == {"version": "1", "bank": {"disposition_empathy": 4}}
|
||||||
|
|
||||||
|
def test_parse_default_bank_template_none_or_empty(self):
|
||||||
|
"""Unset / empty env var resolves to None."""
|
||||||
|
from hindsight_api.config import _parse_default_bank_template
|
||||||
|
|
||||||
|
assert _parse_default_bank_template(None) is None
|
||||||
|
assert _parse_default_bank_template("") is None
|
||||||
|
assert _parse_default_bank_template(" ") is None
|
||||||
|
|
||||||
|
def test_parse_default_bank_template_invalid_json_raises(self):
|
||||||
|
"""Invalid JSON fails fast with a clear error."""
|
||||||
|
from hindsight_api.config import _parse_default_bank_template
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="HINDSIGHT_API_DEFAULT_BANK_TEMPLATE"):
|
||||||
|
_parse_default_bank_template("not-json")
|
||||||
|
|
||||||
|
def test_parse_default_bank_template_non_object_raises(self):
|
||||||
|
"""Non-object JSON (e.g. array, string) fails fast."""
|
||||||
|
from hindsight_api.config import _parse_default_bank_template
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="expected a JSON object"):
|
||||||
|
_parse_default_bank_template("[1, 2, 3]")
|
||||||
|
with pytest.raises(ValueError, match="expected a JSON object"):
|
||||||
|
_parse_default_bank_template('"just a string"')
|
||||||
|
|
|
||||||
|
|
@ -1289,6 +1289,33 @@ Configuration fields are categorized for security:
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `HINDSIGHT_API_ENABLE_BANK_CONFIG_API` | Enable per-bank config API | `true` |
|
| `HINDSIGHT_API_ENABLE_BANK_CONFIG_API` | Enable per-bank config API | `true` |
|
||||||
|
| `HINDSIGHT_API_DEFAULT_BANK_TEMPLATE` | Bank template manifest (JSON) applied automatically to every newly-created bank. See below. | _(unset)_ |
|
||||||
|
|
||||||
|
##### `HINDSIGHT_API_DEFAULT_BANK_TEMPLATE`
|
||||||
|
|
||||||
|
Server-level default bank template. When set, the manifest is applied once
|
||||||
|
to every bank the server creates — triggered the first time a bank is
|
||||||
|
touched (via `PUT /v1/default/banks/{bank_id}`, `/import`, `/retain`, etc.).
|
||||||
|
The value is a JSON-encoded `BankTemplateManifest` with the same shape
|
||||||
|
accepted by `POST /v1/default/banks/{bank_id}/import` (see the `bank`,
|
||||||
|
`mental_models`, and `directives` sections in the Bank Templates API).
|
||||||
|
|
||||||
|
Precedence: fields set by the template become per-bank overrides, so they
|
||||||
|
take precedence over the equivalent `HINDSIGHT_API_*` env-var defaults
|
||||||
|
(e.g. `HINDSIGHT_API_RETAIN_EXTRACTION_MODE`). Users can still override
|
||||||
|
individual fields later via `PATCH /v1/default/banks/{bank_id}/config`;
|
||||||
|
the template is **not** re-applied on subsequent accesses, so explicit
|
||||||
|
overrides are never clobbered.
|
||||||
|
|
||||||
|
A malformed manifest (bad JSON, unknown version, schema errors) is logged
|
||||||
|
and ignored — bank creation still succeeds with plain defaults, so a
|
||||||
|
broken server-level setting cannot wedge all callers.
|
||||||
|
|
||||||
|
Example (compact, single-line JSON as required by env vars):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export HINDSIGHT_API_DEFAULT_BANK_TEMPLATE='{"version":"1","bank":{"reflect_mission":"Help support agents remember customer interactions.","retain_extraction_mode":"verbose","disposition_empathy":5},"directives":[{"name":"Be concise","content":"Always respond concisely.","priority":10}]}'
|
||||||
|
```
|
||||||
|
|
||||||
#### API Endpoints
|
#### API Endpoints
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,31 @@ When you provide a `document_id`, Hindsight upserts the document: if a document
|
||||||
|
|
||||||
If you omit `document_id`, Hindsight assigns a random UUID per request, so re-ingesting the same content will create duplicate memories.
|
If you omit `document_id`, Hindsight assigns a random UUID per request, so re-ingesting the same content will create duplicate memories.
|
||||||
|
|
||||||
|
### update_mode
|
||||||
|
|
||||||
|
Controls how Hindsight handles an existing document when you retain with a `document_id` that already exists.
|
||||||
|
|
||||||
|
| Value | Behaviour |
|
||||||
|
|-------|-----------|
|
||||||
|
| `"replace"` *(default)* | Deletes the old document and all its memories, then processes the new content from scratch. This is the standard upsert described above. |
|
||||||
|
| `"append"` | Concatenates the new content onto the existing document text and reprocesses the combined document. Delta retain automatically skips unchanged chunks, so only the new portion triggers LLM extraction. |
|
||||||
|
|
||||||
|
Append mode requires a `document_id` — without one there is no existing document to append to.
|
||||||
|
|
||||||
|
**When to use append**: Use `"append"` for content that grows incrementally — for example, a log file, a journal, or a chat transcript where you receive new messages one at a time. Instead of re-sending the entire history on each update, send only the new content with `update_mode: "append"` and Hindsight will efficiently merge it with what it already has.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"content": "New entry to add to the existing document.",
|
||||||
|
"document_id": "my-growing-doc",
|
||||||
|
"update_mode": "append"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### entities
|
### entities
|
||||||
|
|
||||||
A list of entities you want to guarantee are recognized, merged with any entities the LLM extracts automatically. Each entry has a `text` field (the entity name) and an optional `type` (e.g., `"PERSON"`, `"ORG"`, `"CONCEPT"` — defaults to `"CONCEPT"` if omitted).
|
A list of entities you want to guarantee are recognized, merged with any entities the LLM extracts automatically. Each entry has a `text` field (the entity name) and an optional `type` (e.g., `"PERSON"`, `"ORG"`, `"CONCEPT"` — defaults to `"CONCEPT"` if omitted).
|
||||||
|
|
|
||||||
|
|
@ -1289,6 +1289,33 @@ Configuration fields are categorized for security:
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `HINDSIGHT_API_ENABLE_BANK_CONFIG_API` | Enable per-bank config API | `true` |
|
| `HINDSIGHT_API_ENABLE_BANK_CONFIG_API` | Enable per-bank config API | `true` |
|
||||||
|
| `HINDSIGHT_API_DEFAULT_BANK_TEMPLATE` | Bank template manifest (JSON) applied automatically to every newly-created bank. See below. | _(unset)_ |
|
||||||
|
|
||||||
|
##### `HINDSIGHT_API_DEFAULT_BANK_TEMPLATE`
|
||||||
|
|
||||||
|
Server-level default bank template. When set, the manifest is applied once
|
||||||
|
to every bank the server creates — triggered the first time a bank is
|
||||||
|
touched (via `PUT /v1/default/banks/{bank_id}`, `/import`, `/retain`, etc.).
|
||||||
|
The value is a JSON-encoded `BankTemplateManifest` with the same shape
|
||||||
|
accepted by `POST /v1/default/banks/{bank_id}/import` (see the `bank`,
|
||||||
|
`mental_models`, and `directives` sections in the Bank Templates API).
|
||||||
|
|
||||||
|
Precedence: fields set by the template become per-bank overrides, so they
|
||||||
|
take precedence over the equivalent `HINDSIGHT_API_*` env-var defaults
|
||||||
|
(e.g. `HINDSIGHT_API_RETAIN_EXTRACTION_MODE`). Users can still override
|
||||||
|
individual fields later via `PATCH /v1/default/banks/{bank_id}/config`;
|
||||||
|
the template is **not** re-applied on subsequent accesses, so explicit
|
||||||
|
overrides are never clobbered.
|
||||||
|
|
||||||
|
A malformed manifest (bad JSON, unknown version, schema errors) is logged
|
||||||
|
and ignored — bank creation still succeeds with plain defaults, so a
|
||||||
|
broken server-level setting cannot wedge all callers.
|
||||||
|
|
||||||
|
Example (compact, single-line JSON as required by env vars):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export HINDSIGHT_API_DEFAULT_BANK_TEMPLATE='{"version":"1","bank":{"reflect_mission":"Help support agents remember customer interactions.","retain_extraction_mode":"verbose","disposition_empathy":5},"directives":[{"name":"Be concise","content":"Always respond concisely.","priority":10}]}'
|
||||||
|
```
|
||||||
|
|
||||||
#### API Endpoints
|
#### API Endpoints
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue