Compare commits

..
2 Commits
420 changed files with 3010 additions and 26516 deletions
-4
View File
@@ -387,10 +387,6 @@ jobs:
target: aarch64-apple-darwin
artifact_name: hindsight
asset_name: hindsight-darwin-arm64
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
artifact_name: hindsight
asset_name: hindsight-linux-arm64
steps:
- uses: actions/checkout@v4
+9 -53
View File
@@ -686,30 +686,6 @@ jobs:
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
build-rust-cli-arm64:
runs-on: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-gnu
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
hindsight-cli/target
key: linux-arm64-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build CLI
working-directory: hindsight-cli
run: cargo build --release --target aarch64-unknown-linux-gnu
test-rust-client:
runs-on: ubuntu-latest
env:
@@ -1321,11 +1297,7 @@ jobs:
test-doc-examples:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [python, node, cli, go]
name: test-doc-examples (${{ matrix.language }})
needs: test-rust-cli
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
@@ -1343,26 +1315,14 @@ jobs:
PROJECT_ID=$(jq -r '.project_id' /tmp/gcp-credentials.json)
echo "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$PROJECT_ID" >> $GITHUB_ENV
- name: Install Rust
if: matrix.language == 'cli'
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
if: matrix.language == 'cli'
uses: actions/cache@v4
- name: Download CLI artifact
uses: actions/download-artifact@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
hindsight-cli/target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
name: hindsight-cli
path: /usr/local/bin
- name: Build CLI
if: matrix.language == 'cli'
working-directory: hindsight-cli
run: |
cargo build --release
cp target/release/hindsight /usr/local/bin/hindsight
- name: Make CLI executable
run: chmod +x /usr/local/bin/hindsight
- name: Install uv
uses: astral-sh/setup-uv@v5
@@ -1376,7 +1336,6 @@ jobs:
python-version-file: ".python-version"
- name: Set up Node.js
if: matrix.language == 'node'
uses: actions/setup-node@v4
with:
node-version: '20'
@@ -1390,12 +1349,10 @@ jobs:
uv sync --frozen --no-install-project --index-strategy unsafe-best-match
- name: Install Python client dependencies
if: matrix.language == 'python'
working-directory: ./hindsight-clients/python
run: uv sync --frozen --extra test --index-strategy unsafe-best-match
- name: Install TypeScript client
if: matrix.language == 'node'
run: |
npm ci --workspace=hindsight-clients/typescript
npm run build --workspace=hindsight-clients/typescript
@@ -1447,11 +1404,10 @@ jobs:
done
- name: Configure CLI
if: matrix.language == 'cli'
run: hindsight configure --api-url http://localhost:8888
- name: Run doc examples (${{ matrix.language }})
run: ./scripts/test-doc-examples.sh --lang ${{ matrix.language }}
- name: Run all doc examples
run: ./scripts/test-doc-examples.sh
- name: Show API server logs
if: always()
+1 -1
View File
@@ -154,7 +154,7 @@ Key tables: `banks`, `memory_units`, `documents`, `entities`, `entity_links`
3. **Run migrations locally**:
```bash
# Set database URL and run migrations for the base schema plus all tenants
# Set database URL and run migrations
uv run hindsight-admin run-db-migration
# Run on a specific tenant schema
+2 -16
View File
@@ -77,32 +77,18 @@ PIDS=()
# Start API if enabled
if [ "$ENABLE_API" = "true" ]; then
cd /app/api
API_HEALTH_URL="${HINDSIGHT_API_HEALTH_URL:-http://localhost:8888/health}"
API_STARTUP_WAIT_SECONDS="${HINDSIGHT_API_STARTUP_WAIT_SECONDS:-300}"
# Run API directly - Python's PYTHONUNBUFFERED=1 handles output buffering
hindsight-api &
API_PID=$!
PIDS+=($API_PID)
# Wait for API to be ready
api_ready=false
for ((i=1; i<=API_STARTUP_WAIT_SECONDS; i++)); do
if ! kill -0 "$API_PID" 2>/dev/null; then
wait "$API_PID"
exit $?
fi
if curl -sf "$API_HEALTH_URL" &>/dev/null; then
api_ready=true
for i in {1..60}; do
if curl -sf http://localhost:8888/health &>/dev/null; then
break
fi
sleep 1
done
if [ "$api_ready" != "true" ]; then
echo "❌ API did not become healthy within ${API_STARTUP_WAIT_SECONDS}s"
exit 1
fi
else
echo "API disabled (HINDSIGHT_ENABLE_API=false)"
fi
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.4.17
appVersion: "0.4.17"
version: 0.4.15
appVersion: "0.4.15"
keywords:
- ai
- memory
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.4.17"
__version__ = "0.4.15"
+9 -81
View File
@@ -14,8 +14,7 @@ from typing import Any
import asyncpg
import typer
from ..config import DEFAULT_DATABASE_SCHEMA, HindsightConfig
from ..extensions import TenantExtension, load_extension
from ..config import HindsightConfig
from ..pg0 import parse_pg0_url, resolve_database_url
@@ -215,81 +214,20 @@ def restore(
typer.echo("Restore complete")
async def _run_migration(
db_url: str,
schema: str | None = None,
base_schema: str = DEFAULT_DATABASE_SCHEMA,
embedding_dimension: int | None = None,
) -> list[str]:
"""Resolve database URL and run migrations for one schema or all discovered schemas."""
from ..migrations import (
ensure_embedding_dimension,
ensure_text_search_extension,
ensure_vector_extension,
run_migrations,
)
async def _run_migration(db_url: str, schema: str = "public") -> None:
"""Resolve database URL and run migrations."""
from ..migrations import run_migrations
is_pg0, instance_name, _ = parse_pg0_url(db_url)
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
config = HindsightConfig.from_env()
if schema:
schemas = [schema]
else:
tenant_extension = load_extension("TENANT", TenantExtension)
schemas = [base_schema or DEFAULT_DATABASE_SCHEMA]
if tenant_extension:
tenants = await tenant_extension.list_tenants()
schemas.extend(tenant.schema for tenant in tenants if tenant.schema)
# Preserve order while removing duplicates.
schemas = list(dict.fromkeys(schemas))
for schema in schemas:
run_migrations(resolved_url, schema=schema)
if embedding_dimension is not None:
for schema in schemas:
ensure_embedding_dimension(
resolved_url,
embedding_dimension,
schema=schema,
vector_extension=config.vector_extension,
)
for schema in schemas:
ensure_vector_extension(
resolved_url,
vector_extension=config.vector_extension,
schema=schema,
)
for schema in schemas:
ensure_text_search_extension(
resolved_url,
text_search_extension=config.text_search_extension,
schema=schema,
)
return schemas
run_migrations(resolved_url, schema=schema)
@app.command(name="run-db-migration")
def run_db_migration(
schema: str | None = typer.Option(
None,
"--schema",
"-s",
help="Database schema to run migrations on. If omitted, migrate the base schema and all discovered tenant schemas.",
),
embedding_dimension: int | None = typer.Option(
None,
"--embedding-dimension",
help="Expected embedding dimension to enforce after migrations. Omit to skip dimension sync.",
),
schema: str = typer.Option("public", "--schema", "-s", help="Database schema to run migrations on"),
):
"""Run database migrations to the latest version."""
config = HindsightConfig.from_env()
@@ -299,21 +237,11 @@ def run_db_migration(
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
raise typer.Exit(1)
if schema:
typer.echo(f"Running database migrations for schema: {schema}...")
else:
typer.echo("Running database migrations for base schema and all discovered tenant schemas...")
typer.echo(f"Running database migrations (schema: {schema})...")
schemas = asyncio.run(
_run_migration(
config.database_url,
schema=schema,
base_schema=config.database_schema,
embedding_dimension=embedding_dimension,
)
)
asyncio.run(_run_migration(config.database_url, schema))
typer.echo(f"Database migrations completed successfully for {len(schemas)} schema(s)")
typer.echo("Database migrations completed successfully")
async def _decommission_worker(db_url: str, worker_id: str, schema: str = "public") -> int:
@@ -1,54 +0,0 @@
"""Add GIN index on source_memory_ids for observation lookup performance
Without this index, queries using the array overlap operator (&&) or array
containment (@>) on source_memory_ids require a full sequential scan over all
observation memory_units. At ~77k observations this was measured at 45ms per
query, becoming a bottleneck during consolidation recall (57-64s timeouts) and
user recall (18-27s average).
The GIN index reduces these queries to index scans: 45ms → 0.049ms (927x
speedup). Recall dropped from 18-27s to ~6s, and consolidation recall
stabilised from timeout to ~15s.
Created with CONCURRENTLY so the migration does not block reads or writes.
CONCURRENTLY requires running outside a transaction block, so the migration
emits an explicit COMMIT before the statement and uses IF NOT EXISTS for
idempotency.
Revision ID: a2b3c4d5e6f8
Revises: f7g8h9i0j1k2
Create Date: 2026-03-04
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "a2b3c4d5e6f8"
down_revision: str | Sequence[str] | None = "f7g8h9i0j1k2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# CREATE INDEX CONCURRENTLY cannot run inside a transaction block.
# Commit the current Alembic transaction first.
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_units_source_memory_ids "
f"ON {schema}memory_units USING GIN (source_memory_ids) "
f"WHERE source_memory_ids IS NOT NULL"
)
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_units_source_memory_ids")
@@ -1,30 +0,0 @@
"""Add history column to mental_models
Revision ID: c3d4e5f6g7h8
Revises: a2b3c4d5e6f7, a2b3c4d5e6f8
Create Date: 2026-03-06
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "c3d4e5f6g7h8"
down_revision: str | Sequence[str] | None = ("a2b3c4d5e6f7", "a2b3c4d5e6f8")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}mental_models ADD COLUMN IF NOT EXISTS history JSONB DEFAULT '[]'::jsonb")
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS history")
@@ -1,62 +0,0 @@
"""Add webhooks table and next_retry_at to async_operations.
Webhook deliveries are handled as async_operations tasks (operation_type='webhook_delivery')
rather than a dedicated webhook_deliveries table.
Revision ID: e4f5a6b7c8d9
Revises: d2e3f4a5b6c7
Create Date: 2026-03-04
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "e4f5a6b7c8d9"
down_revision: str | Sequence[str] | None = "d2e3f4a5b6c7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(
f"""
CREATE TABLE IF NOT EXISTS {schema}webhooks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bank_id TEXT,
url TEXT NOT NULL,
secret TEXT,
event_types TEXT[] NOT NULL DEFAULT '{{}}',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""
)
# Index for bank-scoped webhook lookup
op.execute(f"CREATE INDEX IF NOT EXISTS idx_webhooks_bank_id ON {schema}webhooks(bank_id)")
# Add next_retry_at to async_operations for task-owned retry scheduling
op.execute(f"ALTER TABLE {schema}async_operations ADD COLUMN IF NOT EXISTS next_retry_at TIMESTAMPTZ NULL")
# Index for polling: status + next_retry_at
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_async_operations_status_retry "
f"ON {schema}async_operations(status, next_retry_at)"
)
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_async_operations_status_retry")
op.execute(f"ALTER TABLE {schema}async_operations DROP COLUMN IF EXISTS next_retry_at")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_webhooks_bank_id")
op.execute(f"DROP TABLE IF EXISTS {schema}webhooks")
@@ -1,33 +0,0 @@
"""Add http_config JSONB column to webhooks table.
Stores HTTP delivery configuration (method, timeout, headers, params) as a
single JSONB column rather than separate columns.
Revision ID: f7g8h9i0j1k2
Revises: e4f5a6b7c8d9
Create Date: 2026-03-04
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "f7g8h9i0j1k2"
down_revision: str | Sequence[str] | None = "e4f5a6b7c8d9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}webhooks ADD COLUMN IF NOT EXISTS http_config JSONB NOT NULL DEFAULT '{{}}'")
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}webhooks DROP COLUMN IF EXISTS http_config")
+37 -699
View File
@@ -71,7 +71,9 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
from hindsight_api.config import get_config
from hindsight_api.engine.memory_engine import Budget, _current_schema, _get_tiktoken_encoding, fq_table
from hindsight_api.engine.db_utils import acquire_with_retry
from hindsight_api.engine.memory_engine import Budget, _get_tiktoken_encoding, fq_table
from hindsight_api.engine.reflect.observations import Observation
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MemoryFact, TokenUsage
from hindsight_api.engine.search.tags import TagsMatch
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
@@ -98,12 +100,7 @@ class ChunkIncludeOptions(BaseModel):
class SourceFactsIncludeOptions(BaseModel):
"""Options for including source facts for observation-type results."""
max_tokens: int = Field(
default=4096, description="Maximum total tokens for source facts across all observations (-1 = unlimited)"
)
max_tokens_per_observation: int = Field(
default=-1, description="Maximum tokens of source facts per observation (-1 = unlimited)"
)
max_tokens: int = Field(default=4096, description="Maximum tokens for source facts")
class IncludeOptions(BaseModel):
@@ -477,11 +474,6 @@ class FileRetainMetadata(BaseModel):
metadata: dict[str, Any] | None = Field(default=None, description="Additional metadata")
tags: list[str] | None = Field(default=None, description="Tags for this file")
timestamp: str | None = Field(default=None, description="ISO timestamp")
parser: str | list[str] | None = Field(
default=None,
description="Parser or ordered fallback chain for this file (overrides request-level parser). "
"E.g. 'iris' or ['iris', 'markitdown'].",
)
class FileRetainRequest(BaseModel):
@@ -490,21 +482,14 @@ class FileRetainRequest(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"example": {
"parser": "iris",
"files_metadata": [
{"document_id": "report_2024", "tags": ["quarterly"]},
{"context": "meeting notes", "parser": ["iris", "markitdown"]},
{"context": "meeting notes"},
],
}
}
)
parser: str | list[str] | None = Field(
default=None,
description="Default parser or ordered fallback chain for all files in this request. "
"E.g. 'markitdown' or ['iris', 'markitdown']. Falls back to server default if not set. "
"Per-file 'parser' in files_metadata takes precedence over this value.",
)
files_metadata: list[FileRetainMetadata] | None = Field(
default=None,
description="Metadata for each file (optional, must match number of files if provided)",
@@ -774,6 +759,14 @@ class ReflectResponse(BaseModel):
)
class BanksResponse(BaseModel):
"""Response model for banks list endpoint."""
model_config = ConfigDict(json_schema_extra={"example": {"banks": ["user123", "bank_alice", "bank_bob"]}})
banks: list[str]
class DispositionTraits(BaseModel):
"""Disposition traits that influence how memories are formed and interpreted."""
@@ -1206,30 +1199,6 @@ class DocumentResponse(BaseModel):
tags: list[str] = FieldWithDefault(list, description="Tags associated with this document")
class UpdateDocumentRequest(BaseModel):
"""Request model for updating a document's mutable fields."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"tags": ["team-a", "team-b"],
}
}
)
tags: list[str] | None = Field(
default=None,
description="New tags for the document and its memory units. "
"Triggers observation invalidation and re-consolidation.",
)
class UpdateDocumentResponse(BaseModel):
"""Response model for update document endpoint."""
success: bool = True
class DeleteDocumentResponse(BaseModel):
"""Response model for delete document endpoint."""
@@ -1336,6 +1305,15 @@ class BankStatsResponse(BaseModel):
# Mental Model models
class ObservationEvidenceResponse(BaseModel):
"""A single piece of evidence supporting an observation."""
memory_id: str = Field(description="ID of the memory unit this evidence comes from")
quote: str = Field(description="Exact quote from the memory supporting the observation")
relevance: str = Field(description="Brief explanation of how this quote supports the observation")
timestamp: str = Field(description="When the source memory was created (ISO format)")
# =========================================================================
# Directive Models
# =========================================================================
@@ -1558,24 +1536,6 @@ class CancelOperationResponse(BaseModel):
operation_id: str
class RetryOperationResponse(BaseModel):
"""Response model for retry operation endpoint."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"message": "Operation 550e8400-e29b-41d4-a716-446655440000 queued for retry",
"operation_id": "550e8400-e29b-41d4-a716-446655440000",
}
}
)
success: bool
message: str
operation_id: str
class ChildOperationStatus(BaseModel):
"""Status of a child operation (for batch operations)."""
@@ -1667,123 +1627,6 @@ class VersionResponse(BaseModel):
features: FeaturesInfo = Field(description="Enabled feature flags")
# =========================================================================
# Webhook Models
# =========================================================================
from hindsight_api.webhooks.models import WebhookHttpConfig
class CreateWebhookRequest(BaseModel):
"""Request model for registering a webhook."""
url: str = Field(description="HTTP(S) endpoint URL to deliver events to")
secret: str | None = Field(default=None, description="HMAC-SHA256 signing secret (optional)")
event_types: list[str] = Field(
default=["consolidation.completed"],
description="List of event types to deliver. Currently supported: 'consolidation.completed'",
)
enabled: bool = Field(default=True, description="Whether this webhook is active")
http_config: WebhookHttpConfig = Field(
default_factory=WebhookHttpConfig,
description="HTTP delivery configuration (method, timeout, headers, params)",
)
class WebhookResponse(BaseModel):
"""Response model for a webhook."""
id: str
bank_id: str | None
url: str
secret: str | None = Field(default=None, description="Signing secret (redacted in responses)")
event_types: list[str]
enabled: bool
http_config: WebhookHttpConfig = Field(default_factory=WebhookHttpConfig)
created_at: str | None = None
updated_at: str | None = None
class UpdateWebhookRequest(BaseModel):
"""Request model for updating a webhook. Only provided fields are updated."""
url: str | None = Field(default=None, description="HTTP(S) endpoint URL")
secret: str | None = Field(
default=None, description="HMAC-SHA256 signing secret. Omit to keep existing; send null to clear."
)
event_types: list[str] | None = Field(default=None, description="List of event types")
enabled: bool | None = Field(default=None, description="Whether this webhook is active")
http_config: WebhookHttpConfig | None = Field(default=None, description="HTTP delivery configuration")
class WebhookListResponse(BaseModel):
"""Response model for listing webhooks."""
items: list[WebhookResponse]
class WebhookDeliveryResponse(BaseModel):
"""Response model for a webhook delivery record."""
id: str
webhook_id: str | None
url: str
event_type: str
status: str
attempts: int
next_retry_at: str | None = None
last_error: str | None = None
last_response_status: int | None = None
last_response_body: str | None = None
last_attempt_at: str | None = None
created_at: str | None = None
updated_at: str | None = None
@classmethod
def from_async_operation_row(cls, row: dict) -> "WebhookDeliveryResponse":
import json as _json
raw = row["task_payload"]
if isinstance(raw, str):
task_payload = _json.loads(raw)
elif isinstance(raw, dict):
task_payload = raw
else:
task_payload = {}
raw_meta = row.get("result_metadata")
if isinstance(raw_meta, str):
result_metadata = _json.loads(raw_meta) if raw_meta else {}
elif isinstance(raw_meta, dict):
result_metadata = raw_meta
else:
result_metadata = {}
return cls(
id=str(row["operation_id"]),
webhook_id=task_payload.get("webhook_id"),
url=task_payload.get("url", ""),
event_type=task_payload.get("event_type", ""),
status=row["status"],
attempts=row["retry_count"] + 1,
next_retry_at=row["next_retry_at"],
last_error=row["error_message"],
last_response_status=result_metadata.get("last_status_code"),
last_response_body=result_metadata.get("last_response_body"),
last_attempt_at=result_metadata.get("last_attempt_at"),
created_at=row["created_at"],
updated_at=row["updated_at"],
)
class WebhookDeliveryListResponse(BaseModel):
"""Response model for listing webhook deliveries."""
items: list[WebhookDeliveryResponse]
next_cursor: str | None = None
def create_app(
memory: MemoryEngine,
initialize_memory: bool = True,
@@ -1883,6 +1726,7 @@ def create_app(
worker_id=worker_id,
executor=memory.execute_task,
poll_interval_ms=config.worker_poll_interval_ms,
max_retries=config.worker_max_retries,
schema=schema,
tenant_extension=memory._tenant_extension,
max_slots=config.worker_max_slots,
@@ -2179,7 +2023,7 @@ def _register_routes(app: FastAPI):
@app.get(
"/v1/default/banks/{bank_id}/memories/{memory_id}",
summary="Get memory unit",
description="Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.",
description="Get a single memory unit by ID with all its metadata including entities and tags.",
operation_id="get_memory",
tags=["Memory"],
)
@@ -2209,39 +2053,6 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in /v1/default/banks/{bank_id}/memories/{memory_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/memories/{memory_id}/history",
summary="Get observation history",
description="Get the full history of an observation, with each change's source facts resolved to their text.",
operation_id="get_observation_history",
tags=["Memory"],
)
async def api_get_observation_history(
bank_id: str,
memory_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Get the history of a single observation by ID."""
try:
data = await app.state.memory.get_observation_history(
bank_id=bank_id,
memory_id=memory_id,
request_context=request_context,
)
if data is None:
raise HTTPException(status_code=404, detail=f"Memory unit '{memory_id}' not found")
return data
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in /v1/default/banks/{bank_id}/memories/{memory_id}/history: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/memories/recall",
response_model=RecallResponse,
@@ -2297,9 +2108,6 @@ def _register_routes(app: FastAPI):
# Determine source facts inclusion settings
include_source_facts = request.include.source_facts is not None
max_source_facts_tokens = request.include.source_facts.max_tokens if include_source_facts else 4096
max_source_facts_tokens_per_observation = (
request.include.source_facts.max_tokens_per_observation if include_source_facts else -1
)
pre_recall = time.time() - handler_start
# Run recall with tracing (record metrics)
@@ -2321,7 +2129,6 @@ def _register_routes(app: FastAPI):
max_chunk_tokens=max_chunk_tokens,
include_source_facts=include_source_facts,
max_source_facts_tokens=max_source_facts_tokens,
max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation,
request_context=request_context,
tags=request.tags,
tags_match=request.tags_match,
@@ -2790,41 +2597,6 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models/{mental_model_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history",
summary="Get mental model history",
description="Get the refresh history of a mental model, showing content changes over time.",
operation_id="get_mental_model_history",
tags=["Mental Models"],
)
async def api_get_mental_model_history(
bank_id: str,
mental_model_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Get the refresh history of a mental model."""
try:
data = await app.state.memory.get_mental_model_history(
bank_id=bank_id,
mental_model_id=mental_model_id,
request_context=request_context,
)
if data is None:
raise HTTPException(status_code=404, detail=f"Mental model '{mental_model_id}' not found")
return data
except (AuthenticationError, HTTPException):
raise
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(
f"Error in GET /v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history: {error_detail}"
)
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/mental-models",
response_model=CreateMentalModelResponse,
@@ -3346,55 +3118,6 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in /v1/default/chunks/{chunk_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.patch(
"/v1/default/banks/{bank_id}/documents/{document_id:path}",
response_model=UpdateDocumentResponse,
summary="Update document",
description="Update mutable fields on a document without re-processing its content.\n\n"
"**Tags** (`tags`): Propagated to all associated memory units. Observations derived from "
"those units are invalidated and queued for re-consolidation under the new tags. "
"Co-source memories from other documents that shared those observations are also reset.\n\n"
"At least one field must be provided.",
operation_id="update_document",
tags=["Documents"],
)
async def api_update_document(
bank_id: str,
document_id: str,
body: UpdateDocumentRequest,
request_context: RequestContext = Depends(get_request_context),
):
"""
Update mutable fields on a document without re-processing its content.
Args:
bank_id: Memory Bank ID (from path)
document_id: Document ID (from path)
body: Fields to update (tags, metadata, context)
"""
if body.tags is None:
raise HTTPException(status_code=422, detail="At least one field (tags) must be provided")
try:
result = await app.state.memory.update_document(
document_id,
bank_id,
tags=body.tags,
request_context=request_context,
)
if not result:
raise HTTPException(status_code=404, detail="Document not found")
return UpdateDocumentResponse(success=True)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/documents/{document_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/default/banks/{bank_id}/documents/{document_id:path}",
response_model=DeleteDocumentResponse,
@@ -3445,17 +3168,13 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/operations",
response_model=OperationsListResponse,
summary="List async operations",
description="Get a list of async operations for a specific agent, with optional filtering by status and operation type. Results are sorted by most recent first.",
description="Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.",
operation_id="list_operations",
tags=["Operations"],
)
async def api_list_operations(
bank_id: str,
status: str | None = Query(default=None, description="Filter by status: pending, completed, or failed"),
type: str | None = Query(
default=None,
description="Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery",
),
limit: int = Query(default=20, ge=1, le=100, description="Maximum number of operations to return"),
offset: int = Query(default=0, ge=0, description="Number of operations to skip"),
request_context: RequestContext = Depends(get_request_context),
@@ -3463,7 +3182,7 @@ def _register_routes(app: FastAPI):
"""List async operations for a memory bank with optional filtering and pagination."""
try:
result = await app.state.memory.list_operations(
bank_id, status=status, task_type=type, limit=limit, offset=offset, request_context=request_context
bank_id, status=status, limit=limit, offset=offset, request_context=request_context
)
return OperationsListResponse(
bank_id=bank_id,
@@ -3550,39 +3269,6 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in /v1/default/banks/{bank_id}/operations/{operation_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/operations/{operation_id}/retry",
response_model=RetryOperationResponse,
summary="Retry a failed async operation",
description="Re-queue a failed async operation so the worker picks it up again",
operation_id="retry_operation",
tags=["Operations"],
)
async def api_retry_operation(
bank_id: str, operation_id: str, request_context: RequestContext = Depends(get_request_context)
):
"""Retry a failed async operation."""
try:
try:
uuid.UUID(operation_id)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid operation_id format: {operation_id}")
result = await app.state.memory.retry_operation(bank_id, operation_id, request_context=request_context)
return RetryOperationResponse(**result)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in POST /v1/default/banks/{bank_id}/operations/{operation_id}/retry: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/profile",
response_model=BankProfileResponse,
@@ -4070,318 +3756,6 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in POST /v1/default/banks/{bank_id}/consolidate: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
# =========================================================================
# Webhook Endpoints
# =========================================================================
@app.post(
"/v1/default/banks/{bank_id}/webhooks",
response_model=WebhookResponse,
summary="Register webhook",
description="Register a webhook endpoint to receive event notifications for this bank.",
operation_id="create_webhook",
tags=["Webhooks"],
status_code=201,
)
async def api_create_webhook(
bank_id: str,
request: CreateWebhookRequest,
request_context: RequestContext = Depends(get_request_context),
):
"""Register a webhook for a bank."""
try:
pool = await app.state.memory._get_pool()
from hindsight_api.engine.memory_engine import fq_table
webhook_id = uuid.uuid4()
now = datetime.utcnow().isoformat() + "Z"
row = await pool.fetchrow(
f"""
INSERT INTO {fq_table("webhooks")}
(id, bank_id, url, secret, event_types, enabled, http_config, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, NOW(), NOW())
RETURNING id, bank_id, url, secret, event_types, enabled,
http_config::text, created_at::text, updated_at::text
""",
webhook_id,
bank_id,
request.url,
request.secret,
request.event_types,
request.enabled,
request.http_config.model_dump_json(),
)
return WebhookResponse(
id=str(row["id"]),
bank_id=row["bank_id"],
url=row["url"],
secret=None, # Never return secret in responses
event_types=list(row["event_types"]) if row["event_types"] else [],
enabled=row["enabled"],
http_config=WebhookHttpConfig.model_validate_json(row["http_config"])
if row["http_config"]
else WebhookHttpConfig(),
created_at=row["created_at"],
updated_at=row["updated_at"],
)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in POST /v1/default/banks/{bank_id}/webhooks: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/webhooks",
response_model=WebhookListResponse,
summary="List webhooks",
description="List all webhooks registered for a bank.",
operation_id="list_webhooks",
tags=["Webhooks"],
)
async def api_list_webhooks(
bank_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""List webhooks for a bank."""
try:
pool = await app.state.memory._get_pool()
from hindsight_api.engine.memory_engine import fq_table
rows = await pool.fetch(
f"""
SELECT id, bank_id, url, secret, event_types, enabled,
http_config::text, created_at::text, updated_at::text
FROM {fq_table("webhooks")}
WHERE bank_id = $1
ORDER BY created_at
""",
bank_id,
)
return WebhookListResponse(
items=[
WebhookResponse(
id=str(row["id"]),
bank_id=row["bank_id"],
url=row["url"],
secret=None, # Never return secret in responses
event_types=list(row["event_types"]) if row["event_types"] else [],
enabled=row["enabled"],
http_config=WebhookHttpConfig.model_validate_json(row["http_config"])
if row["http_config"]
else WebhookHttpConfig(),
created_at=row["created_at"],
updated_at=row["updated_at"],
)
for row in rows
]
)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/webhooks: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.delete(
"/v1/default/banks/{bank_id}/webhooks/{webhook_id}",
response_model=DeleteResponse,
summary="Delete webhook",
description="Remove a registered webhook.",
operation_id="delete_webhook",
tags=["Webhooks"],
)
async def api_delete_webhook(
bank_id: str,
webhook_id: str,
request_context: RequestContext = Depends(get_request_context),
):
"""Delete a webhook."""
try:
pool = await app.state.memory._get_pool()
from hindsight_api.engine.memory_engine import fq_table
result = await pool.execute(
f"DELETE FROM {fq_table('webhooks')} WHERE id = $1 AND bank_id = $2",
uuid.UUID(webhook_id),
bank_id,
)
deleted = int(result.split()[-1]) if result else 0
if deleted == 0:
raise HTTPException(status_code=404, detail="Webhook not found")
return DeleteResponse(success=True)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/webhooks/{webhook_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.patch(
"/v1/default/banks/{bank_id}/webhooks/{webhook_id}",
response_model=WebhookResponse,
summary="Update webhook",
description="Update one or more fields of a registered webhook. Only provided fields are changed.",
operation_id="update_webhook",
tags=["Webhooks"],
)
async def api_update_webhook(
bank_id: str,
webhook_id: str,
request: UpdateWebhookRequest,
request_context: RequestContext = Depends(get_request_context),
):
"""Update a webhook's fields (PATCH semantics — only sent fields are updated)."""
try:
pool = await app.state.memory._get_pool()
from hindsight_api.engine.memory_engine import fq_table
set_clauses: list[str] = []
params: list = [uuid.UUID(webhook_id), bank_id]
fields = request.model_fields_set
if "url" in fields:
params.append(request.url)
set_clauses.append(f"url = ${len(params)}")
if "secret" in fields:
params.append(request.secret)
set_clauses.append(f"secret = ${len(params)}")
if "event_types" in fields:
params.append(request.event_types)
set_clauses.append(f"event_types = ${len(params)}")
if "enabled" in fields:
params.append(request.enabled)
set_clauses.append(f"enabled = ${len(params)}")
if "http_config" in fields:
params.append(request.http_config.model_dump_json())
set_clauses.append(f"http_config = ${len(params)}::jsonb")
if not set_clauses:
raise HTTPException(status_code=422, detail="No fields provided to update")
set_clauses.append("updated_at = NOW()")
row = await pool.fetchrow(
f"""
UPDATE {fq_table("webhooks")}
SET {", ".join(set_clauses)}
WHERE id = $1 AND bank_id = $2
RETURNING id, bank_id, url, secret, event_types, enabled,
http_config::text, created_at::text, updated_at::text
""",
*params,
)
if not row:
raise HTTPException(status_code=404, detail="Webhook not found")
return WebhookResponse(
id=str(row["id"]),
bank_id=row["bank_id"],
url=row["url"],
secret=None,
event_types=list(row["event_types"]) if row["event_types"] else [],
enabled=row["enabled"],
http_config=WebhookHttpConfig.model_validate_json(row["http_config"])
if row["http_config"]
else WebhookHttpConfig(),
created_at=row["created_at"],
updated_at=row["updated_at"],
)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/webhooks/{webhook_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/webhooks/{webhook_id}/deliveries",
response_model=WebhookDeliveryListResponse,
summary="List webhook deliveries",
description="Inspect delivery history for a webhook (useful for debugging).",
operation_id="list_webhook_deliveries",
tags=["Webhooks"],
)
async def api_list_webhook_deliveries(
bank_id: str,
webhook_id: str,
limit: int = Query(default=50, le=200, description="Maximum number of deliveries to return"),
cursor: str | None = Query(default=None, description="Pagination cursor (created_at of last item)"),
request_context: RequestContext = Depends(get_request_context),
):
"""List deliveries for a specific webhook, newest first. Use next_cursor for pagination."""
try:
pool = await app.state.memory._get_pool()
from hindsight_api.engine.memory_engine import fq_table
# Verify webhook belongs to this bank
webhook_row = await pool.fetchrow(
f"SELECT id FROM {fq_table('webhooks')} WHERE id = $1 AND bank_id = $2",
uuid.UUID(webhook_id),
bank_id,
)
if not webhook_row:
raise HTTPException(status_code=404, detail="Webhook not found")
# Fetch limit+1 to detect if there's a next page
fetch_limit = limit + 1
if cursor:
rows = await pool.fetch(
f"""
SELECT operation_id, status, retry_count, next_retry_at::text,
error_message, task_payload, result_metadata::text, created_at::text, updated_at::text
FROM {fq_table("async_operations")}
WHERE operation_type = 'webhook_delivery'
AND bank_id = $1
AND task_payload->>'webhook_id' = $2
AND created_at < $3::timestamptz
ORDER BY created_at DESC
LIMIT $4
""",
bank_id,
webhook_id,
cursor,
fetch_limit,
)
else:
rows = await pool.fetch(
f"""
SELECT operation_id, status, retry_count, next_retry_at::text,
error_message, task_payload, result_metadata::text, created_at::text, updated_at::text
FROM {fq_table("async_operations")}
WHERE operation_type = 'webhook_delivery'
AND bank_id = $1
AND task_payload->>'webhook_id' = $2
ORDER BY created_at DESC
LIMIT $3
""",
bank_id,
webhook_id,
fetch_limit,
)
has_more = len(rows) > limit
page = rows[:limit]
next_cursor = page[-1]["created_at"] if has_more and page else None
return WebhookDeliveryListResponse(
items=[WebhookDeliveryResponse.from_async_operation_row(dict(row)) for row in page],
next_cursor=next_cursor,
)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(f"Error in GET /v1/default/banks/{bank_id}/webhooks/{webhook_id}/deliveries: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/memories",
response_model=RetainResponse,
@@ -4474,12 +3848,6 @@ def _register_routes(app: FastAPI):
document_tags=request.document_tags,
request_context=request_context,
return_usage=True,
outbox_callback=app.state.memory._build_retain_outbox_callback(
bank_id=bank_id,
contents=contents,
operation_id=None,
schema=_current_schema.get(),
),
)
return RetainResponse.model_validate(
@@ -4529,14 +3897,8 @@ def _register_routes(app: FastAPI):
"Use the operations endpoint to monitor progress.\n\n"
"**Request format:** multipart/form-data with:\n"
"- `files`: One or more files to upload\n"
"- `request`: JSON string with FileRetainRequest model\n\n"
"**Parser selection:**\n"
"- Set `parser` in the request body to override the server default for all files.\n"
"- Set `parser` inside a `files_metadata` entry for per-file control.\n"
"- Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — "
"each parser is tried in sequence until one succeeds.\n"
"- Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified.\n"
"- Only parsers enabled on the server may be requested; others return HTTP 400.",
"- `request`: JSON string with FileRetainRequest model (files_metadata)\n\n"
"**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).",
operation_id="file_retain",
tags=["Files"],
)
@@ -4582,39 +3944,20 @@ def _register_routes(app: FastAPI):
detail=f"files_metadata count ({len(request_data.files_metadata)}) must match files count ({len(files)})",
)
# Resolve the registered parser names for allowlist validation
registered_parsers = app.state.memory._parser_registry.list_parsers()
allowlist = config.file_parser_allowlist if config.file_parser_allowlist is not None else registered_parsers
def _resolve_parser(raw: str | list[str] | None) -> list[str]:
"""Normalize parser value to a non-empty list of names."""
if raw is None:
return config.file_parser
return [raw] if isinstance(raw, str) else list(raw)
def _validate_parsers(parsers: list[str], context: str) -> None:
"""Raise HTTP 400 if any parser name is not in the allowlist."""
disallowed = [p for p in parsers if p not in allowlist]
if disallowed:
raise HTTPException(
status_code=400,
detail=f"Parser(s) not available ({context}): {disallowed}. Available: {allowlist}",
)
# Validate request-level parser early (before reading files)
if request_data.parser is not None:
_validate_parsers(_resolve_parser(request_data.parser), "request-level parser")
# Prepare file items and calculate total batch size
import io
file_items = []
total_batch_size = 0
for i, file in enumerate(files):
# Read file content to check size
file_content = await file.read()
total_batch_size += len(file_content)
size = len(file_content)
total_batch_size += size
# Create a temporary file-like object from the bytes
import io
file_obj = io.BytesIO(file_content)
# Create a mock UploadFile with the necessary attributes
class FileWrapper:
@@ -4622,6 +3965,7 @@ def _register_routes(app: FastAPI):
self._content = content
self.filename = filename
self.content_type = content_type
self._buffer = io.BytesIO(content)
async def read(self):
return self._content
@@ -4632,12 +3976,6 @@ def _register_routes(app: FastAPI):
file_meta = request_data.files_metadata[i] if request_data.files_metadata else FileRetainMetadata()
doc_id = file_meta.document_id or f"file_{uuid.uuid4()}"
# Resolve and validate per-file parser chain
# Priority: per-file > request-level > server default
raw_parser = file_meta.parser if file_meta.parser is not None else request_data.parser
parser_chain = _resolve_parser(raw_parser)
_validate_parsers(parser_chain, f"file '{file.filename}'")
item = {
"file": wrapped_file,
"document_id": doc_id,
@@ -4645,7 +3983,6 @@ def _register_routes(app: FastAPI):
"metadata": file_meta.metadata or {},
"tags": file_meta.tags or [],
"timestamp": file_meta.timestamp,
"parser": parser_chain,
}
file_items.append(item)
@@ -4660,6 +3997,7 @@ def _register_routes(app: FastAPI):
result = await app.state.memory.submit_async_file_retain(
bank_id=bank_id,
file_items=file_items,
parser=config.file_parser,
document_tags=None,
request_context=request_context,
)
+3 -84
View File
@@ -280,7 +280,6 @@ ENV_FILE_STORAGE_AZURE_CONTAINER = "HINDSIGHT_API_FILE_STORAGE_AZURE_CONTAINER"
ENV_FILE_STORAGE_AZURE_ACCOUNT_NAME = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_NAME"
ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_KEY"
ENV_FILE_PARSER = "HINDSIGHT_API_FILE_PARSER"
ENV_FILE_PARSER_ALLOWLIST = "HINDSIGHT_API_FILE_PARSER_ALLOWLIST"
ENV_FILE_PARSER_IRIS_TOKEN = "HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN"
ENV_FILE_PARSER_IRIS_ORG_ID = "HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID"
ENV_FILE_CONVERSION_MAX_BATCH_SIZE_MB = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE_MB"
@@ -293,19 +292,7 @@ ENV_ENABLE_OBSERVATIONS = "HINDSIGHT_API_ENABLE_OBSERVATIONS"
ENV_CONSOLIDATION_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE"
ENV_CONSOLIDATION_LLM_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_LLM_BATCH_SIZE"
ENV_CONSOLIDATION_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS"
ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS"
ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
"HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION"
)
ENV_OBSERVATIONS_MISSION = "HINDSIGHT_API_OBSERVATIONS_MISSION"
ENV_ENABLE_OBSERVATION_HISTORY = "HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY"
ENV_ENABLE_MENTAL_MODEL_HISTORY = "HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY"
# Webhook configuration (global, static - server-level only)
ENV_WEBHOOK_URL = "HINDSIGHT_API_WEBHOOK_URL"
ENV_WEBHOOK_SECRET = "HINDSIGHT_API_WEBHOOK_SECRET"
ENV_WEBHOOK_EVENT_TYPES = "HINDSIGHT_API_WEBHOOK_EVENT_TYPES"
ENV_WEBHOOK_DELIVERY_POLL_INTERVAL_SECONDS = "HINDSIGHT_API_WEBHOOK_DELIVERY_POLL_INTERVAL_SECONDS"
# Optimization flags
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
@@ -442,8 +429,7 @@ DEFAULT_RETAIN_BATCH_POLL_INTERVAL_SECONDS = 60 # Batch API polling interval in
# File storage defaults
DEFAULT_FILE_STORAGE_TYPE = "native" # PostgreSQL BYTEA storage
DEFAULT_FILE_PARSER = "markitdown" # Default parser fallback chain (comma-separated, e.g. "iris,markitdown")
DEFAULT_FILE_PARSER_ALLOWLIST = None # Allowlist of parsers clients may request (None = all registered parsers)
DEFAULT_FILE_PARSER = "markitdown" # File parser to use (markitdown is the only supported parser)
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB = 100 # Max total batch size in MB (all files combined)
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE = 10 # Max files per batch upload
DEFAULT_ENABLE_FILE_UPLOAD_API = True # Enable file upload endpoint
@@ -451,17 +437,9 @@ DEFAULT_FILE_DELETE_AFTER_RETAIN = True # Delete file bytes after retain (saves
# Observations defaults (consolidated knowledge from facts)
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
DEFAULT_ENABLE_OBSERVATION_HISTORY = True # Observation history tracking enabled by default
DEFAULT_ENABLE_MENTAL_MODEL_HISTORY = True # Mental model history tracking enabled by default
DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization)
DEFAULT_CONSOLIDATION_LLM_BATCH_SIZE = 8 # Facts per LLM call (1 = no batching; >1 = batch mode)
DEFAULT_CONSOLIDATION_MAX_TOKENS = 512 # Max tokens for recall when finding related observations
DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS = (
-1
) # Total token budget for source facts in consolidation recall (-1 = unlimited)
DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = (
256 # Max tokens of source facts per observation in consolidation prompt (-1 = unlimited)
)
DEFAULT_OBSERVATIONS_MISSION = None # Declarative spec of what observations are for this bank
# Database migrations
@@ -519,12 +497,6 @@ Use this tool PROACTIVELY to:
# Default embedding dimension (used by initial migration, adjusted at runtime)
EMBEDDING_DIMENSION = DEFAULT_EMBEDDING_DIMENSION
# Webhook configuration defaults
DEFAULT_WEBHOOK_URL = None # None = no global webhook configured
DEFAULT_WEBHOOK_SECRET = None # None = no signing
DEFAULT_WEBHOOK_EVENT_TYPES = "consolidation.completed" # Comma-separated; default = all supported events
DEFAULT_WEBHOOK_DELIVERY_POLL_INTERVAL_SECONDS = 30 # How often to poll for pending deliveries
class JsonFormatter(logging.Formatter):
"""JSON formatter for structured logging.
@@ -556,11 +528,6 @@ class JsonFormatter(logging.Formatter):
return json.dumps(log_entry)
def _parse_str_list(value: str) -> list[str]:
"""Parse a comma-separated string into a non-empty list of stripped tokens."""
return [v.strip() for v in value.split(",") if v.strip()]
def _validate_extraction_mode(mode: str) -> str:
"""Validate and normalize extraction mode."""
mode_lower = mode.lower()
@@ -720,8 +687,7 @@ class HindsightConfig:
file_storage_azure_container: str | None # Azure container name (required for azure storage)
file_storage_azure_account_name: str | None # Azure storage account name
file_storage_azure_account_key: str | None # Azure storage account key
file_parser: list[str] # Ordered fallback chain of parsers (e.g. ["iris", "markitdown"])
file_parser_allowlist: list[str] | None # Parsers clients may request (None = all registered)
file_parser: str # File parser to use (e.g., "markitdown", "iris")
file_parser_iris_token: str | None # Vectorize API token for iris parser (VECTORIZE_TOKEN)
file_parser_iris_org_id: str | None # Vectorize org ID for iris parser (VECTORIZE_ORG_ID)
file_conversion_max_batch_size_mb: int # Max total batch size in MB (all files combined)
@@ -731,13 +697,9 @@ class HindsightConfig:
# Observations settings (consolidated knowledge from facts)
enable_observations: bool
enable_observation_history: bool
enable_mental_model_history: bool
consolidation_batch_size: int
consolidation_llm_batch_size: int
consolidation_max_tokens: int
consolidation_source_facts_max_tokens: int
consolidation_source_facts_max_tokens_per_observation: int
observations_mission: str | None
# Entity labels (controlled vocabulary of key:value classification labels extracted at retain time)
@@ -788,12 +750,6 @@ class HindsightConfig:
otel_service_name: str
otel_deployment_environment: str
# Webhook configuration (static - server-level only, not per-bank)
webhook_url: str | None # Global webhook URL (None = disabled)
webhook_secret: str | None # HMAC signing secret (None = unsigned)
webhook_event_types: list[str] # Event types to deliver globally
webhook_delivery_poll_interval_seconds: int # How often the delivery worker polls
# Class-level sets for configuration categorization
# CREDENTIAL_FIELDS: Never exposed via API, never configurable per-tenant/bank
@@ -838,9 +794,6 @@ class HindsightConfig:
"entities_allow_free_form",
# Consolidation settings
"enable_observations",
"consolidation_llm_batch_size",
"consolidation_source_facts_max_tokens",
"consolidation_source_facts_max_tokens_per_observation",
"observations_mission",
# Reflect settings
"reflect_mission",
@@ -1165,10 +1118,7 @@ class HindsightConfig:
file_storage_azure_container=os.getenv(ENV_FILE_STORAGE_AZURE_CONTAINER) or None,
file_storage_azure_account_name=os.getenv(ENV_FILE_STORAGE_AZURE_ACCOUNT_NAME) or None,
file_storage_azure_account_key=os.getenv(ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY) or None,
file_parser=_parse_str_list(os.getenv(ENV_FILE_PARSER, DEFAULT_FILE_PARSER)),
file_parser_allowlist=_parse_str_list(os.getenv(ENV_FILE_PARSER_ALLOWLIST))
if os.getenv(ENV_FILE_PARSER_ALLOWLIST)
else None,
file_parser=os.getenv(ENV_FILE_PARSER, DEFAULT_FILE_PARSER),
file_parser_iris_token=os.getenv(ENV_FILE_PARSER_IRIS_TOKEN) or None,
file_parser_iris_org_id=os.getenv(ENV_FILE_PARSER_IRIS_ORG_ID) or None,
file_conversion_max_batch_size_mb=int(
@@ -1185,14 +1135,6 @@ class HindsightConfig:
== "true",
# Observations settings (consolidated knowledge from facts)
enable_observations=os.getenv(ENV_ENABLE_OBSERVATIONS, str(DEFAULT_ENABLE_OBSERVATIONS)).lower() == "true",
enable_observation_history=os.getenv(
ENV_ENABLE_OBSERVATION_HISTORY, str(DEFAULT_ENABLE_OBSERVATION_HISTORY)
).lower()
== "true",
enable_mental_model_history=os.getenv(
ENV_ENABLE_MENTAL_MODEL_HISTORY, str(DEFAULT_ENABLE_MENTAL_MODEL_HISTORY)
).lower()
== "true",
consolidation_batch_size=int(
os.getenv(ENV_CONSOLIDATION_BATCH_SIZE, str(DEFAULT_CONSOLIDATION_BATCH_SIZE))
),
@@ -1202,15 +1144,6 @@ class HindsightConfig:
consolidation_max_tokens=int(
os.getenv(ENV_CONSOLIDATION_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_MAX_TOKENS))
),
consolidation_source_facts_max_tokens=int(
os.getenv(ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS))
),
consolidation_source_facts_max_tokens_per_observation=int(
os.getenv(
ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION,
str(DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION),
)
),
observations_mission=os.getenv(ENV_OBSERVATIONS_MISSION) or DEFAULT_OBSERVATIONS_MISSION,
entity_labels=None,
entities_allow_free_form=True,
@@ -1254,20 +1187,6 @@ class HindsightConfig:
otel_exporter_otlp_headers=os.getenv(ENV_OTEL_EXPORTER_OTLP_HEADERS) or None,
otel_service_name=os.getenv(ENV_OTEL_SERVICE_NAME, DEFAULT_OTEL_SERVICE_NAME),
otel_deployment_environment=os.getenv(ENV_OTEL_DEPLOYMENT_ENVIRONMENT, DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT),
# Webhook configuration (static, server-level only)
webhook_url=os.getenv(ENV_WEBHOOK_URL) or DEFAULT_WEBHOOK_URL,
webhook_secret=os.getenv(ENV_WEBHOOK_SECRET) or DEFAULT_WEBHOOK_SECRET,
webhook_event_types=[
t.strip()
for t in os.getenv(ENV_WEBHOOK_EVENT_TYPES, DEFAULT_WEBHOOK_EVENT_TYPES).split(",")
if t.strip()
],
webhook_delivery_poll_interval_seconds=int(
os.getenv(
ENV_WEBHOOK_DELIVERY_POLL_INTERVAL_SECONDS,
str(DEFAULT_WEBHOOK_DELIVERY_POLL_INTERVAL_SECONDS),
)
),
)
config.validate()
return config
@@ -9,10 +9,6 @@ Observations are stored in memory_units with fact_type='observation' and include
- proof_count: Number of supporting memories
- source_memory_ids: Array of memory UUIDs that contribute to this observation
- history: JSONB tracking changes over time
NOTE: Observations are distinct from mental models (pinned reflections).
- Observations: auto-generated bottom-up by this engine from raw facts (memory_units table, fact_type='observation')
- Mental models: user-defined queries stored in the mental_models table, refreshed on demand via reflect
"""
import json
@@ -24,10 +20,9 @@ from datetime import datetime, timezone
from itertools import combinations
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, field_validator
from pydantic import BaseModel
from ...config import get_config
from ..llm_wrapper import sanitize_llm_output
from ..memory_engine import fq_table
from ..retain import embedding_utils
from .prompts import build_batch_consolidation_prompt
@@ -46,22 +41,12 @@ class _CreateAction(BaseModel):
text: str
source_fact_ids: list[str] # memory UUIDs from the NEW FACTS list
@field_validator("text", mode="before")
@classmethod
def sanitize_text(cls, v: str) -> str:
return sanitize_llm_output(v) or ""
class _UpdateAction(BaseModel):
text: str
observation_id: str # UUID of the existing observation to update
source_fact_ids: list[str] # memory UUIDs from the NEW FACTS list
@field_validator("text", mode="before")
@classmethod
def sanitize_text(cls, v: str) -> str:
return sanitize_llm_output(v) or ""
class _DeleteAction(BaseModel):
observation_id: str # UUID of the observation to remove
@@ -82,42 +67,6 @@ class _BatchLLMResult:
prompt_chars: int = 0
@dataclass
class _SourceAggregation:
"""Fields inherited by an observation from its source memories."""
event_date: datetime | None
occurred_start: datetime | None
occurred_end: datetime | None
mentioned_at: datetime | None
tags: list[str]
def _aggregate_source_fields(source_mems: list[dict[str, Any]], tags: list[str] | None = None) -> _SourceAggregation:
"""Compute the observation fields inherited from a set of source memories.
Temporal aggregation rules:
- ``event_date`` — earliest across sources (min)
- ``occurred_start`` — earliest across sources (min)
- ``occurred_end`` — latest across sources (max)
- ``mentioned_at`` — latest across sources (max)
Fields remain ``None`` when no source memory carries that information, so
observations are never stamped with an artificial timestamp.
``tags`` defaults to those of the first source memory when not explicitly
provided (all memories in a consolidation batch share the same tag set).
"""
effective_tags = tags if tags is not None else (source_mems[0].get("tags") or [] if source_mems else [])
return _SourceAggregation(
event_date=_min_date(m.get("event_date") for m in source_mems),
occurred_start=_min_date(m.get("occurred_start") for m in source_mems),
occurred_end=_max_date(m.get("occurred_end") for m in source_mems),
mentioned_at=_max_date(m.get("mentioned_at") for m in source_mems),
tags=effective_tags,
)
class ConsolidationPerfLog:
"""Performance logging for consolidation operations."""
@@ -231,12 +180,11 @@ async def run_consolidation_job(
perf.log(f"[1] Found {total_count} pending memories to consolidate")
# Process each memory with individual commits for crash recovery
stats: dict[str, int] = {
stats = {
"memories_processed": 0,
"observations_created": 0,
"observations_updated": 0,
"observations_merged": 0,
"observations_deleted": 0,
"actions_executed": 0,
"skipped": 0,
}
@@ -325,12 +273,11 @@ async def run_consolidation_job(
# explicit list[list[str]]
obs_tags_list = _obs_parsed
batch_deleted: int = 0
if obs_tags_list:
# Multi-pass: run one observation consolidation pass per tag set
results = []
for obs_tags in obs_tags_list:
pass_results, pass_deleted = await _process_memory_batch(
pass_results = await _process_memory_batch(
conn=conn,
memory_engine=memory_engine,
llm_config=llm_config,
@@ -341,7 +288,6 @@ async def run_consolidation_job(
config=config,
obs_tags_override=obs_tags,
)
batch_deleted += pass_deleted
# Merge results: prefer non-skipped actions
if not results:
results = pass_results
@@ -369,7 +315,7 @@ async def run_consolidation_job(
}
else:
# Normal single pass using the memory's own tags
results, batch_deleted = await _process_memory_batch(
results = await _process_memory_batch(
conn=conn,
memory_engine=memory_engine,
llm_config=llm_config,
@@ -379,7 +325,6 @@ async def run_consolidation_job(
perf=perf,
config=config,
)
stats["observations_deleted"] += batch_deleted
await conn.executemany(
f"UPDATE {fq_table('memory_units')} SET consolidated_at = NOW() WHERE id = $1",
@@ -576,7 +521,7 @@ async def _process_memory_batch(
perf: ConsolidationPerfLog | None = None,
config: Any = None,
obs_tags_override: list[str] | None = None,
) -> tuple[list[dict[str, Any]], int]:
) -> list[dict[str, Any]]:
"""
Process a batch of memories in a single LLM call.
@@ -667,18 +612,17 @@ async def _process_memory_batch(
source_mems = [mem_by_id[fid] for fid in create.source_fact_ids if fid in mem_by_id]
if not source_mems:
continue
agg = _aggregate_source_fields(source_mems, tags=fact_tags)
await _execute_create_action(
conn=conn,
memory_engine=memory_engine,
bank_id=bank_id,
source_memory_ids=[m["id"] for m in source_mems],
text=create.text,
source_fact_tags=agg.tags,
event_date=agg.event_date,
occurred_start=agg.occurred_start,
occurred_end=agg.occurred_end,
mentioned_at=agg.mentioned_at,
source_fact_tags=fact_tags,
event_date=_min_date(m.get("event_date") for m in source_mems),
occurred_start=_min_date(m.get("occurred_start") for m in source_mems),
occurred_end=_max_date(m.get("occurred_end") for m in source_mems),
mentioned_at=_max_date(m.get("mentioned_at") for m in source_mems),
perf=perf,
)
for m in source_mems:
@@ -695,7 +639,6 @@ async def _process_memory_batch(
f"not in any source fact's recall"
)
continue
agg = _aggregate_source_fields(source_mems, tags=fact_tags)
await _execute_update_action(
conn=conn,
memory_engine=memory_engine,
@@ -704,16 +647,15 @@ async def _process_memory_batch(
observation_id=update.observation_id,
new_text=update.text,
observations=union_observations,
source_fact_tags=agg.tags,
source_occurred_start=agg.occurred_start,
source_occurred_end=agg.occurred_end,
source_mentioned_at=agg.mentioned_at,
source_fact_tags=fact_tags,
source_occurred_start=_min_date(m.get("occurred_start") for m in source_mems),
source_occurred_end=_max_date(m.get("occurred_end") for m in source_mems),
source_mentioned_at=_max_date(m.get("mentioned_at") for m in source_mems),
perf=perf,
)
for m in source_mems:
per_memory_updated.add(str(m["id"]))
deleted_count = 0
for delete in llm_result.deletes:
# Security: the observation must be present in the unioned recall
if not any(str(obs.id) == delete.observation_id for obs in union_observations):
@@ -722,7 +664,6 @@ async def _process_memory_batch(
)
continue
await _execute_delete_action(conn=conn, bank_id=bank_id, observation_id=delete.observation_id)
deleted_count += 1
# Build per-memory result dicts for the stats tracker in the outer loop
results: list[dict[str, Any]] = []
@@ -739,7 +680,7 @@ async def _process_memory_batch(
else:
results.append({"action": "skipped", "reason": "no_durable_knowledge"})
return results, deleted_count
return results
def _min_date(dates: "Any") -> "datetime | None":
@@ -777,17 +718,13 @@ async def _execute_update_action(
logger.debug(f"Update skipped: observation {observation_id} not found in recall results")
return
from ...config import get_config
history_entry = {
"previous_text": model.text,
"previous_tags": list(model.tags or []),
"previous_occurred_start": model.occurred_start,
"previous_occurred_end": model.occurred_end,
"previous_mentioned_at": model.mentioned_at,
"changed_at": datetime.now(timezone.utc).isoformat(),
"new_source_memory_ids": [str(mid) for mid in source_memory_ids],
}
history = [
{
"previous_text": model.text,
"changed_at": datetime.now(timezone.utc).isoformat(),
"source_memory_ids": [str(mid) for mid in source_memory_ids],
}
]
source_ids = list(model.source_fact_ids or []) + source_memory_ids
@@ -802,18 +739,13 @@ async def _execute_update_action(
if perf:
perf.record_timing("embedding", time.time() - t0)
config = get_config()
history_clause = (
"history = COALESCE(history, '[]'::jsonb) || $3::jsonb," if config.enable_observation_history else ""
)
t0 = time.time()
await conn.execute(
f"""
UPDATE {fq_table("memory_units")}
SET text = $1,
embedding = $2::vector,
{history_clause}
history = $3,
source_memory_ids = $4,
proof_count = $5,
tags = $10,
@@ -825,7 +757,7 @@ async def _execute_update_action(
""",
new_text,
embedding_str,
json.dumps([history_entry]),
json.dumps(history),
source_ids,
len(source_ids),
uuid.UUID(observation_id),
@@ -937,9 +869,10 @@ async def _find_related_observations(
"""
# Use recall to find related observations with token budget
# max_tokens naturally limits how many observations are returned
from ...config import get_config
from ...tracing import get_tracer, is_tracing_enabled
config = await memory_engine._config_resolver.resolve_full_config(bank_id, request_context)
config = get_config()
# SECURITY: Use all_strict matching if tags provided to prevent cross-scope consolidation
tags_match = "all_strict" if tags else "any"
@@ -964,8 +897,7 @@ async def _find_related_observations(
tags=tags, # Filter by source memory's tags
tags_match=tags_match, # Use strict matching for security
include_source_facts=True, # Embed source facts so we avoid a separate DB fetch
max_source_facts_tokens=config.consolidation_source_facts_max_tokens,
max_source_facts_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation,
max_source_facts_tokens=-1, # No token limit — we need all source facts for consolidation
_quiet=True, # Suppress logging
)
finally:
@@ -1029,17 +961,14 @@ async def _consolidate_batch_with_llm(
observations_text = "[]"
def _fact_line(m: dict[str, Any]) -> str:
text = f"[{m['id']}] {m['text']}"
temporal_parts = []
parts = [f"[{m['id']}] {m['text']}"]
if m.get("occurred_start"):
temporal_parts.append(f"occurred_start={m['occurred_start']}")
parts.append(f"occurred_start={m['occurred_start']}")
if m.get("occurred_end"):
temporal_parts.append(f"occurred_end={m['occurred_end']}")
parts.append(f"occurred_end={m['occurred_end']}")
if m.get("mentioned_at"):
temporal_parts.append(f"mentioned_at={m['mentioned_at']}")
if temporal_parts:
text += f" ({', '.join(temporal_parts)})"
return text
parts.append(f"mentioned_at={m['mentioned_at']}")
return " | ".join(parts)
facts_lines = "\n".join(_fact_line(m) for m in memories)
@@ -1100,8 +1029,8 @@ async def _create_observation_directly(
# Create the observation as a memory_unit
now = datetime.now(timezone.utc)
obs_event_date = event_date or now
obs_occurred_start = occurred_start
obs_occurred_end = occurred_end
obs_occurred_start = occurred_start or now
obs_occurred_end = occurred_end or now
obs_mentioned_at = mentioned_at or now
obs_tags = tags or []
@@ -29,31 +29,14 @@ Compare the facts against existing observations:
- Same topic as an existing observation → UPDATE it (observation_id + source_fact_ids)
- New topic with durable knowledge → CREATE a new observation (source_fact_ids)
- Cross-reference facts within the batch: a later fact may resolve a vague reference in an earlier one
- Purely ephemeral facts → omit them unless the MISSION above explicitly targets such data (e.g. timestamped events, session state, screen content)"""
- Purely ephemeral facts → omit them (no create/update needed)"""
# Output format — JSON braces escaped as {{ }} so .format() leaves them literal
_BATCH_OUTPUT_FORMAT = """
Output a JSON object with three arrays.
## EXAMPLE
Input facts:
[a1b2c3d4-e5f6-7890-abcd-ef1234567890] Alice mentioned she works long hours, often past midnight | Involving: Alice (occurred_start=2024-01-15, mentioned_at=2024-01-15)
[b2c3d4e5-f6a7-8901-bcde-f12345678901] Alice said she's exhausted from the project deadlines | Involving: Alice (occurred_start=2024-01-20, mentioned_at=2024-01-20)
Good observation text — clean prose, no metadata, each fact tracked distinctly:
"Alice works long hours, often past midnight."
"Alice feels exhausted from project deadlines."
Bad observation text — NEVER do this (verbatim copy of fact text with metadata):
"Alice mentioned she works long hours, often past midnight | Involving: Alice (occurred_start=2024-01-15, mentioned_at=2024-01-15)"
Observation text rules:
- Write clean prose — NEVER copy raw fact lines or their metadata (temporal fields, "Involving:", "When:" labels, UUIDs).
- Parenthesized metadata like (occurred_start=...) and pipe-separated labels like "| Involving: ..." are fact formatting — strip them entirely from observation text.
- How many observations to create and how much to aggregate is driven by the MISSION above.
{{"creates": [{{"text": "Alice works long hours, often past midnight.", "source_fact_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]}}, {{"text": "Alice feels exhausted from project deadlines.", "source_fact_ids": ["b2c3d4e5-f6a7-8901-bcde-f12345678901"]}}],
Example (showing the required UUID format for all IDs):
{{"creates": [{{"text": "Alice lives in Berlin", "source_fact_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f6a7-8901-bcde-f12345678901"]}}],
"updates": [{{"text": "Alice works at Acme Corp as a senior engineer", "observation_id": "c3d4e5f6-a7b8-9012-cdef-123456789012", "source_fact_ids": ["d4e5f6a7-b8c9-0123-defa-234567890123"]}}],
"deletes": [{{"observation_id": "e5f6a7b8-c9d0-1234-efab-345678901234"}}]}}
+4 -10
View File
@@ -58,16 +58,10 @@ async def retry_with_backoff(
last_exception = e
if attempt < max_retries:
delay = min(base_delay * (2**attempt), max_delay)
if isinstance(e, asyncpg.exceptions.DeadlockDetectedError):
logger.warning(
f"Deadlock detected during parallel document processing — this is expected and will resolve automatically "
f"(attempt {attempt + 1}/{max_retries + 1}, retrying in {delay:.1f}s)"
)
else:
logger.warning(
f"Database operation failed (attempt {attempt + 1}/{max_retries + 1}): {e}. "
f"Retrying in {delay:.1f}s..."
)
logger.warning(
f"Database operation failed (attempt {attempt + 1}/{max_retries + 1}): {e}. "
f"Retrying in {delay:.1f}s..."
)
await asyncio.sleep(delay)
else:
logger.error(f"Database operation failed after {max_retries + 1} attempts: {e}")
@@ -459,12 +459,10 @@ class EntityResolver:
entity_dates = [g.event_date for _, g in sorted_groups]
# INSERT ... ON CONFLICT DO NOTHING — no row lock on already-existing entities.
# mention_count starts at 0 here; flush_pending_stats() is the sole source of
# truth for mention counting (one stat per original mention in the batch).
inserted_rows = await conn.fetch(
f"""
INSERT INTO {fq_table("entities")} (bank_id, canonical_name, first_seen, last_seen, mention_count)
SELECT $1, name, COALESCE(event_date, now()), COALESCE(event_date, now()), 0
SELECT $1, name, COALESCE(event_date, now()), COALESCE(event_date, now()), 1
FROM unnest($2::text[], $3::timestamptz[]) AS t(name, event_date)
ON CONFLICT (bank_id, LOWER(canonical_name))
DO NOTHING
@@ -491,15 +489,13 @@ class EntityResolver:
for row in existing_rows:
id_by_name[row["name_lower"]] = row["id"]
# Assign entity IDs back and queue one stat per original mention so that
# flush_pending_stats() increments mention_count by the true mention count,
# not just 1 per unique name.
# Assign entity IDs back and queue for post-txn stats flush.
for name_lower, g in sorted_groups:
entity_id = id_by_name.get(name_lower)
if entity_id:
for original_idx in g.indices:
entity_ids[original_idx] = entity_id
pending.append(_EntityStat(entity_id=entity_id, event_date=g.event_date))
pending.append(_EntityStat(entity_id=entity_id, event_date=g.event_date))
# Accumulate into the resolver's pending list; the orchestrator flushes
# these with await entity_resolver.flush_pending_stats() after the txn.
@@ -48,28 +48,6 @@ _llm_max_concurrent = int(os.getenv(ENV_LLM_MAX_CONCURRENT, str(DEFAULT_LLM_MAX_
_global_llm_semaphore = asyncio.Semaphore(_llm_max_concurrent)
def sanitize_llm_output(text: str | None) -> str | None:
"""
Sanitize text by removing characters that break downstream systems.
Removes:
- ASCII control characters (0x00-0x08, 0x0B-0x0C, 0x0E-0x1F, 0x7F): break
json.loads and PostgreSQL UTF-8 encoding; tab (0x09), newline (0x0A), and
carriage return (0x0D) are preserved as they are valid in text and JSON.
- Unicode surrogates (U+D800-U+DFFF): Invalid in UTF-8, break LLM APIs
Surrogate characters are used in UTF-16 encoding but cannot be encoded
in UTF-8. They can appear in Python strings from improperly decoded data
(e.g., from JavaScript or broken files). Control characters commonly appear
in LLM output embedded inside JSON string values.
"""
if text is None:
return None
if not text:
return text
return re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\ud800-\udfff]", "", text)
class OutputTooLongError(Exception):
"""
Bridge exception raised when LLM output exceeds token limits.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
"""
Mental models module for Hindsight.
Mental models contain directives - hard rules that are injected into reflect prompts.
Directives are user-defined and their observations are user-provided (not LLM-generated).
Other types of consolidated knowledge are handled by:
- Learnings: Automatic bottom-up consolidation from facts
- Pinned Reflections: User-curated living documents
"""
from .models import MentalModel, MentalModelSubtype
__all__ = ["MentalModel", "MentalModelSubtype"]
@@ -0,0 +1,53 @@
"""
Pydantic models for mental models.
"""
from datetime import datetime, timezone
from enum import Enum
from pydantic import BaseModel, Field
class MentalModelSubtype(str, Enum):
"""Subtype of mental model.
Currently only DIRECTIVE is supported. Other types of consolidated knowledge
are handled by:
- Learnings: Automatic bottom-up consolidation from facts
- Pinned Reflections: User-curated living documents
"""
DIRECTIVE = "directive" # User-defined hard rules, observations user-provided
class MentalModel(BaseModel):
"""
A mental model representing synthesized understanding.
Mental models are the agent's consolidated knowledge. Unlike raw facts,
mental models provide:
- A one-liner description for quick scanning/retrieval
- A full summary for deep understanding
- Links to related mental models
"""
id: str = Field(description="Unique identifier within the bank")
bank_id: str = Field(description="Bank this mental model belongs to")
subtype: MentalModelSubtype = Field(description="How this model was created")
name: str = Field(description="Human-readable name")
description: str = Field(description="One-liner for quick scanning and retrieval matching")
summary: str | None = Field(default=None, description="Full synthesized understanding")
# References
entity_id: str | None = Field(default=None, description="Reference to entities table when type=entity")
source_facts: list[str] = Field(default_factory=list, description="Fact IDs used to generate summary")
links: list[str] = Field(default_factory=list, description="Related mental model IDs")
# Tags for scoped visibility (similar to document tags)
tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility filtering")
# Timestamps
last_updated: datetime | None = Field(default=None, description="When summary was last regenerated")
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), description="When this model was created"
)
@@ -1,31 +1,10 @@
"""File parser implementations."""
import logging
from dataclasses import dataclass
from .base import FileParser, UnsupportedFileTypeError
from .iris import IrisParser
from .markitdown import MarkitdownParser
__all__ = [
"FileParser",
"UnsupportedFileTypeError",
"IrisParser",
"MarkitdownParser",
"FileParserRegistry",
"ConvertResult",
]
@dataclass
class ConvertResult:
"""Result of a successful file conversion."""
content: str
parser_name: str
logger = logging.getLogger(__name__)
__all__ = ["FileParser", "UnsupportedFileTypeError", "IrisParser", "MarkitdownParser", "FileParserRegistry"]
class FileParserRegistry:
@@ -78,51 +57,6 @@ class FileParserRegistry:
raise ValueError(f"No parser found for {filename}. Available parsers: {list(self._parsers.keys())}")
async def convert_with_fallback(
self,
parsers: list[str],
file_data: bytes,
filename: str,
content_type: str | None = None,
) -> ConvertResult:
"""
Try each parser in order, falling back on failure or empty content.
Moves to the next parser if the current one raises UnsupportedFileTypeError
or returns empty content. Any other exception (RuntimeError, network error,
etc.) also triggers a fallback so the chain is exhausted before failing.
Args:
parsers: Ordered list of parser names to try
file_data: Raw file bytes
filename: Original filename
content_type: MIME type (optional)
Returns:
ConvertResult with the parsed content and the name of the parser that succeeded
Raises:
ValueError: If a parser name is not registered
RuntimeError: If all parsers fail or return empty content
"""
last_error: Exception | None = None
for name in parsers:
parser = self.get_parser(name, filename, content_type)
try:
content = await parser.convert(file_data, filename)
if content and content.strip():
return ConvertResult(content=content, parser_name=name)
logger.warning(f"Parser '{name}' returned empty content for '{filename}', trying next")
last_error = RuntimeError(f"Parser '{name}' returned no content for '{filename}'")
except UnsupportedFileTypeError as e:
logger.warning(f"Parser '{name}' does not support '{filename}', trying next: {e}")
last_error = e
except Exception as e:
logger.warning(f"Parser '{name}' failed for '{filename}', trying next: {e}")
last_error = e
raise last_error or RuntimeError(f"No parsers available for '{filename}'")
def list_parsers(self) -> list[str]:
"""Get list of registered parser names."""
return list(self._parsers.keys())
@@ -62,7 +62,7 @@ class IrisParser(FileParser):
"""
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, read=120.0)) as client:
async with httpx.AsyncClient() as client:
# Step 1: Request a presigned upload URL
init_resp = await client.post(
f"{_IRIS_BASE_URL}/org/{self._org_id}/files",
@@ -75,10 +75,9 @@ class IrisParser(FileParser):
upload_url: str = init_data["uploadUrl"]
# Step 2: Upload the file bytes to the presigned URL (no auth header)
# Ensure file_data is plain bytes (GCS storage may return obstore.Bytes)
upload_resp = await client.put(
upload_url,
content=bytes(file_data),
content=file_data,
headers={"Content-Type": content_type},
)
_raise_for_status(upload_resp, filename, "file upload")
@@ -522,19 +522,6 @@ class OpenAICompatibleLLM(LLMInterface):
"""
start_time = time.time()
# Normalize named tool_choice dicts to "required" + filter tools.
# Some providers (e.g. LM Studio, Ollama) reject the OpenAI named format
# {"type": "function", "function": {"name": "..."}}. The semantics are
# identical to tool_choice="required" with the tools list restricted to
# just the requested tool, so we apply that transformation universally.
if isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
forced_name = tool_choice.get("function", {}).get("name")
if forced_name:
filtered = [t for t in tools if t.get("function", {}).get("name") == forced_name]
if filtered:
tools = filtered
tool_choice = "required"
# Build call parameters
call_params: dict[str, Any] = {
"model": self.model,
@@ -15,7 +15,7 @@ from typing import Literal, cast
from pydantic import BaseModel, ConfigDict, Field, create_model, field_validator
from ...config import get_config
from ..llm_wrapper import LLMConfig, OutputTooLongError, sanitize_llm_output
from ..llm_wrapper import LLMConfig, OutputTooLongError
from ..response_models import TokenUsage
from .entity_labels import (
EntityLabelsConfig,
@@ -66,7 +66,25 @@ def _infer_temporal_date(fact_text: str, event_date: datetime | None) -> str | N
def _sanitize_text(text: str | None) -> str | None:
return sanitize_llm_output(text)
"""
Sanitize text by removing characters that break downstream systems.
Removes:
- Null bytes (\\x00): Invalid in PostgreSQL UTF-8 encoding
- Unicode surrogates (U+D800-U+DFFF): Invalid in UTF-8, break LLM APIs
Surrogate characters are used in UTF-16 encoding but cannot be encoded
in UTF-8. They can appear in Python strings from improperly decoded data
(e.g., from JavaScript or broken files). Null bytes commonly appear in
OCR output, PDF extraction, or copy-paste from binary sources.
"""
if text is None:
return None
if not text:
return text
# Remove null bytes and surrogate characters
text = text.replace("\x00", "")
return re.sub(r"[\ud800-\udfff]", "", text)
class Entity(BaseModel):
@@ -926,15 +944,16 @@ async def _extract_facts_from_chunk(
user_message = _build_user_message(chunk, chunk_index, total_chunks, event_date, context, metadata)
# Retry logic for JSON validation errors
# Use retain-specific overrides if set, otherwise fall back to global LLM config
llm_max_retries = (
config.retain_llm_max_retries if config.retain_llm_max_retries is not None else config.llm_max_retries
)
last_error: Exception | None = None
max_retries = 2
last_error = None
usage = TokenUsage() # Track cumulative usage across retries
for attempt in range(llm_max_retries):
for attempt in range(max_retries):
try:
# Use retain-specific overrides if set, otherwise fall back to global LLM config
max_retries = (
config.retain_llm_max_retries if config.retain_llm_max_retries is not None else config.llm_max_retries
)
initial_backoff = (
config.retain_llm_initial_backoff
if config.retain_llm_initial_backoff is not None
@@ -950,7 +969,7 @@ async def _extract_facts_from_chunk(
scope="retain_extract_facts",
temperature=0.1,
max_completion_tokens=config.retain_max_completion_tokens,
max_retries=llm_max_retries,
max_retries=max_retries,
initial_backoff=initial_backoff,
max_backoff=max_backoff,
skip_validation=True, # Get raw JSON, we'll validate leniently
@@ -964,14 +983,14 @@ async def _extract_facts_from_chunk(
# Handle malformed LLM responses
if not isinstance(extraction_response_json, dict):
if attempt < llm_max_retries - 1:
if attempt < max_retries - 1:
logger.warning(
f"LLM returned non-dict JSON on attempt {attempt + 1}/{llm_max_retries}: {type(extraction_response_json).__name__}. Retrying..."
f"LLM returned non-dict JSON on attempt {attempt + 1}/{max_retries}: {type(extraction_response_json).__name__}. Retrying..."
)
continue
else:
logger.warning(
f"LLM returned non-dict JSON after {llm_max_retries} attempts: {type(extraction_response_json).__name__}. "
f"LLM returned non-dict JSON after {max_retries} attempts: {type(extraction_response_json).__name__}. "
f"Raw: {str(extraction_response_json)[:500]}"
)
return [], usage
@@ -1187,9 +1206,9 @@ async def _extract_facts_from_chunk(
continue
# If we got malformed facts and haven't exhausted retries, try again
if has_malformed_facts and len(chunk_facts) < len(raw_facts) * 0.8 and attempt < llm_max_retries - 1:
if has_malformed_facts and len(chunk_facts) < len(raw_facts) * 0.8 and attempt < max_retries - 1:
logger.warning(
f"Got {len(raw_facts) - len(chunk_facts)} malformed facts out of {len(raw_facts)} on attempt {attempt + 1}/{llm_max_retries}. Retrying..."
f"Got {len(raw_facts) - len(chunk_facts)} malformed facts out of {len(raw_facts)} on attempt {attempt + 1}/{max_retries}. Retrying..."
)
continue
@@ -1222,18 +1241,16 @@ async def _extract_facts_from_chunk(
if "json_validate_failed" in str(e):
logger.warning(
f" [1.3.{chunk_index + 1}] Attempt {attempt + 1}/{llm_max_retries} failed with JSON validation error: {e}"
f" [1.3.{chunk_index + 1}] Attempt {attempt + 1}/{max_retries} failed with JSON validation error: {e}"
)
if attempt < llm_max_retries - 1:
if attempt < max_retries - 1:
logger.info(f" [1.3.{chunk_index + 1}] Retrying...")
continue
# If it's not a JSON validation error or we're out of retries, re-raise
raise
# If we exhausted all retries, raise the last error or a descriptive fallback
if last_error is not None:
raise last_error
raise RuntimeError(f"Fact extraction failed after {llm_max_retries} attempts: LLM did not return valid JSON")
# If we exhausted all retries, raise the last error
raise last_error
async def _extract_facts_with_auto_split(
@@ -7,11 +7,10 @@ Coordinates all retain pipeline modules to store memories efficiently.
import logging
import time
import uuid
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from typing import Any
from ..db_utils import acquire_with_retry, retry_with_backoff
from ..db_utils import acquire_with_retry
from . import bank_utils
@@ -53,8 +52,6 @@ def parse_datetime_flexible(value: Any) -> datetime:
raise TypeError(f"Expected datetime or string, got {type(value).__name__}")
import asyncpg
from ..response_models import TokenUsage
from . import (
chunk_storage,
@@ -85,7 +82,6 @@ async def retain_batch(
document_tags: list[str] | None = None,
operation_id: str | None = None,
schema: str | None = None,
outbox_callback: Callable[["asyncpg.Connection"], Awaitable[None]] | None = None,
) -> tuple[list[list[str]], TokenUsage]:
"""
Process a batch of content through the retain pipeline.
@@ -269,6 +265,9 @@ async def retain_batch(
for extracted_fact, embedding in zip(extracted_facts, embeddings)
]
# Track document IDs for logging
document_ids_added = []
# Group contents by document_id for document tracking and chunk storage
from collections import defaultdict
@@ -277,249 +276,229 @@ async def retain_batch(
doc_id = content_dict.get("document_id")
contents_by_doc[doc_id].append((idx, content_dict))
# Step 4: Database transaction (retried on deadlock)
result_unit_ids: list[list[str]] = []
# Step 4: Database transaction
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
# Handle document tracking for all documents
step_start = time.time()
# Map None document_id to generated UUIDs
doc_id_mapping = {} # Maps original doc_id (including None) to actual doc_id used
log_buffer_pre_db = len(log_buffer)
if document_id:
# Legacy: single document_id parameter
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
retain_params = {}
# Collect tags from all content items and merge with document_tags
all_tags = set(document_tags or [])
for item in contents_dicts:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
async def _run_db_work() -> None:
nonlocal result_unit_ids
if contents_dicts:
first_item = contents_dicts[0]
if first_item.get("context"):
retain_params["context"] = first_item["context"]
if first_item.get("event_date"):
retain_params["event_date"] = (
first_item["event_date"].isoformat()
if hasattr(first_item["event_date"], "isoformat")
else str(first_item["event_date"])
)
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
# Reset per-fact mutations and log buffer so each retry attempt starts clean
del log_buffer[log_buffer_pre_db:]
document_ids_added: list[str] = []
for pf in processed_facts:
pf.document_id = None
pf.chunk_id = None
await fact_storage.handle_document_tracking(
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, merged_tags
)
document_ids_added.append(document_id)
doc_id_mapping[None] = document_id # For backwards compatibility
else:
# Handle per-item document_ids (create documents if any item has document_id or if chunks exist)
has_any_doc_ids = any(item.get("document_id") for item in contents_dicts)
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
# Handle document tracking for all documents
step_start = time.time()
# Map None document_id to generated UUIDs
doc_id_mapping = {} # Maps original doc_id (including None) to actual doc_id used
if has_any_doc_ids or chunks:
for original_doc_id, doc_contents in contents_by_doc.items():
actual_doc_id = original_doc_id
if document_id:
# Legacy: single document_id parameter
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
retain_params = {}
# Collect tags from all content items and merge with document_tags
all_tags = set(document_tags or [])
for item in contents_dicts:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
# Only create document record if:
# 1. Item has explicit document_id, OR
# 2. There are chunks (need document for chunk storage)
should_create_doc = (original_doc_id is not None) or chunks
if contents_dicts:
first_item = contents_dicts[0]
if first_item.get("context"):
retain_params["context"] = first_item["context"]
if first_item.get("event_date"):
retain_params["event_date"] = (
first_item["event_date"].isoformat()
if hasattr(first_item["event_date"], "isoformat")
else str(first_item["event_date"])
if should_create_doc:
if actual_doc_id is None:
# No document_id but have chunks - generate one
actual_doc_id = str(uuid.uuid4())
# Store mapping for later use
doc_id_mapping[original_doc_id] = actual_doc_id
# Combine content for this document
combined_content = "\n".join([c.get("content", "") for _, c in doc_contents])
# Collect tags from all content items for this document and merge with document_tags
all_tags = set(document_tags or [])
for _, item in doc_contents:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
# Extract retain params from first content item
retain_params = {}
if doc_contents:
first_item = doc_contents[0][1]
if first_item.get("context"):
retain_params["context"] = first_item["context"]
if first_item.get("event_date"):
retain_params["event_date"] = (
first_item["event_date"].isoformat()
if hasattr(first_item["event_date"], "isoformat")
else str(first_item["event_date"])
)
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn,
bank_id,
actual_doc_id,
combined_content,
is_first_batch,
retain_params,
merged_tags,
)
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
document_ids_added.append(actual_doc_id)
await fact_storage.handle_document_tracking(
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, merged_tags
)
document_ids_added.append(document_id)
doc_id_mapping[None] = document_id # For backwards compatibility
else:
# Handle per-item document_ids (create documents if any item has document_id or if chunks exist)
has_any_doc_ids = any(item.get("document_id") for item in contents_dicts)
if has_any_doc_ids or chunks:
for original_doc_id, doc_contents in contents_by_doc.items():
actual_doc_id = original_doc_id
# Only create document record if:
# 1. Item has explicit document_id, OR
# 2. There are chunks (need document for chunk storage)
should_create_doc = (original_doc_id is not None) or chunks
if should_create_doc:
if actual_doc_id is None:
# No document_id but have chunks - generate one
actual_doc_id = str(uuid.uuid4())
# Store mapping for later use
doc_id_mapping[original_doc_id] = actual_doc_id
# Combine content for this document
combined_content = "\n".join([c.get("content", "") for _, c in doc_contents])
# Collect tags from all content items for this document and merge with document_tags
all_tags = set(document_tags or [])
for _, item in doc_contents:
item_tags = item.get("tags", []) or []
all_tags.update(item_tags)
merged_tags = list(all_tags)
# Extract retain params from first content item
retain_params = {}
if doc_contents:
first_item = doc_contents[0][1]
if first_item.get("context"):
retain_params["context"] = first_item["context"]
if first_item.get("event_date"):
retain_params["event_date"] = (
first_item["event_date"].isoformat()
if hasattr(first_item["event_date"], "isoformat")
else str(first_item["event_date"])
)
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn,
bank_id,
actual_doc_id,
combined_content,
is_first_batch,
retain_params,
merged_tags,
)
document_ids_added.append(actual_doc_id)
if document_ids_added:
log_buffer.append(
f"[2.5] Document tracking: {len(document_ids_added)} documents in {time.time() - step_start:.3f}s"
)
# Store chunks and map to facts for all documents
step_start = time.time()
chunk_id_map_by_doc = {} # Maps (doc_id, chunk_index) -> chunk_id
if chunks:
# Group chunks by their source document
chunks_by_doc = defaultdict(list)
for chunk in chunks:
# chunk.content_index tells us which content this chunk came from
original_doc_id = contents_dicts[chunk.content_index].get("document_id")
# Map to actual document_id (handles None -> generated UUID mapping)
actual_doc_id = doc_id_mapping.get(original_doc_id, original_doc_id)
if actual_doc_id is None and document_id:
actual_doc_id = document_id
chunks_by_doc[actual_doc_id].append(chunk)
# Store chunks for each document
for doc_id, doc_chunks in chunks_by_doc.items():
chunk_id_map = await chunk_storage.store_chunks_batch(conn, bank_id, doc_id, doc_chunks)
# Store mapping with document context
for chunk_idx, chunk_id in chunk_id_map.items():
chunk_id_map_by_doc[(doc_id, chunk_idx)] = chunk_id
log_buffer.append(
f"[3] Store chunks: {len(chunks)} chunks for {len(chunks_by_doc)} documents in {time.time() - step_start:.3f}s"
)
# Map chunk_ids and document_ids to facts
for fact, processed_fact in zip(extracted_facts, processed_facts):
# Get the original document_id for this fact's source content
original_doc_id = contents_dicts[fact.content_index].get("document_id")
# Map to actual document_id (handles None -> generated UUID mapping)
actual_doc_id = doc_id_mapping.get(original_doc_id, original_doc_id)
if actual_doc_id is None and document_id:
actual_doc_id = document_id
# Set document_id on the fact
processed_fact.document_id = actual_doc_id
# Map chunk_id if this fact came from a chunk
if fact.chunk_index is not None:
# Look up chunk_id using (doc_id, chunk_index)
chunk_id = chunk_id_map_by_doc.get((actual_doc_id, fact.chunk_index))
if chunk_id:
processed_fact.chunk_id = chunk_id
else:
# No chunks - still need to set document_id on facts
for fact, processed_fact in zip(extracted_facts, processed_facts):
original_doc_id = contents_dicts[fact.content_index].get("document_id")
# Map to actual document_id (handles None -> generated UUID mapping)
actual_doc_id = doc_id_mapping.get(original_doc_id, original_doc_id)
if actual_doc_id is None and document_id:
actual_doc_id = document_id
processed_fact.document_id = actual_doc_id
non_duplicate_facts = processed_facts
# Insert facts (document_id is now stored per-fact)
step_start = time.time()
unit_ids = await fact_storage.insert_facts_batch(conn, bank_id, non_duplicate_facts)
log_buffer.append(f"[5] Insert facts: {len(unit_ids)} units in {time.time() - step_start:.3f}s")
# Process entities
step_start = time.time()
# Build map of content_index -> user entities for merging
user_entities_per_content = {
idx: content.entities for idx, content in enumerate(contents) if content.entities
}
entity_links = await entity_processing.process_entities_batch(
entity_resolver,
conn,
bank_id,
unit_ids,
non_duplicate_facts,
log_buffer,
user_entities_per_content=user_entities_per_content,
entity_labels=getattr(config, "entity_labels", None),
)
log_buffer.append(f"[6] Process entities: {len(entity_links)} links in {time.time() - step_start:.3f}s")
# Create temporal links
step_start = time.time()
temporal_link_count = await link_creation.create_temporal_links_batch(conn, bank_id, unit_ids)
log_buffer.append(f"[7] Temporal links: {temporal_link_count} links in {time.time() - step_start:.3f}s")
# Create semantic links
step_start = time.time()
embeddings_for_links = [fact.embedding for fact in non_duplicate_facts]
semantic_link_count = await link_creation.create_semantic_links_batch(
conn, bank_id, unit_ids, embeddings_for_links
)
log_buffer.append(f"[8] Semantic links: {semantic_link_count} links in {time.time() - step_start:.3f}s")
# Insert entity links
step_start = time.time()
if entity_links:
await entity_processing.insert_entity_links_batch(conn, entity_links)
log_buffer.append(
f"[9] Entity links: {len(entity_links) if entity_links else 0} links in {time.time() - step_start:.3f}s"
)
# Create causal links
step_start = time.time()
causal_link_count = await link_creation.create_causal_links_batch(conn, unit_ids, non_duplicate_facts)
log_buffer.append(f"[10] Causal links: {causal_link_count} links in {time.time() - step_start:.3f}s")
# Map results back to original content items
result_unit_ids = _map_results_to_contents(contents, extracted_facts, unit_ids)
# Transactional outbox: queue any side-effect tasks (e.g. webhook deliveries)
# inside the same transaction so they are atomically committed with the retain data.
if outbox_callback:
await outbox_callback(conn)
# Flush entity stats (mention_count / last_seen) now that the transaction
# has committed. Uses a fresh pool connection — no locks held.
await entity_resolver.flush_pending_stats()
# Log final summary
total_time = time.time() - start_time
log_buffer.append(f"{'=' * 60}")
log_buffer.append(f"RETAIN_BATCH COMPLETE: {len(unit_ids)} units in {total_time:.3f}s")
if document_ids_added:
log_buffer.append(f"Documents: {', '.join(document_ids_added)}")
log_buffer.append(f"{'=' * 60}")
log_buffer.append(
f"[2.5] Document tracking: {len(document_ids_added)} documents in {time.time() - step_start:.3f}s"
)
logger.info("\n" + "\n".join(log_buffer) + "\n")
# Store chunks and map to facts for all documents
step_start = time.time()
chunk_id_map_by_doc = {} # Maps (doc_id, chunk_index) -> chunk_id
await retry_with_backoff(_run_db_work)
return result_unit_ids, usage
if chunks:
# Group chunks by their source document
chunks_by_doc = defaultdict(list)
for chunk in chunks:
# chunk.content_index tells us which content this chunk came from
original_doc_id = contents_dicts[chunk.content_index].get("document_id")
# Map to actual document_id (handles None -> generated UUID mapping)
actual_doc_id = doc_id_mapping.get(original_doc_id, original_doc_id)
if actual_doc_id is None and document_id:
actual_doc_id = document_id
chunks_by_doc[actual_doc_id].append(chunk)
# Store chunks for each document
for doc_id, doc_chunks in chunks_by_doc.items():
chunk_id_map = await chunk_storage.store_chunks_batch(conn, bank_id, doc_id, doc_chunks)
# Store mapping with document context
for chunk_idx, chunk_id in chunk_id_map.items():
chunk_id_map_by_doc[(doc_id, chunk_idx)] = chunk_id
log_buffer.append(
f"[3] Store chunks: {len(chunks)} chunks for {len(chunks_by_doc)} documents in {time.time() - step_start:.3f}s"
)
# Map chunk_ids and document_ids to facts
for fact, processed_fact in zip(extracted_facts, processed_facts):
# Get the original document_id for this fact's source content
original_doc_id = contents_dicts[fact.content_index].get("document_id")
# Map to actual document_id (handles None -> generated UUID mapping)
actual_doc_id = doc_id_mapping.get(original_doc_id, original_doc_id)
if actual_doc_id is None and document_id:
actual_doc_id = document_id
# Set document_id on the fact
processed_fact.document_id = actual_doc_id
# Map chunk_id if this fact came from a chunk
if fact.chunk_index is not None:
# Look up chunk_id using (doc_id, chunk_index)
chunk_id = chunk_id_map_by_doc.get((actual_doc_id, fact.chunk_index))
if chunk_id:
processed_fact.chunk_id = chunk_id
else:
# No chunks - still need to set document_id on facts
for fact, processed_fact in zip(extracted_facts, processed_facts):
original_doc_id = contents_dicts[fact.content_index].get("document_id")
# Map to actual document_id (handles None -> generated UUID mapping)
actual_doc_id = doc_id_mapping.get(original_doc_id, original_doc_id)
if actual_doc_id is None and document_id:
actual_doc_id = document_id
processed_fact.document_id = actual_doc_id
non_duplicate_facts = processed_facts
# Insert facts (document_id is now stored per-fact)
step_start = time.time()
unit_ids = await fact_storage.insert_facts_batch(conn, bank_id, non_duplicate_facts)
log_buffer.append(f"[5] Insert facts: {len(unit_ids)} units in {time.time() - step_start:.3f}s")
# Process entities
step_start = time.time()
# Build map of content_index -> user entities for merging
user_entities_per_content = {
idx: content.entities for idx, content in enumerate(contents) if content.entities
}
entity_links = await entity_processing.process_entities_batch(
entity_resolver,
conn,
bank_id,
unit_ids,
non_duplicate_facts,
log_buffer,
user_entities_per_content=user_entities_per_content,
entity_labels=getattr(config, "entity_labels", None),
)
log_buffer.append(f"[6] Process entities: {len(entity_links)} links in {time.time() - step_start:.3f}s")
# Create temporal links
step_start = time.time()
temporal_link_count = await link_creation.create_temporal_links_batch(conn, bank_id, unit_ids)
log_buffer.append(f"[7] Temporal links: {temporal_link_count} links in {time.time() - step_start:.3f}s")
# Create semantic links
step_start = time.time()
embeddings_for_links = [fact.embedding for fact in non_duplicate_facts]
semantic_link_count = await link_creation.create_semantic_links_batch(
conn, bank_id, unit_ids, embeddings_for_links
)
log_buffer.append(f"[8] Semantic links: {semantic_link_count} links in {time.time() - step_start:.3f}s")
# Insert entity links
step_start = time.time()
if entity_links:
await entity_processing.insert_entity_links_batch(conn, entity_links)
log_buffer.append(
f"[9] Entity links: {len(entity_links) if entity_links else 0} links in {time.time() - step_start:.3f}s"
)
# Create causal links
step_start = time.time()
causal_link_count = await link_creation.create_causal_links_batch(conn, unit_ids, non_duplicate_facts)
log_buffer.append(f"[10] Causal links: {causal_link_count} links in {time.time() - step_start:.3f}s")
# Map results back to original content items
result_unit_ids = _map_results_to_contents(contents, extracted_facts, unit_ids)
# Flush entity stats (mention_count / last_seen) now that the transaction
# has committed. Uses a fresh pool connection — no locks held.
await entity_resolver.flush_pending_stats()
# Log final summary
total_time = time.time() - start_time
log_buffer.append(f"{'=' * 60}")
log_buffer.append(f"RETAIN_BATCH COMPLETE: {len(unit_ids)} units in {total_time:.3f}s")
if document_ids_added:
log_buffer.append(f"Documents: {', '.join(document_ids_added)}")
log_buffer.append(f"{'=' * 60}")
logger.info("\n" + "\n".join(log_buffer) + "\n")
return result_unit_ids, usage
def _map_results_to_contents(
@@ -395,28 +395,27 @@ class LinkExpansionRetriever(GraphRetriever):
WHERE id = ANY($1::uuid[])
AND source_memory_ids IS NOT NULL
),
connected_sources AS (
-- Mirror the non-observation entity expansion: follow pre-bounded entity
-- links in memory_links (capped to MAX_LINKS_PER_ENTITY=50 at retain time).
-- Score = number of distinct shared entities, same as the non-obs path.
SELECT DISTINCT ml.to_unit_id AS source_id
source_entities AS (
SELECT DISTINCT ue.entity_id
FROM seed_sources ss
JOIN {fq_table("memory_links")} ml ON ml.from_unit_id = ss.source_id
WHERE ml.link_type = 'entity'
JOIN {fq_table("unit_entities")} ue ON ss.source_id = ue.unit_id
),
connected_array AS (
SELECT array_agg(source_id) AS source_ids FROM connected_sources
all_connected_sources AS (
SELECT DISTINCT other_ue.unit_id AS source_id
FROM source_entities se
JOIN {fq_table("unit_entities")} other_ue ON se.entity_id = other_ue.entity_id
)
SELECT
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
mu.occurred_end, mu.mentioned_at,
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
(SELECT COUNT(DISTINCT s) FROM unnest(mu.source_memory_ids) s WHERE s = ANY(ca.source_ids))::float AS score
FROM {fq_table("memory_units")} mu, connected_array ca
COUNT(DISTINCT cs.source_id)::float AS score
FROM all_connected_sources cs
JOIN {fq_table("memory_units")} mu
ON mu.source_memory_ids @> ARRAY[cs.source_id]
WHERE mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
AND ca.source_ids IS NOT NULL
AND mu.source_memory_ids && ca.source_ids
GROUP BY mu.id
ORDER BY score DESC
LIMIT $2
""",
@@ -2,72 +2,8 @@
Cross-encoder neural reranking for search results.
"""
from datetime import datetime, timezone
from .types import MergedCandidate, ScoredResult
UTC = timezone.utc
# Multiplicative boost alphas for recency and temporal proximity.
# Each signal contributes at most ±(alpha/2) relative adjustment to the base CE score,
# so the max combined boost is (1 + alpha/2)^2 ≈ +21% and min is (1 - alpha/2)^2 ≈ -19%.
_RECENCY_ALPHA: float = 0.2
_TEMPORAL_ALPHA: float = 0.2
def apply_combined_scoring(
scored_results: list[ScoredResult],
now: datetime,
recency_alpha: float = _RECENCY_ALPHA,
temporal_alpha: float = _TEMPORAL_ALPHA,
) -> None:
"""Apply combined scoring to a list of ScoredResults in-place.
Uses the cross-encoder score as the primary relevance signal, with recency
and temporal proximity applied as multiplicative boosts. This ensures the
influence of these secondary signals is always proportional to the base
relevance score, regardless of the cross-encoder model's score calibration.
Formula::
recency_boost = 1 + recency_alpha * (recency - 0.5) # in [1-α/2, 1+α/2]
temporal_boost = 1 + temporal_alpha * (temporal - 0.5) # in [1-α/2, 1+α/2]
combined_score = cross_encoder_score_normalized * recency_boost * temporal_boost
Temporal proximity is treated as neutral (0.5) when not set by temporal retrieval,
so temporal_boost collapses to 1.0 for non-temporal queries.
Args:
scored_results: Results from the cross-encoder reranker. Mutated in place.
now: Current UTC datetime for recency calculation.
recency_alpha: Max relative recency adjustment (default 0.2 → ±10%).
temporal_alpha: Max relative temporal adjustment (default 0.2 → ±10%).
"""
if now.tzinfo is None:
now = now.replace(tzinfo=UTC)
for sr in scored_results:
# Recency: linear decay over 365 days → [0.1, 1.0]; neutral 0.5 if no date.
sr.recency = 0.5
if sr.retrieval.occurred_start:
occurred = sr.retrieval.occurred_start
if occurred.tzinfo is None:
occurred = occurred.replace(tzinfo=UTC)
days_ago = (now - occurred).total_seconds() / 86400
sr.recency = max(0.1, min(1.0, 1.0 - (days_ago / 365)))
# Temporal proximity: meaningful only for temporal queries; neutral otherwise.
sr.temporal = sr.retrieval.temporal_proximity if sr.retrieval.temporal_proximity is not None else 0.5
# RRF: kept at 0.0 for trace continuity but excluded from scoring.
# RRF is batch-relative (min-max normalised) and redundant after reranking.
sr.rrf_normalized = 0.0
recency_boost = 1.0 + recency_alpha * (sr.recency - 0.5)
temporal_boost = 1.0 + temporal_alpha * (sr.temporal - 0.5)
sr.combined_score = sr.cross_encoder_score_normalized * recency_boost * temporal_boost
sr.weight = sr.combined_score
class CrossEncoderReranker:
"""
@@ -1,8 +1,7 @@
"""Google Cloud Storage backend using obstore."""
import logging
import os
from datetime import datetime, timedelta, timezone
from datetime import timedelta
import obstore as obs
from obstore.store import GCSStore
@@ -12,30 +11,6 @@ from .base import FileStorage
logger = logging.getLogger(__name__)
def _make_google_auth_credential_provider():
"""Create a credential provider using google.auth (supports all credential types).
obstore's built-in credential parsing only supports service_account and
authorized_user JSON types. This provider uses the google-auth library
which additionally handles external_account (Workload Identity Federation),
impersonated credentials, and metadata-server credentials.
"""
import google.auth
import google.auth.transport.requests
credentials, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"])
request = google.auth.transport.requests.Request()
def _provide():
credentials.refresh(request)
expiry = credentials.expiry
if expiry and expiry.tzinfo is None:
expiry = expiry.replace(tzinfo=timezone.utc)
return {"token": credentials.token, "expires_at": expiry}
return _provide
class GCSFileStorage(FileStorage):
"""
Google Cloud Storage backend.
@@ -52,29 +27,8 @@ class GCSFileStorage(FileStorage):
kwargs: dict = {}
if service_account_key:
kwargs["service_account_key"] = service_account_key
else:
# Use google.auth credential provider for broad credential type support
# (service_account, authorized_user, external_account, metadata server, etc.)
try:
kwargs["credential_provider"] = _make_google_auth_credential_provider()
logger.info("Using google.auth credential provider for GCS")
except Exception as e:
logger.warning(
f"Failed to create google.auth credential provider, falling back to obstore defaults: {e}"
)
# Workaround for https://github.com/developmentseed/obstore/issues/605
# obstore's Rust layer doesn't support external_account credentials (Workload
# Identity Federation) and eagerly parses GOOGLE_APPLICATION_CREDENTIALS even
# when credential_provider is given. Per the obstore maintainer's guidance,
# remove env vars so the Rust code doesn't try to authenticate itself.
# google.auth (used by credential_provider above) has already loaded credentials.
gac = os.environ.pop("GOOGLE_APPLICATION_CREDENTIALS", None)
try:
self._store = GCSStore(bucket, **kwargs)
finally:
if gac is not None:
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = gac
self._store = GCSStore(bucket, **kwargs)
logger.info(f"Initialized GCS file storage: bucket={bucket}")
async def store(self, file_data: bytes, key: str, metadata: dict[str, str] | None = None) -> str:
@@ -30,8 +30,6 @@ from hindsight_api.extensions.operation_validator import (
# Consolidation operation
ConsolidateContext,
ConsolidateResult,
# File Conversion
FileConvertResult,
# Mental Model operations
MentalModelGetContext,
MentalModelGetResult,
@@ -85,8 +83,6 @@ __all__ = [
# Operation Validator - Consolidation
"ConsolidateContext",
"ConsolidateResult",
# Operation Validator - File Conversion
"FileConvertResult",
# Operation Validator - Mental Model
"MentalModelGetContext",
"MentalModelGetResult",
@@ -96,8 +96,6 @@ class DefaultExtensionContext(ExtensionContext):
async def run_migration(self, schema: str) -> None:
"""Run migrations for a specific schema."""
import asyncio
from hindsight_api.config import get_config
from hindsight_api.migrations import (
ensure_embedding_dimension,
@@ -113,14 +111,10 @@ class DefaultExtensionContext(ExtensionContext):
if engine_url:
db_url = engine_url
# Run synchronous migration functions in a thread so the asyncio event loop
# remains free. This is critical for single-machine deployments where the
# worker runs in-process: if run_migrations() blocks the event loop, any
# in-flight asyncpg transactions cannot flush their COMMIT, and
# CREATE INDEX CONCURRENTLY inside the migration waits for those transactions
# forever — a deadlock.
run_migrations(db_url, schema=schema)
# Get config for vector extension setting
config = get_config()
await asyncio.to_thread(run_migrations, db_url, schema=schema)
# Ensure embedding column dimension matches the model's dimension
# This is needed because migrations create columns with default dimension
@@ -129,23 +123,15 @@ class DefaultExtensionContext(ExtensionContext):
if embeddings is not None:
dimension = getattr(embeddings, "dimension", None)
if dimension is not None:
await asyncio.to_thread(
ensure_embedding_dimension,
db_url,
dimension,
schema=schema,
vector_extension=config.vector_extension,
ensure_embedding_dimension(
db_url, dimension, schema=schema, vector_extension=config.vector_extension
)
# Ensure vector indexes match the configured extension
await asyncio.to_thread(
ensure_vector_extension, db_url, vector_extension=config.vector_extension, schema=schema
)
ensure_vector_extension(db_url, vector_extension=config.vector_extension, schema=schema)
# Ensure text search columns/indexes match the configured extension
await asyncio.to_thread(
ensure_text_search_extension, db_url, text_search_extension=config.text_search_extension, schema=schema
)
ensure_text_search_extension(db_url, text_search_extension=config.text_search_extension, schema=schema)
def get_memory_engine(self) -> "MemoryEngineInterface":
"""Get the memory engine interface."""
@@ -289,28 +289,6 @@ class MentalModelRefreshResult:
error: str | None = None
# =============================================================================
# File Conversion Post-operation Context
# =============================================================================
@dataclass
class FileConvertResult:
"""Result context for post-file-conversion hook.
Fired after a file is converted to markdown, before the retain step.
"""
bank_id: str
parser_name: str
filename: str
output_chars: int
output_text: str
request_context: "RequestContext"
success: bool = True
error: str | None = None
class OperationValidatorExtension(Extension, ABC):
"""
Validates and hooks into retain/recall/reflect/consolidate operations.
@@ -518,31 +496,6 @@ class OperationValidatorExtension(Extension, ABC):
"""
pass
# =========================================================================
# File Conversion - Post-operation hook (optional - override to implement)
# =========================================================================
async def on_file_convert_complete(self, result: FileConvertResult) -> None:
"""
Called after a file is converted to markdown (before the retain step).
Override to implement post-conversion logic such as:
- Billing for premium parsers (e.g., Iris)
- Usage tracking
- Audit logging
Args:
result: Result context containing:
- bank_id: Bank identifier
- parser_name: Name of the parser used (e.g., 'markitdown', 'iris')
- filename: Original filename
- output_chars: Character count of the converted markdown
- request_context: Request context with auth info
- success: Whether the conversion succeeded
- error: Error message (if failed)
"""
pass
# =========================================================================
# Mental Model - Pre-operation validation hook (optional - override to implement)
# =========================================================================
-10
View File
@@ -268,7 +268,6 @@ def main():
file_storage_azure_account_name=config.file_storage_azure_account_name,
file_storage_azure_account_key=config.file_storage_azure_account_key,
file_parser=config.file_parser,
file_parser_allowlist=config.file_parser_allowlist,
file_parser_iris_token=config.file_parser_iris_token,
file_parser_iris_org_id=config.file_parser_iris_org_id,
file_conversion_max_batch_size_mb=config.file_conversion_max_batch_size_mb,
@@ -276,13 +275,9 @@ def main():
enable_file_upload_api=config.enable_file_upload_api,
file_delete_after_retain=config.file_delete_after_retain,
enable_observations=config.enable_observations,
enable_observation_history=config.enable_observation_history,
enable_mental_model_history=config.enable_mental_model_history,
consolidation_batch_size=config.consolidation_batch_size,
consolidation_llm_batch_size=config.consolidation_llm_batch_size,
consolidation_max_tokens=config.consolidation_max_tokens,
consolidation_source_facts_max_tokens=config.consolidation_source_facts_max_tokens,
consolidation_source_facts_max_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation,
observations_mission=config.observations_mission,
entity_labels=config.entity_labels,
entities_allow_free_form=config.entities_allow_free_form,
@@ -312,10 +307,6 @@ def main():
otel_exporter_otlp_headers=config.otel_exporter_otlp_headers,
otel_service_name=config.otel_service_name,
otel_deployment_environment=config.otel_deployment_environment,
webhook_url=config.webhook_url,
webhook_secret=config.webhook_secret,
webhook_event_types=config.webhook_event_types,
webhook_delivery_poll_interval_seconds=config.webhook_delivery_poll_interval_seconds,
)
config.configure_logging()
if not args.daemon:
@@ -394,7 +385,6 @@ def main():
"ws": "wsproto", # Use wsproto instead of websockets to avoid deprecation warnings
"loop": loop_impl, # Explicitly set event loop implementation
"timeout_keep_alive": 30, # Exceed aiohttp's 15s client timeout so the client always closes first
"timeout_graceful_shutdown": 5, # Cap graceful shutdown at 5s; also enables force-kill on second Ctrl+C
}
# Add optional parameters if provided
+36 -59
View File
@@ -271,71 +271,48 @@ def register_mcp_tools(
def _apply_bank_tool_filtering(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
"""Filter bank-level mcp_enabled_tools from both tools/list and tool invocation.
Compatible with FastMCP 2.x (_tool_manager pattern) and 3.x (provider pattern).
Wraps _tool_manager.get_tools() so that:
- tools/list only returns permitted tools (they are hidden, not just blocked)
- tools/call for a disabled tool raises NotFoundError (via the manager) before run()
tool.run wrappers are kept as defense-in-depth for any caller that bypasses the manager.
"""
try:
tool_manager = mcp._tool_manager
original_get_tools = tool_manager.get_tools
async def _get_enabled_tools() -> set[str] | None:
"""Return the enabled tool set for the current bank, or None if unrestricted."""
bank_id = config.bank_id_resolver()
if not bank_id:
return None
request_context = _get_request_context(config)
bank_cfg = await memory._config_resolver.get_bank_config(bank_id, request_context)
enabled: list[str] | None = bank_cfg.get("mcp_enabled_tools")
if enabled is None:
return None
return set(enabled)
async def _filtered_get_tools():
all_tools = await original_get_tools()
bank_id = config.bank_id_resolver()
if not bank_id:
return all_tools
request_context = _get_request_context(config)
bank_cfg = await memory._config_resolver.get_bank_config(bank_id, request_context)
enabled: list[str] | None = bank_cfg.get("mcp_enabled_tools")
if enabled is None:
return all_tools
enabled_set = set(enabled)
return {k: v for k, v in all_tools.items() if k in enabled_set}
if hasattr(mcp, "list_tools"):
# FastMCP 3.x: wrap list_tools() and get_tool() on the instance
original_list_tools = mcp.list_tools
original_get_tool = mcp.get_tool
setattr(tool_manager, "get_tools", _filtered_get_tools)
async def _filtered_list_tools(**kwargs):
tools = await original_list_tools(**kwargs)
enabled_set = await _get_enabled_tools()
if enabled_set is None:
return tools
return [t for t in tools if t.name in enabled_set]
# Defense-in-depth: also wrap tool.run for any direct caller that bypasses the manager
for name, tool in tool_manager._tools.items():
original_run = tool.run
async def _filtered_get_tool(name, **kwargs):
enabled_set = await _get_enabled_tools()
if enabled_set is not None and name not in enabled_set:
return None # FastMCP treats None as "not found" → raises NotFoundError
return await original_get_tool(name, **kwargs)
async def _filtered_run(arguments, _name=name, _orig=original_run):
bank_id = config.bank_id_resolver()
if bank_id:
request_context = _get_request_context(config)
bank_cfg = await memory._config_resolver.get_bank_config(bank_id, request_context)
enabled: list[str] | None = bank_cfg.get("mcp_enabled_tools")
if enabled is not None and _name not in enabled:
raise ValueError(f"Tool '{_name}' is not enabled for bank '{bank_id}'")
return await _orig(arguments)
object.__setattr__(mcp, "list_tools", _filtered_list_tools)
object.__setattr__(mcp, "get_tool", _filtered_get_tool)
elif hasattr(mcp, "_tool_manager"):
# FastMCP 2.x: wrap _tool_manager.get_tools() and tool.run()
try:
tool_manager = mcp._tool_manager
original_get_tools = tool_manager.get_tools
async def _filtered_get_tools():
all_tools = await original_get_tools()
enabled_set = await _get_enabled_tools()
if enabled_set is None:
return all_tools
return {k: v for k, v in all_tools.items() if k in enabled_set}
setattr(tool_manager, "get_tools", _filtered_get_tools)
for name, tool in tool_manager._tools.items():
original_run = tool.run
async def _filtered_run(arguments, _name=name, _orig=original_run):
enabled_set = await _get_enabled_tools()
if enabled_set is not None and _name not in enabled_set:
raise ValueError(f"Tool '{_name}' is not enabled for bank '{config.bank_id_resolver()}'")
return await _orig(arguments)
object.__setattr__(tool, "run", _filtered_run)
except (AttributeError, KeyError) as e:
logger.warning(f"Could not apply bank tool filtering (v2): {e}")
else:
logger.warning("Could not apply bank tool filtering: unknown FastMCP version")
object.__setattr__(tool, "run", _filtered_run)
except (AttributeError, KeyError) as e:
logger.warning(f"Could not apply bank tool filtering: {e}")
def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
+118 -138
View File
@@ -18,14 +18,13 @@ No alembic.ini required - all configuration is done programmatically.
import hashlib
import logging
import os
import threading
import time
from pathlib import Path
from alembic import command
from alembic.config import Config
from alembic.script.revision import ResolutionError
from sqlalchemy import Connection, create_engine, text
from sqlalchemy import create_engine, text
from .utils import mask_network_location
@@ -34,13 +33,6 @@ logger = logging.getLogger(__name__)
# Advisory lock ID for migrations (arbitrary unique number)
MIGRATION_LOCK_ID = 123456789
# Alembic's command.upgrade() is NOT thread-safe: it uses module-level global
# proxies (context._proxy, script) that get overwritten when two threads call
# upgrade() concurrently. This causes migrations to target the wrong schema
# and crash with "relation already exists" or KeyError: 'script'.
# Serialize all Alembic invocations with a process-level lock.
_alembic_lock = threading.Lock()
def _detect_vector_extension(conn, vector_extension: str = "pgvector") -> str:
"""
@@ -152,12 +144,9 @@ def _run_migrations_internal(database_url: str, script_location: str, schema: st
if schema:
alembic_cfg.set_main_option("target_schema", schema)
# Run migrations under a process-level lock. Alembic uses module-level
# global proxies that are not thread-safe, so concurrent command.upgrade()
# calls from different threads corrupt each other's context.
# Run migrations
try:
with _alembic_lock:
command.upgrade(alembic_cfg, "head")
command.upgrade(alembic_cfg, "head")
except ResolutionError as e:
# This happens during rolling deployments when a newer version of the code
# has already run migrations, and this older replica doesn't have the new
@@ -471,125 +460,6 @@ def check_migration_status(
return None, None
def _migrate_table_embedding_dimension(
conn: Connection,
schema_name: str,
table_name: str,
required_dimension: int,
vector_ext: str,
) -> None:
"""
Migrate the embedding column of a single table to the required dimension.
- If dimensions match: no action needed
- If dimensions differ and table is empty: ALTER COLUMN to new dimension
- If dimensions differ and table has data: raise error with migration guidance
"""
current_dim = conn.execute(
text("""
SELECT atttypmod
FROM pg_attribute a
JOIN pg_class c ON a.attrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE n.nspname = :schema
AND c.relname = :table
AND a.attname = 'embedding'
"""),
{"schema": schema_name, "table": table_name},
).scalar()
if current_dim is None:
logger.debug(f"No embedding column found on {table_name}, skipping")
return
if current_dim == required_dimension:
logger.debug(f"Embedding dimension OK for {table_name}: {current_dim}")
return
logger.info(
f"Embedding dimension mismatch on {table_name}: database has {current_dim}, model requires {required_dimension}"
)
row_count = conn.execute(
text(f"SELECT COUNT(*) FROM {schema_name}.{table_name} WHERE embedding IS NOT NULL")
).scalar()
if row_count > 0:
raise RuntimeError(
f"Cannot change embedding dimension from {current_dim} to {required_dimension}: "
f"{table_name} table contains {row_count} rows with embeddings. "
f"To change dimensions, you must either:\n"
f" 1. Re-embed all data: DELETE FROM {schema_name}.{table_name}; then restart\n"
f" 2. Use a model with {current_dim}-dimensional embeddings"
)
logger.info(f"Altering {table_name}.embedding column dimension from {current_dim} to {required_dimension}")
# Drop existing vector index (works for both HNSW and vchordrq)
conn.execute(
text(f"""
DO $$
DECLARE idx_name TEXT;
BEGIN
FOR idx_name IN
SELECT indexname FROM pg_indexes
WHERE schemaname = '{schema_name}'
AND tablename = '{table_name}'
AND (indexdef LIKE '%hnsw%' OR indexdef LIKE '%vchordrq%' OR indexdef LIKE '%diskann%')
AND indexdef LIKE '%embedding%'
LOOP
EXECUTE 'DROP INDEX IF EXISTS {schema_name}.' || idx_name;
END LOOP;
END $$;
""")
)
conn.execute(
text(f"ALTER TABLE {schema_name}.{table_name} ALTER COLUMN embedding TYPE vector({required_dimension})")
)
conn.commit()
# Recreate index with appropriate type based on detected extension
if vector_ext == "pgvectorscale":
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_{table_name}_embedding_diskann
ON {schema_name}.{table_name}
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
""")
)
logger.info(f"Created DiskANN index on {table_name} for {required_dimension}-dimensional embeddings")
elif vector_ext == "vchord":
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_{table_name}_embedding_vchordrq
ON {schema_name}.{table_name}
USING vchordrq (embedding vector_l2_ops)
""")
)
logger.info(f"Created vchordrq index on {table_name} for {required_dimension}-dimensional embeddings")
else: # pgvector
if required_dimension > 2000:
raise RuntimeError(
f"Embedding dimension {required_dimension} exceeds pgvector HNSW index limit of 2000. "
f"Use an embedding model with <= 2000 dimensions, or switch to a vector extension "
f"that supports higher dimensions (e.g., pgvectorscale/DiskANN)."
)
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_{table_name}_embedding_hnsw
ON {schema_name}.{table_name}
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
""")
)
logger.info(f"Created HNSW index on {table_name} for {required_dimension}-dimensional embeddings")
conn.commit()
logger.info(f"Successfully changed {table_name}.embedding dimension to {required_dimension}")
def ensure_embedding_dimension(
database_url: str,
required_dimension: int,
@@ -597,9 +467,10 @@ def ensure_embedding_dimension(
vector_extension: str = "pgvector",
) -> None:
"""
Ensure the embedding column dimension matches the model's dimension for all tables.
Ensure the embedding column dimension matches the model's dimension.
Checks and adjusts memory_units.embedding and mental_models.embedding:
This function checks the current vector column dimension in the database
and adjusts it if necessary:
- If dimensions match: no action needed
- If dimensions differ and table is empty: ALTER COLUMN to new dimension
- If dimensions differ and table has data: raise error with migration guidance
@@ -617,7 +488,7 @@ def ensure_embedding_dimension(
engine = create_engine(database_url)
with engine.connect() as conn:
# Check if memory_units table exists (proxy for schema being initialized)
# Check if memory_units table exists
table_exists = conn.execute(
text("""
SELECT EXISTS (
@@ -636,8 +507,117 @@ def ensure_embedding_dimension(
vector_ext = _detect_vector_extension(conn, vector_extension)
logger.info(f"Using vector extension: {vector_ext}")
_migrate_table_embedding_dimension(conn, schema_name, "memory_units", required_dimension, vector_ext)
_migrate_table_embedding_dimension(conn, schema_name, "mental_models", required_dimension, vector_ext)
# Get current column dimension from pg_attribute
# pgvector stores dimension in atttypmod
current_dim = conn.execute(
text("""
SELECT atttypmod
FROM pg_attribute a
JOIN pg_class c ON a.attrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE n.nspname = :schema
AND c.relname = 'memory_units'
AND a.attname = 'embedding'
"""),
{"schema": schema_name},
).scalar()
if current_dim is None:
logger.warning("Could not determine current embedding dimension, skipping check")
return
# pgvector stores dimension directly in atttypmod (no offset like other types)
current_dimension = current_dim
if current_dimension == required_dimension:
logger.debug(f"Embedding dimension OK: {current_dimension}")
return
logger.info(
f"Embedding dimension mismatch: database has {current_dimension}, model requires {required_dimension}"
)
# Check if table has data
row_count = conn.execute(
text(f"SELECT COUNT(*) FROM {schema_name}.memory_units WHERE embedding IS NOT NULL")
).scalar()
if row_count > 0:
raise RuntimeError(
f"Cannot change embedding dimension from {current_dimension} to {required_dimension}: "
f"memory_units table contains {row_count} rows with embeddings. "
f"To change dimensions, you must either:\n"
f" 1. Re-embed all data: DELETE FROM {schema_name}.memory_units; then restart\n"
f" 2. Use a model with {current_dimension}-dimensional embeddings"
)
# Table is empty, safe to alter column
logger.info(f"Altering embedding column dimension from {current_dimension} to {required_dimension}")
# Drop existing vector index (works for both HNSW and vchordrq)
conn.execute(
text(f"""
DO $$
DECLARE idx_name TEXT;
BEGIN
FOR idx_name IN
SELECT indexname FROM pg_indexes
WHERE schemaname = '{schema_name}'
AND tablename = 'memory_units'
AND (indexdef LIKE '%hnsw%' OR indexdef LIKE '%vchordrq%')
AND indexdef LIKE '%embedding%'
LOOP
EXECUTE 'DROP INDEX IF EXISTS {schema_name}.' || idx_name;
END LOOP;
END $$;
""")
)
# Alter the column type
conn.execute(
text(f"ALTER TABLE {schema_name}.memory_units ALTER COLUMN embedding TYPE vector({required_dimension})")
)
conn.commit()
# Recreate index with appropriate type based on detected extension
if vector_ext == "pgvectorscale":
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_embedding_diskann
ON {schema_name}.memory_units
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
""")
)
logger.info(f"Created DiskANN index for {required_dimension}-dimensional embeddings")
elif vector_ext == "vchord":
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_embedding_vchordrq
ON {schema_name}.memory_units
USING vchordrq (embedding vector_l2_ops)
""")
)
logger.info(f"Created vchordrq index for {required_dimension}-dimensional embeddings")
else: # pgvector
if required_dimension > 2000:
raise RuntimeError(
f"Embedding dimension {required_dimension} exceeds pgvector HNSW index limit of 2000. "
f"Use an embedding model with <= 2000 dimensions, or switch to a vector extension "
f"that supports higher dimensions (e.g., pgvectorscale/DiskANN)."
)
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_embedding_hnsw
ON {schema_name}.memory_units
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
""")
)
logger.info(f"Created HNSW index for {required_dimension}-dimensional embeddings")
conn.commit()
logger.info(f"Successfully changed embedding dimension to {required_dimension}")
def ensure_vector_extension(
@@ -1,13 +0,0 @@
"""Webhook system for Hindsight API event notifications."""
from .manager import WebhookManager
from .models import ConsolidationEventData, RetainEventData, WebhookConfig, WebhookEvent, WebhookEventType
__all__ = [
"WebhookManager",
"WebhookConfig",
"WebhookEvent",
"WebhookEventType",
"ConsolidationEventData",
"RetainEventData",
]
@@ -1,242 +0,0 @@
"""Webhook manager for delivering event notifications."""
import hashlib
import hmac
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING
import asyncpg
from .models import WebhookConfig, WebhookEvent, WebhookHttpConfig
if TYPE_CHECKING:
from hindsight_api.extensions.tenant import TenantExtension
logger = logging.getLogger(__name__)
# Retry delay schedule in seconds: 5 retries after the first attempt.
# Fast early retries catch transient failures; later retries handle longer outages.
RETRY_DELAYS = [5, 300, 1800, 7200, 18000]
MAX_ATTEMPTS = len(RETRY_DELAYS) + 1 # first attempt + len(RETRY_DELAYS) retries
def _fq_table(table: str, schema: str | None = None) -> str:
"""Get fully-qualified table name with optional schema prefix."""
if schema:
return f'"{schema}".{table}'
return table
def _parse_http_config(value: str | dict | None) -> WebhookHttpConfig:
"""Parse http_config column value (JSONB returned as text or dict) into a model."""
if value is None:
return WebhookHttpConfig()
if isinstance(value, str):
return WebhookHttpConfig.model_validate_json(value)
return WebhookHttpConfig.model_validate(value)
class WebhookManager:
"""
Manages webhook registration and event firing.
Supports both global webhooks (configured via env vars) and per-bank
webhooks stored in the database. Deliveries are queued as async_operations
tasks (operation_type='webhook_delivery') and picked up by the worker poller.
"""
def __init__(
self,
pool: asyncpg.Pool,
global_webhooks: list[WebhookConfig],
tenant_extension: "TenantExtension | None" = None,
):
self._pool = pool
self._global_webhooks = global_webhooks
self._tenant_extension = tenant_extension
def _sign_payload(self, secret: str, payload_bytes: bytes) -> str:
"""Compute HMAC-SHA256 signature for a payload."""
return "sha256=" + hmac.new(secret.encode(), payload_bytes, hashlib.sha256).hexdigest()
async def fire_event(self, event: WebhookEvent, schema: str | None = None) -> None:
"""
Queue webhook deliveries for an event as async_operations tasks.
Loads per-bank and global webhooks, inserts pending webhook_delivery tasks for
any webhook whose event_types list matches the fired event type. The worker
poller picks these up and calls MemoryEngine._handle_webhook_delivery().
Args:
event: The event to deliver.
schema: Database schema (for multi-tenant). None = default schema.
"""
webhook_table = _fq_table("webhooks", schema)
ops_table = _fq_table("async_operations", schema)
now = datetime.now(timezone.utc)
payload_str = event.model_dump_json()
try:
# Load per-bank webhooks from DB (bank-specific + global NULL rows)
rows = await self._pool.fetch(
f"""
SELECT id, bank_id, url, secret, event_types, enabled, http_config::text
FROM {webhook_table}
WHERE (bank_id = $1 OR bank_id IS NULL) AND enabled = true
""",
event.bank_id,
)
db_webhooks = [
WebhookConfig(
id=str(row["id"]),
bank_id=row["bank_id"],
url=row["url"],
secret=row["secret"],
event_types=list(row["event_types"]) if row["event_types"] else [],
enabled=row["enabled"],
http_config=_parse_http_config(row["http_config"]),
)
for row in rows
]
# Merge with global webhooks from env config
all_webhooks = self._global_webhooks + db_webhooks
matched = 0
for webhook in all_webhooks:
if not webhook.enabled:
continue
if event.event.value not in webhook.event_types:
continue
operation_id = uuid.uuid4()
webhook_id = webhook.id if webhook.id else None
task_payload = json.dumps(
{
"type": "webhook_delivery",
"operation_id": str(operation_id),
"bank_id": event.bank_id,
"url": webhook.url,
"secret": webhook.secret,
"event_type": event.event.value,
"payload": payload_str,
"webhook_id": webhook_id,
"http_config": webhook.http_config.model_dump(),
}
)
await self._pool.execute(
f"""
INSERT INTO {ops_table}
(operation_id, bank_id, operation_type, status, task_payload, result_metadata, created_at, updated_at)
VALUES ($1, $2, 'webhook_delivery', 'pending', $3::jsonb, '{{}}'::jsonb, $4, $4)
""",
operation_id,
event.bank_id,
task_payload,
now,
)
matched += 1
logger.debug(f"Fired webhook event {event.event} for bank {event.bank_id}: {matched} delivery(ies) queued")
except Exception as e:
logger.error(f"Failed to queue webhook deliveries for event {event.event}: {e}")
async def fire_event_with_conn(
self, event: WebhookEvent, conn: asyncpg.Connection, schema: str | None = None
) -> None:
"""
Queue webhook deliveries within an existing database connection/transaction.
Identical to fire_event() but uses the provided connection instead of acquiring
one from the pool. Use this to atomically insert delivery tasks in the same
transaction as the primary operation (transactional outbox pattern).
Args:
event: The event to deliver.
conn: Existing asyncpg connection (may be inside an active transaction).
schema: Database schema (for multi-tenant). None = default schema.
"""
webhook_table = _fq_table("webhooks", schema)
ops_table = _fq_table("async_operations", schema)
now = datetime.now(timezone.utc)
payload_str = event.model_dump_json()
try:
rows = await conn.fetch(
f"""
SELECT id, bank_id, url, secret, event_types, enabled, http_config::text
FROM {webhook_table}
WHERE (bank_id = $1 OR bank_id IS NULL) AND enabled = true
""",
event.bank_id,
)
db_webhooks = [
WebhookConfig(
id=str(row["id"]),
bank_id=row["bank_id"],
url=row["url"],
secret=row["secret"],
event_types=list(row["event_types"]) if row["event_types"] else [],
enabled=row["enabled"],
http_config=_parse_http_config(row["http_config"]),
)
for row in rows
]
all_webhooks = self._global_webhooks + db_webhooks
matched = 0
for webhook in all_webhooks:
if not webhook.enabled:
continue
if event.event.value not in webhook.event_types:
continue
operation_id = uuid.uuid4()
webhook_id = webhook.id if webhook.id else None
task_payload = json.dumps(
{
"type": "webhook_delivery",
"operation_id": str(operation_id),
"bank_id": event.bank_id,
"url": webhook.url,
"secret": webhook.secret,
"event_type": event.event.value,
"payload": payload_str,
"webhook_id": webhook_id,
"http_config": webhook.http_config.model_dump(),
}
)
await conn.execute(
f"""
INSERT INTO {ops_table}
(operation_id, bank_id, operation_type, status, task_payload, result_metadata, created_at, updated_at)
VALUES ($1, $2, 'webhook_delivery', 'pending', $3::jsonb, '{{}}'::jsonb, $4, $4)
""",
operation_id,
event.bank_id,
task_payload,
now,
)
matched += 1
logger.debug(
f"Fired webhook event {event.event} for bank {event.bank_id}: {matched} delivery(ies) queued (in-transaction)"
)
except Exception as e:
logger.error(
f"Failed to queue webhook deliveries (in-transaction) for event {event.event}: {e}. "
"CRITICAL: The enclosing database transaction is now aborted and will roll back all changes."
)
raise
@@ -1,51 +0,0 @@
"""Pydantic models for the webhook system."""
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, Field
class WebhookEventType(StrEnum):
CONSOLIDATION_COMPLETED = "consolidation.completed"
RETAIN_COMPLETED = "retain.completed"
class ConsolidationEventData(BaseModel):
observations_created: int | None = None
observations_updated: int | None = None
observations_deleted: int | None = None
error_message: str | None = None
class RetainEventData(BaseModel):
document_id: str | None = None
tags: list[str] | None = None
class WebhookEvent(BaseModel):
event: WebhookEventType
bank_id: str
operation_id: str
status: str # "completed" or "failed"
timestamp: datetime
data: ConsolidationEventData | RetainEventData
class WebhookHttpConfig(BaseModel):
"""HTTP delivery configuration for a webhook."""
method: str = Field(default="POST", description="HTTP method: GET or POST")
timeout_seconds: int = Field(default=30, description="HTTP request timeout in seconds")
headers: dict[str, str] = Field(default_factory=dict, description="Custom HTTP headers")
params: dict[str, str] = Field(default_factory=dict, description="Custom HTTP query parameters")
class WebhookConfig(BaseModel):
id: str
bank_id: str | None
url: str
secret: str | None
event_types: list[str]
enabled: bool
http_config: WebhookHttpConfig = Field(default_factory=WebhookHttpConfig)
@@ -1,9 +0,0 @@
from datetime import datetime
class RetryTaskAt(Exception):
"""Raise from a task handler to schedule a retry at a specific time."""
def __init__(self, retry_at: datetime, message: str = ""):
self.retry_at = retry_at
super().__init__(message)
@@ -219,6 +219,7 @@ def main():
worker_id=args.worker_id,
executor=memory.execute_task,
poll_interval_ms=args.poll_interval,
max_retries=args.max_retries,
schema=schema,
tenant_extension=tenant_extension,
max_slots=config.worker_max_slots,
+49 -38
View File
@@ -14,8 +14,6 @@ from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from .exceptions import RetryTaskAt
if TYPE_CHECKING:
import asyncpg
@@ -59,6 +57,7 @@ class WorkerPoller:
worker_id: str,
executor: Callable[[dict[str, Any]], Awaitable[None]],
poll_interval_ms: int = 500,
max_retries: int = 3,
schema: str | None = None,
tenant_extension: "TenantExtension | None" = None,
max_slots: int = 10,
@@ -72,6 +71,7 @@ class WorkerPoller:
worker_id: Unique identifier for this worker
executor: Async function to execute tasks (typically MemoryEngine.execute_task)
poll_interval_ms: Interval between polls when no tasks found (milliseconds)
max_retries: Maximum retry attempts before marking task as failed
schema: Database schema for single-tenant support (deprecated, use tenant_extension)
tenant_extension: Extension for dynamic multi-tenant discovery. If None, creates a
DefaultTenantExtension with the configured schema.
@@ -82,6 +82,7 @@ class WorkerPoller:
self._worker_id = worker_id
self._executor = executor
self._poll_interval_ms = poll_interval_ms
self._max_retries = max_retries
self._schema = schema
# Always set tenant extension (use DefaultTenantExtension if none provided)
if tenant_extension is None:
@@ -217,12 +218,11 @@ class WorkerPoller:
# 1. Claim non-consolidation tasks (up to limit)
non_consolidation_rows = await conn.fetch(
f"""
SELECT operation_id, task_payload, retry_count
SELECT operation_id, task_payload
FROM {table}
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type != 'consolidation'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY created_at
LIMIT $1
FOR UPDATE SKIP LOCKED
@@ -238,12 +238,11 @@ class WorkerPoller:
if consolidation_limit > 0 and remaining_limit > 0:
consolidation_rows = await conn.fetch(
f"""
SELECT operation_id, task_payload, retry_count
SELECT operation_id, task_payload
FROM {table} AS pending
WHERE status = 'pending'
AND task_payload IS NOT NULL
AND operation_type = 'consolidation'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
AND NOT EXISTS (
SELECT 1 FROM {table} AS processing
WHERE processing.bank_id = pending.bank_id
@@ -275,19 +274,14 @@ class WorkerPoller:
)
# Parse and return task payloads with schema context
result = []
for row in all_rows:
task_dict = json.loads(row["task_payload"])
task_dict["_retry_count"] = row["retry_count"]
task_dict["_operation_id"] = str(row["operation_id"])
result.append(
ClaimedTask(
operation_id=str(row["operation_id"]),
task_dict=task_dict,
schema=schema,
)
return [
ClaimedTask(
operation_id=str(row["operation_id"]),
task_dict=json.loads(row["task_payload"]),
schema=schema,
)
return result
for row in all_rows
]
async def _mark_completed(self, operation_id: str, schema: str | None):
"""Mark a task as completed."""
@@ -316,22 +310,40 @@ class WorkerPoller:
error_message,
)
async def _schedule_retry(self, operation_id: str, retry_at: "Any", error_message: str, schema: str | None):
"""Reset task to pending with a future retry timestamp."""
async def _retry_or_fail(self, operation_id: str, error_message: str, schema: str | None):
"""Increment retry count or mark as failed if max retries exceeded."""
table = fq_table("async_operations", schema)
error_message = error_message[:5000] if len(error_message) > 5000 else error_message
await self._pool.execute(
f"""
UPDATE {table}
SET status = 'pending', next_retry_at = $2, worker_id = NULL, claimed_at = NULL,
retry_count = retry_count + 1, error_message = $3, updated_at = now()
WHERE operation_id = $1
""",
# Get current retry count
row = await self._pool.fetchrow(
f"SELECT retry_count FROM {table} WHERE operation_id = $1",
operation_id,
retry_at,
error_message,
)
logger.warning(f"Task {operation_id} scheduled for retry at {retry_at}: {error_message}")
if row is None:
logger.warning(f"Operation {operation_id} not found, cannot retry")
return
retry_count = row["retry_count"]
if retry_count >= self._max_retries:
# Max retries exceeded, mark as failed
await self._mark_failed(
operation_id, f"Max retries ({self._max_retries}) exceeded. Last error: {error_message}", schema
)
logger.error(f"Task {operation_id} failed after {retry_count} retries")
else:
# Increment retry and reset to pending
await self._pool.execute(
f"""
UPDATE {table}
SET status = 'pending', worker_id = NULL, claimed_at = NULL,
retry_count = retry_count + 1, updated_at = now()
WHERE operation_id = $1
""",
operation_id,
)
logger.warning(f"Task {operation_id} failed, will retry (attempt {retry_count + 1}/{self._max_retries})")
async def execute_task(self, task: ClaimedTask):
"""Execute a single task as a background job (fire-and-forget)."""
@@ -366,10 +378,11 @@ class WorkerPoller:
async def _execute_task_inner(self, task: ClaimedTask):
"""Inner task execution with retry/fail handling.
Tasks that want to be retried raise RetryTaskAt; the poller sets next_retry_at
and resets status to 'pending'. All other exceptions are marked as failed immediately.
Non-retryable failures (e.g., file_convert_retain) are handled by the executor
internally — it marks the operation as failed and returns normally.
Retryable task failures are re-raised by the executor (MemoryEngine.execute_task)
and handled here via _retry_or_fail, which resets status='pending' (or marks as
'failed' after max retries). Non-retryable failures (e.g., file_convert_retain) are
handled by the executor internally — it marks the operation as failed and returns
normally, so no exception reaches here.
"""
task_type = task.task_dict.get("type", "unknown")
bank_id = task.task_dict.get("bank_id", "unknown")
@@ -381,12 +394,10 @@ class WorkerPoller:
task.task_dict["_schema"] = task.schema
await self._executor(task.task_dict)
logger.debug(f"Task {task.operation_id} execution finished")
except RetryTaskAt as e:
await self._schedule_retry(task.operation_id, e.retry_at, str(e), task.schema)
except Exception as e:
logger.error(f"Task {task.operation_id} failed: {e}")
traceback.print_exc()
await self._mark_failed(task.operation_id, str(e), task.schema)
await self._retry_or_fail(task.operation_id, str(e), task.schema)
async def recover_own_tasks(self) -> int:
"""
+4 -9
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api"
version = "0.4.17"
version = "0.4.15"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
@@ -43,8 +43,8 @@ dependencies = [
"cohere>=5.0.0",
"flashrank>=0.2.0",
"litellm>=1.0.0",
"markitdown[pdf,docx,pptx,xlsx,xls]>=0.1.4", # File to markdown conversion
"obstore>=0.4.0", # S3/GCS/Azure object storage client (Rust-backed)
"markitdown[pdf,docx,pptx,xlsx,xls]>=0.1.4", # File to markdown conversion
"obstore>=0.4.0", # S3/GCS/Azure object storage client (Rust-backed)
# Local ML models for embeddings/reranking - can be excluded in Docker with INCLUDE_LOCAL_MODELS=false
"sentence-transformers>=3.3.0",
"transformers>=4.53.0", # Security fixes for ReDoS vulnerabilities
@@ -53,16 +53,11 @@ dependencies = [
# Transitive dependency security fixes
"pyasn1>=0.6.2", # DoS vulnerability fix
"urllib3>=2.6.3", # Decompression-bomb safeguards bypass fix
"langchain-core>=1.2.11", # Serialization injection + SSRF vulnerability fix
"langsmith>=0.6.3", # SSRF via tracing header injection fix
"protobuf>=6.33.5", # JSON recursion depth bypass fix
"pillow>=12.1.1", # Out-of-bounds write in PSD image loading fix
"cryptography>=46.0.5", # Subgroup attack vulnerability fix
"langchain-core>=1.2.5", # Serialization injection vulnerability fix
"filelock>=3.20.1", # TOCTOU race condition fix
"authlib>=1.6.6", # Account takeover vulnerability fix
"aiohttp>=3.13.3", # Multiple DoS vulnerabilities
"claude-agent-sdk>=0.1.27",
"einops>=0.8.2",
]
[project.optional-dependencies]
@@ -15,9 +15,7 @@ import asyncpg
import pytest
import pytest_asyncio
import hindsight_api.admin.cli as admin_cli
from hindsight_api.admin.cli import _backup, _restore, BACKUP_TABLES
from hindsight_api.extensions import Tenant
from hindsight_api.migrations import run_migrations
@@ -292,196 +290,3 @@ async def test_backup_restore_preserves_all_column_types(backup_test_schema):
finally:
if backup_path.exists():
backup_path.unlink()
@pytest.mark.asyncio
async def test_run_migration_without_schema_discovers_and_deduplicates_schemas(monkeypatch):
"""run-db-migration without --schema should include the base schema and deduplicate tenant schemas."""
calls: dict[str, list] = {
"run_migrations": [],
"ensure_vector_extension": [],
"ensure_text_search_extension": [],
}
class MockTenantExtension:
async def list_tenants(self):
return [
Tenant(schema="public"),
Tenant(schema="tenant_demo"),
Tenant(schema="tenant_demo"),
]
async def fake_resolve_database_url(db_url: str) -> str:
return f"resolved::{db_url}"
def fake_run_migrations(database_url: str, schema: str | None = None) -> None:
calls["run_migrations"].append((database_url, schema))
def fake_ensure_vector_extension(
database_url: str,
vector_extension: str = "pgvector",
schema: str | None = None,
) -> None:
calls["ensure_vector_extension"].append((database_url, vector_extension, schema))
def fake_ensure_text_search_extension(
database_url: str,
text_search_extension: str = "native",
schema: str | None = None,
) -> None:
calls["ensure_text_search_extension"].append((database_url, text_search_extension, schema))
monkeypatch.setenv("HINDSIGHT_API_DATABASE_URL", "postgresql://test")
monkeypatch.setattr(admin_cli, "load_extension", lambda *args, **kwargs: MockTenantExtension())
monkeypatch.setattr(admin_cli, "resolve_database_url", fake_resolve_database_url)
from hindsight_api import migrations as migrations_module
monkeypatch.setattr(migrations_module, "run_migrations", fake_run_migrations)
monkeypatch.setattr(migrations_module, "ensure_vector_extension", fake_ensure_vector_extension)
monkeypatch.setattr(migrations_module, "ensure_text_search_extension", fake_ensure_text_search_extension)
schemas = await admin_cli._run_migration("postgresql://test")
assert schemas == ["public", "tenant_demo"]
assert calls["run_migrations"] == [
("resolved::postgresql://test", "public"),
("resolved::postgresql://test", "tenant_demo"),
]
assert calls["ensure_vector_extension"] == [
("resolved::postgresql://test", "pgvector", "public"),
("resolved::postgresql://test", "pgvector", "tenant_demo"),
]
assert calls["ensure_text_search_extension"] == [
("resolved::postgresql://test", "native", "public"),
("resolved::postgresql://test", "native", "tenant_demo"),
]
@pytest.mark.asyncio
async def test_run_migration_without_schema_runs_optional_post_migration_hooks(monkeypatch):
"""Embedding dimension sync should be optional, while vector/text checks always run."""
monkeypatch.setenv("HINDSIGHT_API_DATABASE_URL", "postgresql://test")
calls: dict[str, list] = {
"run_migrations": [],
"ensure_embedding_dimension": [],
"ensure_vector_extension": [],
"ensure_text_search_extension": [],
}
class MockTenantExtension:
async def list_tenants(self):
return [Tenant(schema="tenant_demo")]
async def fake_resolve_database_url(db_url: str) -> str:
return f"resolved::{db_url}"
def fake_run_migrations(database_url: str, schema: str | None = None) -> None:
calls["run_migrations"].append((database_url, schema))
def fake_ensure_embedding_dimension(
database_url: str,
dimension: int,
schema: str | None = None,
vector_extension: str = "pgvector",
) -> None:
calls["ensure_embedding_dimension"].append((database_url, dimension, schema, vector_extension))
def fake_ensure_vector_extension(
database_url: str,
vector_extension: str = "pgvector",
schema: str | None = None,
) -> None:
calls["ensure_vector_extension"].append((database_url, vector_extension, schema))
def fake_ensure_text_search_extension(
database_url: str,
text_search_extension: str = "native",
schema: str | None = None,
) -> None:
calls["ensure_text_search_extension"].append((database_url, text_search_extension, schema))
monkeypatch.setattr(admin_cli, "load_extension", lambda *args, **kwargs: MockTenantExtension())
monkeypatch.setattr(admin_cli, "resolve_database_url", fake_resolve_database_url)
from hindsight_api import migrations as migrations_module
monkeypatch.setattr(migrations_module, "run_migrations", fake_run_migrations)
monkeypatch.setattr(migrations_module, "ensure_embedding_dimension", fake_ensure_embedding_dimension)
monkeypatch.setattr(migrations_module, "ensure_vector_extension", fake_ensure_vector_extension)
monkeypatch.setattr(migrations_module, "ensure_text_search_extension", fake_ensure_text_search_extension)
schemas = await admin_cli._run_migration(
"postgresql://test",
base_schema="public",
embedding_dimension=384,
)
assert schemas == ["public", "tenant_demo"]
assert calls["run_migrations"] == [
("resolved::postgresql://test", "public"),
("resolved::postgresql://test", "tenant_demo"),
]
assert calls["ensure_embedding_dimension"] == [
("resolved::postgresql://test", 384, "public", "pgvector"),
("resolved::postgresql://test", 384, "tenant_demo", "pgvector"),
]
assert calls["ensure_vector_extension"] == [
("resolved::postgresql://test", "pgvector", "public"),
("resolved::postgresql://test", "pgvector", "tenant_demo"),
]
assert calls["ensure_text_search_extension"] == [
("resolved::postgresql://test", "native", "public"),
("resolved::postgresql://test", "native", "tenant_demo"),
]
@pytest.mark.asyncio
async def test_run_migration_with_schema_only_runs_requested_schema(monkeypatch):
"""run-db-migration with --schema should only migrate the requested schema."""
monkeypatch.setenv("HINDSIGHT_API_DATABASE_URL", "postgresql://test")
calls: dict[str, list] = {
"run_migrations": [],
"ensure_vector_extension": [],
"ensure_text_search_extension": [],
}
class MockTenantExtension:
async def list_tenants(self):
return [Tenant(schema="tenant_demo"), Tenant(schema="tenant_other")]
async def fake_resolve_database_url(db_url: str) -> str:
return f"resolved::{db_url}"
def fake_run_migrations(database_url: str, schema: str | None = None) -> None:
calls["run_migrations"].append((database_url, schema))
def fake_ensure_vector_extension(
database_url: str,
vector_extension: str = "pgvector",
schema: str | None = None,
) -> None:
calls["ensure_vector_extension"].append((database_url, vector_extension, schema))
def fake_ensure_text_search_extension(
database_url: str,
text_search_extension: str = "native",
schema: str | None = None,
) -> None:
calls["ensure_text_search_extension"].append((database_url, text_search_extension, schema))
monkeypatch.setattr(admin_cli, "load_extension", lambda *args, **kwargs: MockTenantExtension())
monkeypatch.setattr(admin_cli, "resolve_database_url", fake_resolve_database_url)
from hindsight_api import migrations as migrations_module
monkeypatch.setattr(migrations_module, "run_migrations", fake_run_migrations)
monkeypatch.setattr(migrations_module, "ensure_vector_extension", fake_ensure_vector_extension)
monkeypatch.setattr(migrations_module, "ensure_text_search_extension", fake_ensure_text_search_extension)
schemas = await admin_cli._run_migration("postgresql://test", schema="tenant_demo")
assert schemas == ["tenant_demo"]
assert calls["run_migrations"] == [("resolved::postgresql://test", "tenant_demo")]
assert calls["ensure_vector_extension"] == [("resolved::postgresql://test", "pgvector", "tenant_demo")]
assert calls["ensure_text_search_extension"] == [("resolved::postgresql://test", "native", "tenant_demo")]
+1
View File
@@ -413,6 +413,7 @@ async def test_worker_batch_recovery(memory, request_context):
worker_id="test_worker_recovery",
executor=memory,
poll_interval_ms=100,
max_retries=3,
schema=schema,
tenant_extension=tenant_extension,
max_slots=5,
+305 -142
View File
@@ -1,171 +1,334 @@
"""
Tests for combined scoring (apply_combined_scoring).
Tests for combined scoring functionality.
The function applies multiplicative recency/temporal boosts to the cross-encoder
score so that the relative influence of these signals is proportional to the base
relevance score, independent of the cross-encoder model's score calibration.
Verifies that:
1. RRF scores are properly normalized to [0, 1] range
2. Combined scoring formula is applied correctly
3. Tracer captures normalized values (not raw values)
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
import pytest
from hindsight_api.engine.search.reranking import apply_combined_scoring, _RECENCY_ALPHA, _TEMPORAL_ALPHA
from hindsight_api.engine.search.types import MergedCandidate, RetrievalResult, ScoredResult
UTC = timezone.utc
NOW = datetime(2024, 6, 1, tzinfo=UTC)
from datetime import datetime, timezone
from hindsight_api.engine.search.types import RetrievalResult, MergedCandidate, ScoredResult
from hindsight_api.engine.memory_engine import Budget
from hindsight_api import RequestContext
def _make_result(
ce_norm: float,
occurred_start: datetime | None = None,
temporal_proximity: float | None = None,
) -> ScoredResult:
retrieval = MagicMock(spec=RetrievalResult)
retrieval.occurred_start = occurred_start
retrieval.temporal_proximity = temporal_proximity
class TestRRFNormalization:
"""Test that RRF scores are properly normalized."""
candidate = MagicMock(spec=MergedCandidate)
candidate.retrieval = retrieval
candidate.rrf_score = 0.05
def test_rrf_normalized_range(self):
"""RRF normalized values should be in [0, 1] range, not raw [0.04, 0.06]."""
# Simulate RRF scores like what we get from actual retrieval
raw_rrf_scores = [0.0607, 0.0550, 0.0480, 0.0390]
return ScoredResult(
candidate=candidate,
cross_encoder_score=1.0,
cross_encoder_score_normalized=ce_norm,
weight=ce_norm,
)
max_rrf = max(raw_rrf_scores)
min_rrf = min(raw_rrf_scores)
rrf_range = max_rrf - min_rrf
normalized = []
for score in raw_rrf_scores:
if rrf_range > 0:
norm = (score - min_rrf) / rrf_range
else:
norm = 0.5
normalized.append(norm)
# Verify normalized values are in [0, 1]
for i, norm in enumerate(normalized):
assert 0.0 <= norm <= 1.0, f"Normalized RRF {norm} not in [0, 1] for raw {raw_rrf_scores[i]}"
# Highest raw should be 1.0
assert normalized[0] == 1.0, f"Highest RRF should normalize to 1.0, got {normalized[0]}"
# Lowest raw should be 0.0
assert normalized[-1] == 0.0, f"Lowest RRF should normalize to 0.0, got {normalized[-1]}"
def test_rrf_all_same_scores(self):
"""When all RRF scores are the same, normalized should be 0.5 (neutral)."""
raw_rrf_scores = [0.0500, 0.0500, 0.0500]
max_rrf = max(raw_rrf_scores)
min_rrf = min(raw_rrf_scores)
rrf_range = max_rrf - min_rrf
normalized = []
for score in raw_rrf_scores:
if rrf_range > 0:
norm = (score - min_rrf) / rrf_range
else:
norm = 0.5 # Neutral value when all same
normalized.append(norm)
# All should be 0.5 when scores are identical
for norm in normalized:
assert norm == 0.5, f"Expected 0.5 for identical scores, got {norm}"
class TestBoostFormula:
def test_neutral_signals_leave_score_unchanged(self):
"""recency=0.5 and temporal=0.5 both produce boost=1.0, so weight == ce."""
sr = _make_result(ce_norm=0.6)
apply_combined_scoring([sr], now=NOW)
assert abs(sr.weight - 0.6) < 1e-9
class TestCombinedScoringFormula:
"""Test that the combined scoring formula is applied correctly."""
def test_max_recency_boost(self):
"""A memory from today (recency≈1.0) should boost by (1 + alpha*0.5)."""
sr = _make_result(ce_norm=0.5, occurred_start=NOW)
apply_combined_scoring([sr], now=NOW)
expected = 0.5 * (1.0 + _RECENCY_ALPHA * 0.5) * 1.0 # temporal neutral
assert abs(sr.weight - expected) < 1e-6
def test_combined_score_calculation(self):
"""Verify the weighted combination: 0.6*CE + 0.2*RRF + 0.1*temporal + 0.1*recency."""
# Test case 1: All components at 1.0
ce_norm = 1.0
rrf_norm = 1.0
temporal = 1.0
recency = 1.0
def test_min_recency_penalty(self):
"""A memory from >365 days ago (recency=0.1) should penalise score."""
old = NOW - timedelta(days=400)
sr = _make_result(ce_norm=0.5, occurred_start=old)
apply_combined_scoring([sr], now=NOW)
expected = 0.5 * (1.0 + _RECENCY_ALPHA * (0.1 - 0.5)) * 1.0
assert abs(sr.weight - expected) < 1e-6
expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency
assert expected == 1.0, f"All 1.0 should give 1.0, got {expected}"
def test_max_temporal_boost(self):
"""temporal_proximity=1.0 should boost by (1 + alpha*0.5)."""
sr = _make_result(ce_norm=0.5, temporal_proximity=1.0)
apply_combined_scoring([sr], now=NOW)
expected = 0.5 * 1.0 * (1.0 + _TEMPORAL_ALPHA * 0.5) # recency neutral
assert abs(sr.weight - expected) < 1e-6
# Test case 2: All components at 0.0
ce_norm = 0.0
rrf_norm = 0.0
temporal = 0.0
recency = 0.0
def test_temporal_none_is_neutral(self):
"""temporal_proximity=None must be treated as 0.5 (no boost/penalty)."""
sr_none = _make_result(ce_norm=0.5, temporal_proximity=None)
sr_half = _make_result(ce_norm=0.5, temporal_proximity=0.5)
apply_combined_scoring([sr_none], now=NOW)
apply_combined_scoring([sr_half], now=NOW)
assert abs(sr_none.weight - sr_half.weight) < 1e-9
expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency
assert expected == 0.0, f"All 0.0 should give 0.0, got {expected}"
def test_both_signals_combined(self):
"""Both boosts are applied multiplicatively."""
sr = _make_result(ce_norm=0.5, occurred_start=NOW, temporal_proximity=1.0)
apply_combined_scoring([sr], now=NOW)
recency_boost = 1.0 + _RECENCY_ALPHA * (1.0 - 0.5)
temporal_boost = 1.0 + _TEMPORAL_ALPHA * (1.0 - 0.5)
expected = 0.5 * recency_boost * temporal_boost
assert abs(sr.weight - expected) < 1e-6
# Test case 3: High CE, low RRF (cross-encoder finds something retrieval missed)
ce_norm = 0.999
rrf_norm = 0.0 # Lowest in set
temporal = 0.5
recency = 0.5
def test_boost_is_proportional_to_ce(self):
"""The absolute boost from recency scales with the CE score."""
sr_high = _make_result(ce_norm=0.9, occurred_start=NOW)
sr_low = _make_result(ce_norm=0.3, occurred_start=NOW)
apply_combined_scoring([sr_high, sr_low], now=NOW)
expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency
# 0.5994 + 0.0 + 0.05 + 0.05 = 0.6994
assert abs(expected - 0.6994) < 0.001, f"Expected ~0.6994, got {expected}"
# Both get the same recency boost factor — absolute gain is proportional to CE
boost_factor = 1.0 + _RECENCY_ALPHA * 0.5
assert abs(sr_high.weight - 0.9 * boost_factor) < 1e-6
assert abs(sr_low.weight - 0.3 * boost_factor) < 1e-6
# Test case 4: Medium CE, high RRF (retrieval consensus)
ce_norm = 0.8
rrf_norm = 1.0 # Highest in set
temporal = 0.5
recency = 0.5
def test_boost_capped(self):
"""Max boost: recency=1.0 + temporal=1.0 gives ≤21% uplift on CE."""
sr = _make_result(ce_norm=1.0, occurred_start=NOW, temporal_proximity=1.0)
apply_combined_scoring([sr], now=NOW)
assert sr.weight <= 1.0 * (1 + _RECENCY_ALPHA / 2) * (1 + _TEMPORAL_ALPHA / 2) + 1e-9
expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency
# 0.48 + 0.2 + 0.05 + 0.05 = 0.78
assert abs(expected - 0.78) < 0.001, f"Expected ~0.78, got {expected}"
def test_rrf_normalized_always_zero(self):
"""RRF is excluded from scoring; rrf_normalized is set to 0.0 for trace clarity."""
sr = _make_result(ce_norm=0.5)
apply_combined_scoring([sr], now=NOW)
assert sr.rrf_normalized == 0.0
def test_rrf_contribution_is_significant(self):
"""Verify RRF actually contributes to the final score (not negligible)."""
# Same CE, different RRF
ce_norm = 0.8
temporal = 0.5
recency = 0.5
def test_combined_score_equals_weight(self):
"""combined_score and weight must stay in sync."""
sr = _make_result(ce_norm=0.7, occurred_start=NOW, temporal_proximity=0.8)
apply_combined_scoring([sr], now=NOW)
assert sr.combined_score == sr.weight
# Low RRF
score_low_rrf = 0.6 * ce_norm + 0.2 * 0.0 + 0.1 * temporal + 0.1 * recency
def test_model_calibration_independence(self):
"""
A low-calibration model (low CE scores) and a high-calibration model
(high CE scores) should produce the same ranking for identical content.
# High RRF
score_high_rrf = 0.6 * ce_norm + 0.2 * 1.0 + 0.1 * temporal + 0.1 * recency
With additive scoring the recency term would dominate for low-CE models;
with multiplicative boosting the relative ranking is stable.
"""
recent = NOW - timedelta(days=10)
old = NOW - timedelta(days=300)
# Difference should be 0.2 (20% contribution)
diff = score_high_rrf - score_low_rrf
assert abs(diff - 0.2) < 0.001, f"RRF should contribute 0.2 difference, got {diff}"
# High-calibration model: clear winner is #1 (more relevant, slightly older)
h_relevant = _make_result(ce_norm=0.85, occurred_start=old)
h_recent = _make_result(ce_norm=0.60, occurred_start=recent)
apply_combined_scoring([h_relevant, h_recent], now=NOW)
assert h_relevant.weight > h_recent.weight, "High-CE model: relevance should win"
# Low-calibration model: same relative difference, just compressed scores
l_relevant = _make_result(ce_norm=0.34, occurred_start=old)
l_recent = _make_result(ce_norm=0.24, occurred_start=recent)
apply_combined_scoring([l_relevant, l_recent], now=NOW)
assert l_relevant.weight > l_recent.weight, "Low-CE model: relevance should still win"
@pytest.mark.asyncio
async def test_trace_has_normalized_rrf(memory, request_context):
"""Integration test: verify trace contains normalized RRF values, not raw."""
bank_id = f"test_scoring_{datetime.now(timezone.utc).timestamp()}"
def test_no_occurred_start_defaults_recency_neutral(self):
"""Missing occurred_start → recency=0.5 → no boost/penalty."""
sr = _make_result(ce_norm=0.5, occurred_start=None)
apply_combined_scoring([sr], now=NOW)
assert sr.recency == 0.5
assert abs(sr.weight - 0.5) < 1e-9
try:
# Store multiple memories to ensure different RRF scores
await memory.retain_async(
bank_id=bank_id,
content="Python is a programming language created by Guido van Rossum",
context="tech facts",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="JavaScript was created by Brendan Eich at Netscape",
context="tech facts",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="The Eiffel Tower is located in Paris, France",
context="geography facts",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="Mount Everest is the tallest mountain on Earth",
context="geography facts",
request_context=request_context,
)
def test_timezone_naive_occurred_start_handled(self):
"""Naive datetimes in occurred_start should not raise."""
naive_date = datetime(2024, 1, 1) # no tzinfo
sr = _make_result(ce_norm=0.5, occurred_start=naive_date)
apply_combined_scoring([sr], now=NOW) # must not raise
assert 0.0 < sr.weight < 1.0
# Search with tracing
result = await memory.recall_async(
bank_id=bank_id,
query="programming languages",
fact_type=["world"],
budget=Budget.LOW,
max_tokens=1024,
enable_trace=True,
request_context=request_context,
)
def test_custom_alpha_values(self):
"""Custom alpha parameters are respected."""
sr = _make_result(ce_norm=0.5, occurred_start=NOW)
apply_combined_scoring([sr], now=NOW, recency_alpha=0.4, temporal_alpha=0.0)
expected = 0.5 * (1.0 + 0.4 * 0.5) * 1.0
assert abs(sr.weight - expected) < 1e-6
assert result.trace is not None, "Trace should be present"
trace = result.trace
def test_future_event_recency_capped_at_one(self):
"""Events in the future must not produce recency > 1.0, keeping boost within bounds."""
future = NOW + timedelta(days=180)
sr = _make_result(ce_norm=0.5, occurred_start=future)
apply_combined_scoring([sr], now=NOW)
assert sr.recency == 1.0
expected_max_boost = 1.0 + _RECENCY_ALPHA * 0.5
assert sr.weight <= 0.5 * expected_max_boost + 1e-9
# Check reranked results have proper score_components
assert "reranked" in trace, "Trace should have reranked results"
assert len(trace["reranked"]) > 0, "Should have reranked results"
def test_empty_list_is_noop(self):
apply_combined_scoring([], now=NOW) # must not raise
has_valid_rrf = False
has_valid_temporal = False
has_valid_recency = False
for r in trace["reranked"]:
sc = r.get("score_components", {})
# Check RRF normalized is present and in valid range
if "rrf_normalized" in sc:
rrf_norm = sc["rrf_normalized"]
assert 0.0 <= rrf_norm <= 1.0, f"rrf_normalized {rrf_norm} should be in [0, 1]"
# Should NOT be raw RRF score (which would be ~0.04-0.06)
# A normalized value of exactly 0.0 or 1.0 is valid (min/max of set)
# But raw scores like 0.0607 should never appear as normalized
if rrf_norm > 0.1: # Any value > 0.1 is likely properly normalized
has_valid_rrf = True
# Check temporal is present and in valid range
if "temporal" in sc:
temporal = sc["temporal"]
assert 0.0 <= temporal <= 1.0, f"temporal {temporal} should be in [0, 1]"
has_valid_temporal = True
# Check recency is present and in valid range
if "recency" in sc:
recency = sc["recency"]
assert 0.0 <= recency <= 1.0, f"recency {recency} should be in [0, 1]"
has_valid_recency = True
# At least some results should have these components
# (might not have rrf > 0.1 if all scores are same, which is fine)
assert has_valid_temporal, "Should have temporal scores in trace"
assert has_valid_recency, "Should have recency scores in trace"
print("\n✓ Combined scoring trace test passed!")
print(f" - Reranked results: {len(trace['reranked'])}")
if trace["reranked"]:
sc = trace["reranked"][0].get("score_components", {})
print(f" - First result score components: {sc}")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_rrf_normalized_not_raw_in_trace(memory, request_context):
"""Verify that raw RRF scores (0.04-0.06 range) don't appear as normalized values."""
bank_id = f"test_rrf_raw_{datetime.now(timezone.utc).timestamp()}"
try:
# Store enough memories to get varied RRF scores
for i in range(5):
await memory.retain_async(
bank_id=bank_id,
content=f"Test fact number {i} about various topics",
context="test context",
request_context=request_context,
)
result = await memory.recall_async(
bank_id=bank_id,
query="test fact",
fact_type=["world"],
budget=Budget.LOW,
max_tokens=512,
enable_trace=True,
request_context=request_context,
)
trace = result.trace
assert trace is not None
# Check that rrf_normalized values are NOT in the raw range
raw_rrf_range = (0.01, 0.08) # Raw RRF scores are typically in this range
for r in trace.get("reranked", []):
sc = r.get("score_components", {})
if "rrf_normalized" in sc and "rrf_score" in sc:
rrf_norm = sc["rrf_normalized"]
rrf_raw = sc["rrf_score"]
# Raw should be in the typical range
assert raw_rrf_range[0] <= rrf_raw <= raw_rrf_range[1], \
f"Raw RRF {rrf_raw} should be in typical range {raw_rrf_range}"
# Normalized should either be:
# - 0.0 (min in set)
# - 1.0 (max in set)
# - 0.5 (all same)
# - Something in between (0.0 to 1.0)
# But NOT the same as raw (which would indicate no normalization)
if len(trace["reranked"]) > 1:
# If we have multiple results, normalized should differ from raw
# (unless by coincidence, which is very unlikely)
assert rrf_norm != rrf_raw, \
f"Normalized RRF ({rrf_norm}) should differ from raw ({rrf_raw})"
print("\n✓ RRF raw vs normalized test passed!")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_combined_score_matches_components(memory, request_context):
"""Verify the final score actually equals the weighted sum of components."""
bank_id = f"test_combined_{datetime.now(timezone.utc).timestamp()}"
try:
await memory.retain_async(
bank_id=bank_id,
content="The quick brown fox jumps over the lazy dog",
context="test",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="A quick test of the emergency broadcast system",
context="test",
request_context=request_context,
)
result = await memory.recall_async(
bank_id=bank_id,
query="quick test",
fact_type=["world"],
budget=Budget.LOW,
max_tokens=512,
enable_trace=True,
request_context=request_context,
)
trace = result.trace
assert trace is not None
for r in trace.get("reranked", []):
sc = r.get("score_components", {})
final_score = r.get("rerank_score", 0)
# Get components (use defaults if missing)
ce = sc.get("cross_encoder_score_normalized", 0)
rrf = sc.get("rrf_normalized", 0.5)
tmp = sc.get("temporal", 0.5)
rec = sc.get("recency", 0.5)
# Calculate expected score
expected = 0.6 * ce + 0.2 * rrf + 0.1 * tmp + 0.1 * rec
# Allow small floating point difference
assert abs(final_score - expected) < 0.01, \
f"Final score {final_score} doesn't match expected {expected} from components"
print("\n✓ Combined score verification test passed!")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
+2 -183
View File
@@ -5,17 +5,11 @@ Note: Consolidation runs automatically after retain via SyncTaskBackend in tests
"""
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, call, patch
from unittest.mock import patch
import pytest
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.consolidation.consolidator import (
_aggregate_source_fields,
_find_related_observations,
run_consolidation_job,
)
from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
from hindsight_api.engine.memory_engine import MemoryEngine
from hindsight_api.engine.reflect.tools import (
tool_recall,
@@ -2323,178 +2317,3 @@ async def test_observation_scopes_all_combinations(memory: MemoryEngine, request
assert combined, f"Expected an observation scoped to both tags, got: {tag_sets}"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
def _dt(year: int, month: int, day: int) -> datetime:
return datetime(year, month, day, tzinfo=timezone.utc)
class TestAggregateSourceFields:
"""Unit tests for _aggregate_source_fields no database required."""
def test_all_none_temporal_fields_stay_none(self):
"""When source memories carry no temporal data, all fields must remain None."""
source_mems = [
{"tags": ["t1"], "event_date": None, "occurred_start": None, "occurred_end": None, "mentioned_at": None},
{"tags": ["t1"], "event_date": None, "occurred_start": None, "occurred_end": None, "mentioned_at": None},
]
agg = _aggregate_source_fields(source_mems)
assert agg.event_date is None
assert agg.occurred_start is None
assert agg.occurred_end is None
assert agg.mentioned_at is None
def test_temporal_fields_aggregated_correctly(self):
"""occurred_start and event_date are minimised; occurred_end and mentioned_at are maximised."""
early = _dt(2023, 1, 1)
late = _dt(2024, 6, 15)
source_mems = [
{
"tags": [],
"event_date": late,
"occurred_start": late,
"occurred_end": early,
"mentioned_at": early,
},
{
"tags": [],
"event_date": early,
"occurred_start": early,
"occurred_end": late,
"mentioned_at": late,
},
]
agg = _aggregate_source_fields(source_mems)
assert agg.event_date == early
assert agg.occurred_start == early
assert agg.occurred_end == late
assert agg.mentioned_at == late
def test_partial_temporal_fields_ignored_when_none(self):
"""None values in individual sources do not corrupt the min/max from sources that do have dates."""
d = _dt(2023, 3, 10)
source_mems = [
{"tags": [], "event_date": None, "occurred_start": None, "occurred_end": None, "mentioned_at": None},
{"tags": [], "event_date": d, "occurred_start": d, "occurred_end": d, "mentioned_at": d},
]
agg = _aggregate_source_fields(source_mems)
assert agg.event_date == d
assert agg.occurred_start == d
assert agg.occurred_end == d
assert agg.mentioned_at == d
def test_tags_inherited_from_first_source_memory(self):
"""Tags default to those of the first source memory (batch invariant)."""
source_mems = [
{"tags": ["user:alice"], "event_date": None, "occurred_start": None, "occurred_end": None, "mentioned_at": None},
{"tags": ["user:alice"], "event_date": None, "occurred_start": None, "occurred_end": None, "mentioned_at": None},
]
agg = _aggregate_source_fields(source_mems)
assert agg.tags == ["user:alice"]
def test_tags_override_takes_precedence(self):
"""Explicit tags parameter overrides the source-memory tags."""
source_mems = [
{"tags": ["user:alice"], "event_date": None, "occurred_start": None, "occurred_end": None, "mentioned_at": None},
]
agg = _aggregate_source_fields(source_mems, tags=["scope:override"])
assert agg.tags == ["scope:override"]
def test_empty_tags_override_is_respected(self):
"""An explicit empty list override must not fall back to source tags."""
source_mems = [
{"tags": ["user:alice"], "event_date": None, "occurred_start": None, "occurred_end": None, "mentioned_at": None},
]
agg = _aggregate_source_fields(source_mems, tags=[])
assert agg.tags == []
def test_single_source_memory(self):
"""Single-source aggregation should just pass through that memory's fields."""
d = _dt(2024, 11, 5)
source_mems = [
{"tags": ["x"], "event_date": d, "occurred_start": d, "occurred_end": d, "mentioned_at": d},
]
agg = _aggregate_source_fields(source_mems)
assert agg.event_date == d
assert agg.occurred_start == d
assert agg.occurred_end == d
assert agg.mentioned_at == d
assert agg.tags == ["x"]
class TestConsolidationSourceFactsConfig:
"""Tests that consolidation uses the source_facts token config when calling recall."""
@pytest.fixture(autouse=True)
def enable_observations(self):
config = _get_raw_config()
original = config.enable_observations
config.enable_observations = True
yield
config.enable_observations = original
@pytest.mark.asyncio
async def test_consolidation_passes_source_facts_max_tokens_to_recall(
self, memory: MemoryEngine, request_context
):
"""consolidation_source_facts_max_tokens from config is forwarded to recall_async."""
bank_id = f"test-sf-config-total-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
raw = _get_raw_config()
fake_config = type(raw)(**{
**{f: getattr(raw, f) for f in raw.__dataclass_fields__},
"consolidation_source_facts_max_tokens": 999,
"consolidation_source_facts_max_tokens_per_observation": -1,
})
try:
with (
patch.object(memory._config_resolver, "resolve_full_config", return_value=fake_config),
patch.object(memory, "recall_async", wraps=memory.recall_async) as mock_recall,
):
await _find_related_observations(
memory_engine=memory,
bank_id=bank_id,
query="test query",
request_context=request_context,
)
assert mock_recall.called
_, kwargs = mock_recall.call_args
assert kwargs.get("max_source_facts_tokens") == 999
assert kwargs.get("max_source_facts_tokens_per_observation") == -1
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_consolidation_passes_source_facts_per_obs_tokens_to_recall(
self, memory: MemoryEngine, request_context
):
"""consolidation_source_facts_max_tokens_per_observation from config is forwarded to recall_async."""
bank_id = f"test-sf-config-per-obs-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
raw = _get_raw_config()
fake_config = type(raw)(**{
**{f: getattr(raw, f) for f in raw.__dataclass_fields__},
"consolidation_source_facts_max_tokens": -1,
"consolidation_source_facts_max_tokens_per_observation": 128,
})
try:
with (
patch.object(memory._config_resolver, "resolve_full_config", return_value=fake_config),
patch.object(memory, "recall_async", wraps=memory.recall_async) as mock_recall,
):
await _find_related_observations(
memory_engine=memory,
bank_id=bank_id,
query="test query",
request_context=request_context,
)
assert mock_recall.called
_, kwargs = mock_recall.call_args
assert kwargs.get("max_source_facts_tokens") == -1
assert kwargs.get("max_source_facts_tokens_per_observation") == 128
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@@ -75,7 +75,7 @@ def drop_schema(db_url: str, schema_name: str):
conn.commit()
def get_column_dimension(db_url: str, schema: str = "public", table: str = "memory_units") -> int | None:
def get_column_dimension(db_url: str, schema: str = "public") -> int | None:
"""Get the current embedding column dimension from the database."""
engine = create_engine(db_url)
with engine.connect() as conn:
@@ -86,10 +86,10 @@ def get_column_dimension(db_url: str, schema: str = "public", table: str = "memo
JOIN pg_class c ON a.attrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE n.nspname = :schema
AND c.relname = :table
AND c.relname = 'memory_units'
AND a.attname = 'embedding'
"""),
{"schema": schema, "table": table},
{"schema": schema},
).scalar()
return result
@@ -125,38 +125,6 @@ def clear_embeddings(db_url: str, schema: str):
conn.commit()
def insert_test_mental_model_embedding(db_url: str, schema: str, dimension: int):
"""Insert a test mental model row with a dummy embedding."""
engine = create_engine(db_url)
embedding = [0.1] * dimension
embedding_str = "[" + ",".join(str(x) for x in embedding) + "]"
with engine.connect() as conn:
# Ensure test bank exists
conn.execute(
text(f"""
INSERT INTO {schema}.banks (bank_id, name)
VALUES ('test-bank-mm', 'Test Bank')
ON CONFLICT (bank_id) DO NOTHING
""")
)
conn.execute(
text(f"""
INSERT INTO {schema}.mental_models (bank_id, name, source_query, content, embedding)
VALUES ('test-bank-mm', 'test model', 'test query', 'test content', '{embedding_str}'::vector)
""")
)
conn.commit()
def clear_mental_model_embeddings(db_url: str, schema: str):
"""Clear all rows from mental_models."""
engine = create_engine(db_url)
with engine.connect() as conn:
conn.execute(text(f"DELETE FROM {schema}.mental_models"))
conn.commit()
# =============================================================================
# Embedding Dimension Tests (Local Embeddings)
# =============================================================================
@@ -231,49 +199,6 @@ class TestEmbeddingDimension:
# Cleanup
clear_embeddings(db_url, schema)
def test_mental_models_dimension_matches_no_change(self, dimension_test_schema):
"""When mental_models dimension matches, no changes should be made."""
db_url, schema = dimension_test_schema
initial_dim = get_column_dimension(db_url, schema, table="mental_models")
assert initial_dim == 384, f"Expected 384, got {initial_dim}"
ensure_embedding_dimension(db_url, 384, schema=schema)
assert get_column_dimension(db_url, schema, table="mental_models") == 384
def test_mental_models_dimension_change_empty_table(self, dimension_test_schema):
"""When mental_models is empty, dimension can be changed."""
db_url, schema = dimension_test_schema
clear_mental_model_embeddings(db_url, schema)
ensure_embedding_dimension(db_url, 768, schema=schema)
assert get_column_dimension(db_url, schema, table="mental_models") == 768
# Change back for other tests
ensure_embedding_dimension(db_url, 384, schema=schema)
assert get_column_dimension(db_url, schema, table="mental_models") == 384
def test_mental_models_dimension_change_blocked_with_data(self, dimension_test_schema):
"""When mental_models has data, dimension change should be blocked."""
db_url, schema = dimension_test_schema
clear_mental_model_embeddings(db_url, schema)
insert_test_mental_model_embedding(db_url, schema, 384)
with pytest.raises(RuntimeError) as exc_info:
ensure_embedding_dimension(db_url, 768, schema=schema)
assert "Cannot change embedding dimension" in str(exc_info.value)
assert "mental_models" in str(exc_info.value)
assert get_column_dimension(db_url, schema, table="mental_models") == 384
# Cleanup
clear_mental_model_embeddings(db_url, schema)
def test_local_embeddings_dimension_detection(self, embeddings):
"""Test that LocalSTEmbeddings correctly detects dimension."""
# Initialize embeddings if not already done
@@ -1,141 +0,0 @@
"""
Unit tests for fact extraction retry logic.
Tests the fix for the TypeError when LLM returns invalid JSON across all retries.
Previously, `raise last_error` would raise None (TypeError) because last_error was
only set in the BadRequestError handler, not when the LLM returned non-dict JSON.
"""
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def _make_config(llm_max_retries: int = 3, retain_llm_max_retries: int | None = None):
"""Build a minimal HindsightConfig for fact extraction tests."""
from hindsight_api.config import HindsightConfig
cfg = MagicMock(spec=HindsightConfig)
cfg.retain_llm_max_retries = retain_llm_max_retries
cfg.llm_max_retries = llm_max_retries
cfg.retain_llm_initial_backoff = None
cfg.llm_initial_backoff = 0.0
cfg.retain_llm_max_backoff = None
cfg.llm_max_backoff = 0.0
cfg.retain_max_completion_tokens = 8192
cfg.retain_extraction_mode = "concise"
cfg.retain_extract_causal_links = False
cfg.retain_mission = None
return cfg
def _make_llm_config(mock_response):
"""Build a mock LLMProvider that returns the given response."""
from hindsight_api.engine.llm_wrapper import LLMProvider
llm = MagicMock(spec=LLMProvider)
llm.provider = "mock"
token_usage = MagicMock()
token_usage.__add__ = lambda self, other: self
llm.call = AsyncMock(return_value=(mock_response, token_usage))
return llm
@pytest.mark.asyncio
async def test_non_dict_json_all_retries_returns_empty():
"""
When LLM returns non-dict JSON on every attempt, extraction should return []
without raising TypeError ('exceptions must derive from BaseException').
This was the bug: the loop ran range(2) times (hardcoded), but comparisons
used config.llm_max_retries (default 10). On the last loop iteration (attempt=1),
`attempt < 10 - 1` was True, so the code called `continue`, the loop
exhausted, and `raise last_error` raised None TypeError.
"""
from hindsight_api.engine.retain.fact_extraction import _extract_facts_from_chunk
# llm_max_retries=3 ensures the bug triggers with the old code (3 != 2 hardcoded)
config = _make_config(llm_max_retries=3, retain_llm_max_retries=None)
# Mock: always returns a list (non-dict), which is invalid
llm_config = _make_llm_config(mock_response=[{"invalid": "response"}])
with patch(
"hindsight_api.engine.retain.fact_extraction._build_extraction_prompt_and_schema",
return_value=("system prompt", MagicMock()),
):
facts, usage = await _extract_facts_from_chunk(
chunk="Alice visited Paris in 2023.",
chunk_index=0,
total_chunks=1,
event_date=datetime(2023, 1, 1, tzinfo=timezone.utc),
context="travel notes",
llm_config=llm_config,
config=config,
agent_name="test-agent",
)
assert facts == []
@pytest.mark.asyncio
async def test_non_dict_json_with_default_max_retries_returns_empty():
"""
Same scenario with the default llm_max_retries=10 (matching real default config).
The old code ran range(2) but checked against 10, always continuing until
the loop exhausted, then raised None TypeError.
"""
from hindsight_api.engine.retain.fact_extraction import _extract_facts_from_chunk
config = _make_config(llm_max_retries=10, retain_llm_max_retries=None)
llm_config = _make_llm_config(mock_response="not a dict at all")
with patch(
"hindsight_api.engine.retain.fact_extraction._build_extraction_prompt_and_schema",
return_value=("system prompt", MagicMock()),
):
facts, usage = await _extract_facts_from_chunk(
chunk="Some text.",
chunk_index=0,
total_chunks=1,
event_date=datetime(2023, 6, 1, tzinfo=timezone.utc),
context="",
llm_config=llm_config,
config=config,
agent_name="agent",
)
assert facts == []
@pytest.mark.asyncio
async def test_retain_llm_max_retries_overrides_global():
"""
When retain_llm_max_retries is set, it should be used for the loop range
and all comparisons (no shadowing bug).
"""
from hindsight_api.engine.retain.fact_extraction import _extract_facts_from_chunk
# retain_llm_max_retries=5 should override llm_max_retries=10
config = _make_config(llm_max_retries=10, retain_llm_max_retries=5)
llm_config = _make_llm_config(mock_response=42) # non-dict: integer
with patch(
"hindsight_api.engine.retain.fact_extraction._build_extraction_prompt_and_schema",
return_value=("system prompt", MagicMock()),
):
facts, usage = await _extract_facts_from_chunk(
chunk="Bob likes Python.",
chunk_index=0,
total_chunks=1,
event_date=datetime(2024, 1, 1, tzinfo=timezone.utc),
context="",
llm_config=llm_config,
config=config,
agent_name="agent",
)
assert facts == []
# Verify it retried exactly retain_llm_max_retries times
assert llm_config.call.call_count == 5
+2 -216
View File
@@ -2,22 +2,12 @@
End-to-end tests for file retain (upload, convert, retain) functionality.
"""
import asyncio
import io
import json
import pytest
from httpx import ASGITransport, AsyncClient
from hindsight_api.extensions import FileConvertResult, OperationValidatorExtension, ValidationResult
from hindsight_api.extensions.operation_validator import (
RecallContext,
RecallResult,
ReflectContext,
RetainContext,
RetainResult,
)
@pytest.fixture
def sample_pdf_content():
@@ -403,13 +393,13 @@ async def test_file_conversion_creates_separate_retain_operation(memory_no_llm_v
"metadata": {"source": "test"},
"tags": ["test_tag"],
"timestamp": None,
"parser": ["markitdown"],
}
]
result = await memory_no_llm_verify.submit_async_file_retain(
bank_id=bank_id,
file_items=file_items,
parser="markitdown",
document_tags=["two_phase_test"],
request_context=context,
)
@@ -521,7 +511,6 @@ async def test_file_conversion_failure_sets_status_to_failed(memory_no_llm_verif
"metadata": {},
"tags": [],
"timestamp": None,
"parser": ["failing_converter"],
}
]
@@ -529,6 +518,7 @@ async def test_file_conversion_failure_sets_status_to_failed(memory_no_llm_verif
result = await memory_no_llm_verify.submit_async_file_retain(
bank_id=bank_id,
file_items=file_items,
parser="failing_converter",
document_tags=None,
request_context=context,
)
@@ -561,207 +551,3 @@ async def test_file_conversion_failure_sets_status_to_failed(memory_no_llm_verif
assert operation["error_message"] is not None
assert "Mock conversion error" in operation["error_message"]
assert "test.fail" in operation["error_message"]
class FileConvertTrackingValidator(OperationValidatorExtension):
"""Validator that tracks on_file_convert_complete hook calls."""
def __init__(self):
super().__init__({})
self.convert_calls: list[FileConvertResult] = []
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
return ValidationResult.accept()
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
return ValidationResult.accept()
async def on_retain_complete(self, result: RetainResult) -> None:
pass
async def on_recall_complete(self, result: RecallResult) -> None:
pass
async def on_file_convert_complete(self, result: FileConvertResult) -> None:
self.convert_calls.append(result)
@pytest.mark.asyncio
async def test_on_file_convert_complete_hook_called(memory_no_llm_verify, sample_txt_content):
"""Test that on_file_convert_complete hook is called after file conversion with correct parameters."""
from hindsight_api.models import RequestContext
bank_id = "test_file_convert_hook_bank"
validator = FileConvertTrackingValidator()
memory_no_llm_verify._operation_validator = validator
context = RequestContext(internal=True, api_key_id="test-key-id", tenant_id="test-tenant")
await memory_no_llm_verify.get_bank_profile(bank_id, request_context=context)
class MockFile:
def __init__(self, content, filename, content_type):
self.content = content
self.filename = filename
self.content_type = content_type
async def read(self):
return self.content
mock_file = MockFile(sample_txt_content, "report.txt", "text/plain")
file_items = [
{
"file": mock_file,
"document_id": "hook_test_doc",
"context": "test context",
"metadata": {},
"tags": [],
"timestamp": None,
"parser": ["markitdown"],
}
]
await memory_no_llm_verify.submit_async_file_retain(
bank_id=bank_id,
file_items=file_items,
document_tags=None,
request_context=context,
)
await asyncio.sleep(0.1)
assert len(validator.convert_calls) == 1
result = validator.convert_calls[0]
assert result.bank_id == bank_id
assert result.filename == "report.txt"
assert result.parser_name == "markitdown"
assert result.output_chars > 0
assert result.output_text is not None
assert len(result.output_text) == result.output_chars
assert result.success is True
assert result.error is None
assert result.request_context is not None
assert result.request_context.api_key_id == "test-key-id"
assert result.request_context.tenant_id == "test-tenant"
@pytest.mark.asyncio
async def test_on_file_convert_complete_hook_called_for_each_file(memory_no_llm_verify, sample_txt_content):
"""Test that on_file_convert_complete is called once per file when uploading multiple files."""
from hindsight_api.models import RequestContext
bank_id = "test_file_convert_hook_multi_bank"
validator = FileConvertTrackingValidator()
memory_no_llm_verify._operation_validator = validator
context = RequestContext(internal=True)
await memory_no_llm_verify.get_bank_profile(bank_id, request_context=context)
class MockFile:
def __init__(self, content, filename, content_type):
self.content = content
self.filename = filename
self.content_type = content_type
async def read(self):
return self.content
file_items = [
{
"file": MockFile(b"First document content", "first.txt", "text/plain"),
"document_id": "doc_1",
"context": None,
"metadata": {},
"tags": [],
"timestamp": None,
"parser": ["markitdown"],
},
{
"file": MockFile(b"Second document content", "second.txt", "text/plain"),
"document_id": "doc_2",
"context": None,
"metadata": {},
"tags": [],
"timestamp": None,
"parser": ["markitdown"],
},
]
await memory_no_llm_verify.submit_async_file_retain(
bank_id=bank_id,
file_items=file_items,
document_tags=None,
request_context=context,
)
await asyncio.sleep(0.2)
assert len(validator.convert_calls) == 2
filenames = {r.filename for r in validator.convert_calls}
assert filenames == {"first.txt", "second.txt"}
for result in validator.convert_calls:
assert result.bank_id == bank_id
assert result.parser_name == "markitdown"
assert result.output_chars > 0
assert result.success is True
@pytest.mark.asyncio
async def test_on_file_convert_complete_hook_not_called_on_conversion_failure(memory_no_llm_verify, sample_txt_content):
"""Test that on_file_convert_complete is NOT called when file conversion fails."""
from hindsight_api.engine.parsers.base import FileParser
from hindsight_api.models import RequestContext
bank_id = "test_file_convert_hook_fail_bank"
validator = FileConvertTrackingValidator()
memory_no_llm_verify._operation_validator = validator
class FailingParser(FileParser):
async def convert(self, file_data: bytes, filename: str) -> str:
raise RuntimeError("Mock conversion failure")
def supports(self, filename: str, content_type: str | None = None) -> bool:
return filename.endswith(".hookfail")
def name(self) -> str:
return "hookfail_parser"
memory_no_llm_verify._parser_registry.register(FailingParser())
context = RequestContext(internal=True)
await memory_no_llm_verify.get_bank_profile(bank_id, request_context=context)
class MockFile:
def __init__(self, content, filename, content_type):
self.content = content
self.filename = filename
self.content_type = content_type
async def read(self):
return self.content
file_items = [
{
"file": MockFile(sample_txt_content, "bad.hookfail", "application/octet-stream"),
"document_id": "fail_hook_doc",
"context": None,
"metadata": {},
"tags": [],
"timestamp": None,
"parser": ["hookfail_parser"],
}
]
await memory_no_llm_verify.submit_async_file_retain(
bank_id=bank_id,
file_items=file_items,
document_tags=None,
request_context=context,
)
await asyncio.sleep(0.2)
assert len(validator.convert_calls) == 0
@@ -75,9 +75,6 @@ async def test_hierarchical_fields_categorization():
assert "retain_custom_instructions" in configurable
assert "retain_chunk_size" in configurable
assert "enable_observations" in configurable
assert "consolidation_llm_batch_size" in configurable
assert "consolidation_source_facts_max_tokens" in configurable
assert "consolidation_source_facts_max_tokens_per_observation" in configurable
assert "observations_mission" in configurable
assert "reflect_mission" in configurable
assert "disposition_skepticism" in configurable
@@ -89,7 +86,7 @@ async def test_hierarchical_fields_categorization():
assert "entity_labels" in configurable
# Verify count is correct
assert len(configurable) == 17
assert len(configurable) == 14
# Verify credential fields (NEVER exposed)
assert "llm_api_key" in credentials
-37
View File
@@ -1,37 +0,0 @@
import pytest
from hindsight_api.engine.llm_wrapper import sanitize_llm_output
@pytest.mark.parametrize(
"input_text, expected",
[
# Null bytes stripped
("hello\x00world", "helloworld"),
("FIRST\u0000PAGE", "FIRSTPAGE"),
# Multiple null bytes
("\x00\x00text\x00", "text"),
# Other control characters stripped (non-whitespace)
("text\x01\x02\x03end", "textend"),
("text\x08end", "textend"), # backspace
("text\x0cend", "textend"), # form feed
("text\x0bend", "textend"), # vertical tab
("text\x1fend", "textend"), # unit separator
("text\x7fend", "textend"), # DEL
# Whitespace preserved
("hello\tworld", "hello\tworld"),
("hello\nworld", "hello\nworld"),
("hello\r\nworld", "hello\r\nworld"),
# Unicode surrogates stripped
("text\ud800end", "textend"),
("text\udfffend", "textend"),
# Clean text unchanged
("normal text", "normal text"),
("unicode: café naïve", "unicode: café naïve"),
# Edge cases
("", ""),
(None, None),
],
)
def test_sanitize_llm_output(input_text, expected):
assert sanitize_llm_output(input_text) == expected
@@ -1,321 +0,0 @@
"""
Reproduce issue #520: Reflect fails with LM Studio due to unsupported tool_choice format.
The reflect agent forces tool selection via named tool_choice dicts on the first few iterations:
{"type": "function", "function": {"name": "search_mental_models"}}
LM Studio (and Ollama) reject this format with HTTP 400:
"Tool choice of type 'function' is not supported. Use 'auto', 'none', or 'required'."
The fix should convert named tool_choice to "required" and filter the tools list
to only the requested tool for providers that don't support named tool_choice.
"""
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from openai import APIStatusError
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
# Reflect agent tools (subset matching what agent.py uses)
REFLECT_TOOLS = [
{
"type": "function",
"function": {
"name": "search_mental_models",
"description": "Search consolidated mental models",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "search_observations",
"description": "Search raw observations",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "recall",
"description": "Recall semantic memories",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "done",
"description": "Finish and return the answer",
"parameters": {
"type": "object",
"properties": {"answer": {"type": "string"}},
"required": ["answer"],
},
},
},
]
def _make_lmstudio_llm() -> OpenAICompatibleLLM:
return OpenAICompatibleLLM(
provider="lmstudio",
api_key="local",
base_url="http://localhost:1234/v1",
model="openai/gpt-oss-20b",
)
def _lmstudio_400_error(msg: str = "Tool choice of type 'function' is not supported. Use 'auto', 'none', or 'required'.") -> APIStatusError:
"""Simulate the HTTP 400 LM Studio returns for unsupported tool_choice format."""
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.headers = {}
return APIStatusError(
message=msg,
response=mock_response,
body={"error": {"message": msg, "type": "invalid_request_error"}},
)
def _make_tool_call_response(tool_name: str, arguments: dict) -> MagicMock:
"""Build a mock successful tool call response from the LLM API."""
mock_tc = MagicMock()
mock_tc.id = "call_abc123"
mock_tc.function.name = tool_name
mock_tc.function.arguments = json.dumps(arguments)
mock_response = MagicMock()
mock_response.usage.prompt_tokens = 120
mock_response.usage.completion_tokens = 40
mock_response.usage.total_tokens = 160
mock_response.choices[0].finish_reason = "tool_calls"
mock_response.choices[0].message.content = None
mock_response.choices[0].message.tool_calls = [mock_tc]
return mock_response
class TestLMStudioNamedToolChoiceBug:
"""
Reproduces issue #520.
The reflect agent (agent.py lines 546-555) sets tool_choice to a named dict
on the first iterations to force sequential retrieval:
iteration=0, has_mental_models=True {"type": "function", "function": {"name": "search_mental_models"}}
iteration=0, has_mental_models=False {"type": "function", "function": {"name": "search_observations"}}
iteration=1, has_mental_models=True {"type": "function", "function": {"name": "search_observations"}}
iteration=1 or (2 with models) {"type": "function", "function": {"name": "recall"}}
LM Studio rejects these dict formats with HTTP 400.
"""
@pytest.mark.asyncio
async def test_lmstudio_named_tool_choice_no_longer_causes_400(self):
"""
Regression test for issue #520: named tool_choice dict is converted to
"required" + filtered tools before the API call, so LM Studio never
sees the unsupported format and the 400 error no longer occurs.
"""
llm = _make_lmstudio_llm()
named_tool_choice = {"type": "function", "function": {"name": "search_mental_models"}}
success_response = _make_tool_call_response("search_mental_models", {"query": "user name"})
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
mock_create.return_value = success_response
# Should succeed — no 400 because the dict is converted before sending
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "What is the user's name?"}],
tools=REFLECT_TOOLS,
tool_choice=named_tool_choice,
max_retries=0,
)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "search_mental_models"
sent_kwargs = mock_create.call_args.kwargs
assert sent_kwargs["tool_choice"] == "required"
assert len(sent_kwargs["tools"]) == 1
assert sent_kwargs["tools"][0]["function"]["name"] == "search_mental_models"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"forced_tool_name",
["search_mental_models", "search_observations", "recall"],
)
async def test_all_reflect_forced_tools_fail_on_lmstudio(self, forced_tool_name: str):
"""
Each named tool_choice the reflect agent uses on iterations 0-2 triggers
the same 400 error on LM Studio.
"""
llm = _make_lmstudio_llm()
named_tool_choice = {"type": "function", "function": {"name": forced_tool_name}}
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
mock_create.side_effect = _lmstudio_400_error()
with pytest.raises(APIStatusError) as exc_info:
await llm.call_with_tools(
messages=[{"role": "user", "content": "Test query"}],
tools=REFLECT_TOOLS,
tool_choice=named_tool_choice,
max_retries=0,
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_lmstudio_string_tool_choice_works_fine(self):
"""
String tool_choice values ("auto", "none", "required") ARE supported by LM Studio.
Only the dict format {"type": "function", "function": {"name": "..."}} fails.
This test confirms the control case works.
"""
llm = _make_lmstudio_llm()
success_response = _make_tool_call_response("search_mental_models", {"query": "user name"})
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
mock_create.return_value = success_response
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "What is the user's name?"}],
tools=REFLECT_TOOLS,
tool_choice="required", # string form — LM Studio accepts this
max_retries=0,
)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "search_mental_models"
# Confirm "required" was sent, not a dict
sent_kwargs = mock_create.call_args.kwargs
assert sent_kwargs["tool_choice"] == "required"
class TestExpectedFixBehavior:
"""
Tests that document the EXPECTED behavior after the fix is applied.
For lmstudio (and ollama) providers, when tool_choice is a named dict:
{"type": "function", "function": {"name": "search_mental_models"}}
The fix should:
1. Convert tool_choice to "required"
2. Filter tools to only the requested tool
These tests currently FAIL (because the fix is not yet implemented).
After the fix is applied, they should PASS.
"""
@pytest.mark.asyncio
async def test_fix_converts_named_tool_choice_to_required(self):
"""
After fix: named tool_choice dict is converted to "required" for lmstudio.
The API receives tool_choice="required" instead of the unsupported dict.
"""
llm = _make_lmstudio_llm()
named_tool_choice = {"type": "function", "function": {"name": "search_mental_models"}}
success_response = _make_tool_call_response("search_mental_models", {"query": "user name"})
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
mock_create.return_value = success_response
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "What is the user's name?"}],
tools=REFLECT_TOOLS,
tool_choice=named_tool_choice,
max_retries=0,
)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "search_mental_models"
sent_kwargs = mock_create.call_args.kwargs
# Fix: dict was converted to "required"
assert sent_kwargs["tool_choice"] == "required", (
f"Expected tool_choice='required', got {sent_kwargs['tool_choice']!r}"
)
# Fix: tools filtered to just the requested one
assert len(sent_kwargs["tools"]) == 1
assert sent_kwargs["tools"][0]["function"]["name"] == "search_mental_models"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"forced_tool_name",
["search_mental_models", "search_observations", "recall"],
)
async def test_fix_filters_tools_to_requested_tool(self, forced_tool_name: str):
"""
After fix: tools list is filtered to only the forced tool so the model
can only call that one tool (equivalent to the named tool_choice behavior).
"""
llm = _make_lmstudio_llm()
named_tool_choice = {"type": "function", "function": {"name": forced_tool_name}}
success_response = _make_tool_call_response(forced_tool_name, {"query": "test"})
with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
mock_create.return_value = success_response
await llm.call_with_tools(
messages=[{"role": "user", "content": "Test query"}],
tools=REFLECT_TOOLS,
tool_choice=named_tool_choice,
max_retries=0,
)
sent_kwargs = mock_create.call_args.kwargs
assert sent_kwargs["tool_choice"] == "required"
assert len(sent_kwargs["tools"]) == 1
assert sent_kwargs["tools"][0]["function"]["name"] == forced_tool_name
@pytest.mark.asyncio
async def test_fix_also_applies_to_openai_provider(self):
"""
The fix is generalized: all providers convert named tool_choice to
"required" + filtered tools. OpenAI natively supports the dict format
too, so the behaviour is semantically identical either way.
"""
from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM
openai_llm = OpenAICompatibleLLM(
provider="openai",
api_key="sk-test",
base_url="",
model="gpt-4o-mini",
)
named_tool_choice = {"type": "function", "function": {"name": "search_mental_models"}}
success_response = _make_tool_call_response("search_mental_models", {"query": "test"})
with patch.object(openai_llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
mock_create.return_value = success_response
await openai_llm.call_with_tools(
messages=[{"role": "user", "content": "Test"}],
tools=REFLECT_TOOLS,
tool_choice=named_tool_choice,
max_retries=0,
)
sent_kwargs = mock_create.call_args.kwargs
# Generalized fix applies to OpenAI too
assert sent_kwargs["tool_choice"] == "required"
assert len(sent_kwargs["tools"]) == 1
assert sent_kwargs["tools"][0]["function"]["name"] == "search_mental_models"
-107
View File
@@ -656,113 +656,6 @@ class TestDirectivesPromptInjection:
assert directives_pos < critical_rules_pos
class TestMentalModelHistory:
"""Test mental model history persistence."""
async def test_history_recorded_on_content_update(self, memory: MemoryEngine, request_context):
"""Test that updating content records a history entry."""
bank_id = f"test-mm-history-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
mm = await memory.create_mental_model(
bank_id=bank_id,
name="Test Model",
source_query="What is the test?",
content="Original content",
request_context=request_context,
)
# No history yet
history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
assert history == []
# Update content
await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
content="Updated content",
request_context=request_context,
)
history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
assert len(history) == 1
assert history[0]["previous_content"] == "Original content"
assert "changed_at" in history[0]
await memory.delete_bank(bank_id, request_context=request_context)
async def test_history_ordered_most_recent_first(self, memory: MemoryEngine, request_context):
"""Test that history is returned most recent first."""
bank_id = f"test-mm-history-order-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
mm = await memory.create_mental_model(
bank_id=bank_id,
name="Test Model",
source_query="What is the test?",
content="v1",
request_context=request_context,
)
await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
content="v2",
request_context=request_context,
)
await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
content="v3",
request_context=request_context,
)
history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
assert len(history) == 2
# Most recent first: second update recorded "v2" as previous, first recorded "v1"
assert history[0]["previous_content"] == "v2"
assert history[1]["previous_content"] == "v1"
await memory.delete_bank(bank_id, request_context=request_context)
async def test_history_not_recorded_on_name_only_update(self, memory: MemoryEngine, request_context):
"""Test that updating only name does not record history."""
bank_id = f"test-mm-history-name-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
mm = await memory.create_mental_model(
bank_id=bank_id,
name="Original Name",
source_query="What is the test?",
content="Content",
request_context=request_context,
)
await memory.update_mental_model(
bank_id=bank_id,
mental_model_id=mm["id"],
name="Updated Name",
request_context=request_context,
)
history = await memory.get_mental_model_history(bank_id, mm["id"], request_context=request_context)
assert history == []
await memory.delete_bank(bank_id, request_context=request_context)
async def test_history_returns_none_for_missing_model(self, memory: MemoryEngine, request_context):
"""Test that history returns None when mental model doesn't exist."""
bank_id = f"test-mm-history-missing-{uuid.uuid4().hex[:8]}"
await memory.get_bank_profile(bank_id, request_context=request_context)
result = await memory.get_mental_model_history(
bank_id, "nonexistent-id", request_context=request_context
)
assert result is None
await memory.delete_bank(bank_id, request_context=request_context)
class TestMentalModelRefreshTagSecurity:
"""Test that mental model refresh respects tag-based security boundaries."""
@@ -1,48 +0,0 @@
import threading
import time
from hindsight_api import migrations
def test_run_migrations_internal_serializes_alembic_upgrade(monkeypatch):
max_concurrent_upgrades = 0
active_upgrades = 0
active_lock = threading.Lock()
start_barrier = threading.Barrier(2)
def fake_upgrade(_cfg, _revision):
nonlocal max_concurrent_upgrades, active_upgrades
with active_lock:
active_upgrades += 1
max_concurrent_upgrades = max(max_concurrent_upgrades, active_upgrades)
time.sleep(0.05)
with active_lock:
active_upgrades -= 1
monkeypatch.setattr(migrations.command, "upgrade", fake_upgrade)
errors = []
def run_in_thread(schema):
try:
start_barrier.wait()
migrations._run_migrations_internal(
"postgresql://user:pass@localhost/db",
"/tmp/alembic",
schema=schema,
)
except Exception as exc: # pragma: no cover - diagnostic path
errors.append(exc)
threads = [
threading.Thread(target=run_in_thread, args=("tenant_alpha",)),
threading.Thread(target=run_in_thread, args=("tenant_beta",)),
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert not errors
assert max_concurrent_upgrades == 1
@@ -455,281 +455,3 @@ class TestClearObservationsForMemory:
assert await _get_consolidated_at(conn, m2) is None
await memory.delete_bank(bank_id, request_context=request_context)
# ---------------------------------------------------------------------------
# Tests: update_document
# ---------------------------------------------------------------------------
async def _insert_document_with_memories(
conn, bank_id: str, doc_id: str, memories: list[tuple[str, str]]
) -> list[uuid.UUID]:
"""Insert a document and attach memory units to it. Returns list of memory UUIDs."""
await conn.execute(
"""
INSERT INTO documents (id, bank_id, original_text, content_hash, created_at, updated_at)
VALUES ($1, $2, 'some doc', 'hash123', NOW(), NOW())
""",
doc_id,
bank_id,
)
mem_ids = []
for text, fact_type in memories:
mem_id = uuid.uuid4()
await conn.execute(
"""
INSERT INTO memory_units (id, bank_id, text, fact_type, event_date, document_id, created_at, updated_at, consolidated_at)
VALUES ($1, $2, $3, $4, NOW(), $5, NOW(), NOW(), NOW())
""",
mem_id,
bank_id,
text,
fact_type,
doc_id,
)
mem_ids.append(mem_id)
return mem_ids
class TestUpdateDocumentTagsObservationCleanup:
@pytest.mark.asyncio
async def test_update_tags_returns_updated_document(
self, memory: MemoryEngine, request_context: RequestContext
):
"""update_document returns the updated document with new tags."""
bank_id = f"test-tag-update-basic-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
await _insert_document_with_memories(conn, bank_id, doc_id, [("Alice loves hiking.", "experience")])
result = await memory.update_document(
doc_id, bank_id, tags=["new-tag"], request_context=request_context
)
assert result is True
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_tags_returns_none_for_missing_document(
self, memory: MemoryEngine, request_context: RequestContext
):
"""update_document returns False when document does not exist."""
bank_id = f"test-tag-update-missing-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
result = await memory.update_document(
"nonexistent-doc", bank_id, tags=["tag"], request_context=request_context
)
assert result is False
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_tags_propagates_to_memory_units(
self, memory: MemoryEngine, request_context: RequestContext
):
"""Changing document tags also updates all associated memory unit tags."""
bank_id = f"test-tag-update-propagate-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
mem_ids = await _insert_document_with_memories(
conn, bank_id, doc_id, [("Alice loves hiking.", "experience"), ("Alice hikes weekly.", "world")]
)
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()):
await memory.update_document(
doc_id, bank_id, tags=["new-tag"], request_context=request_context
)
async with pool.acquire() as conn:
for mem_id in mem_ids:
tags = await conn.fetchval(
"SELECT tags FROM memory_units WHERE id = $1", mem_id
)
assert list(tags) == ["new-tag"], f"Memory unit {mem_id} should have updated tags"
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_tags_invalidates_observations(
self, memory: MemoryEngine, request_context: RequestContext
):
"""Observations referencing the document's memory units are deleted on tag change."""
bank_id = f"test-tag-update-obs-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
mem_ids = await _insert_document_with_memories(
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
)
obs_id = await _insert_observation(conn, bank_id, "Alice is a hiker.", mem_ids)
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()):
await memory.update_document(
doc_id, bank_id, tags=["new-tag"], request_context=request_context
)
async with pool.acquire() as conn:
obs_ids = await _get_observation_ids(conn, bank_id)
assert str(obs_id) not in obs_ids, "Observation should have been invalidated"
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_tags_resets_consolidated_at_on_affected_units(
self, memory: MemoryEngine, request_context: RequestContext
):
"""Affected memory units get consolidated_at reset for re-consolidation under new tags."""
bank_id = f"test-tag-update-reset-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
mem_ids = await _insert_document_with_memories(
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
)
obs_id = await _insert_observation(conn, bank_id, "Alice is a hiker.", mem_ids)
# Verify memory starts consolidated
assert await _get_consolidated_at(conn, mem_ids[0]) is not None
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()):
await memory.update_document(
doc_id, bank_id, tags=["new-tag"], request_context=request_context
)
async with pool.acquire() as conn:
consolidated_at = await _get_consolidated_at(conn, mem_ids[0])
assert consolidated_at is None, "Memory unit should be reset for re-consolidation"
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_tags_triggers_consolidation_when_observations_invalidated(
self, memory: MemoryEngine, request_context: RequestContext
):
"""submit_async_consolidation is called when observations are invalidated."""
bank_id = f"test-tag-update-cons-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
mem_ids = await _insert_document_with_memories(
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
)
await _insert_observation(conn, bank_id, "Alice is a hiker.", mem_ids)
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()) as mock_consolidate:
await memory.update_document(
doc_id, bank_id, tags=["new-tag"], request_context=request_context
)
mock_consolidate.assert_awaited_once()
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_tags_no_consolidation_when_no_observations(
self, memory: MemoryEngine, request_context: RequestContext
):
"""submit_async_consolidation is NOT called when no observations are invalidated."""
bank_id = f"test-tag-update-nocons-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
await _insert_document_with_memories(
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
)
# No observations inserted
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()) as mock_consolidate:
await memory.update_document(
doc_id, bank_id, tags=["new-tag"], request_context=request_context
)
mock_consolidate.assert_not_awaited()
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_tags_resets_co_source_memories_from_other_documents(
self, memory: MemoryEngine, request_context: RequestContext
):
"""Co-source memories from other documents that shared an invalidated observation are also reset."""
bank_id = f"test-tag-update-cosource-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
doc_mem_ids = await _insert_document_with_memories(
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
)
# Unrelated memory from another document — co-sourced in the same observation
other_mem = await _insert_memory(conn, bank_id, "Alice also rock-climbs.")
obs_id = await _insert_observation(
conn, bank_id, "Alice loves outdoor activities.", doc_mem_ids + [other_mem]
)
# Verify other_mem starts consolidated
assert await _get_consolidated_at(conn, other_mem) is not None
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()):
await memory.update_document(
doc_id, bank_id, tags=["new-tag"], request_context=request_context
)
async with pool.acquire() as conn:
obs_ids = await _get_observation_ids(conn, bank_id)
assert str(obs_id) not in obs_ids, "Observation should have been invalidated"
# other_mem (co-source from another document) must also be reset
consolidated_at = await _get_consolidated_at(conn, other_mem)
assert consolidated_at is None, "Co-source memory from other document should be reset"
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_update_tags_does_not_affect_unrelated_observations(
self, memory: MemoryEngine, request_context: RequestContext
):
"""Observations referencing memories from a different document are not affected."""
bank_id = f"test-tag-update-unrelated-{uuid.uuid4().hex[:8]}"
await _ensure_bank(memory, bank_id, request_context)
pool = await memory._get_pool()
async with pool.acquire() as conn:
doc_id = f"doc-{uuid.uuid4().hex[:8]}"
mem_ids = await _insert_document_with_memories(
conn, bank_id, doc_id, [("Alice loves hiking.", "experience")]
)
# Unrelated memory not in the document
unrelated = await _insert_memory(conn, bank_id, "Bob likes cycling.")
unrelated_obs_id = await _insert_observation(
conn, bank_id, "Bob is a cyclist.", [unrelated]
)
with patch.object(memory, "submit_async_consolidation", new=AsyncMock()):
await memory.update_document(
doc_id, bank_id, tags=["new-tag"], request_context=request_context
)
async with pool.acquire() as conn:
obs_ids = await _get_observation_ids(conn, bank_id)
assert str(unrelated_obs_id) in obs_ids, "Unrelated observation should remain untouched"
await memory.delete_bank(bank_id, request_context=request_context)
@@ -231,61 +231,6 @@ async def test_recall_chunks_ordering_by_relevance(memory, request_context):
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_recall_chunks_for_observations(memory, request_context):
"""
Test that chunks are returned when recalling only observations.
Observations have no direct chunk_id (they are synthesized from source memories).
When include_chunks=True, chunks should be resolved via source_memory_ids.
"""
bank_id = "test-chunks-observations"
try:
# Retain content that will generate observations via consolidation
test_content = """
Alice is a senior software engineer at a large technology company.
She specializes in distributed systems and has 10 years of experience.
Alice leads a team of 8 engineers working on cloud infrastructure.
She holds a PhD in computer science from Stanford University.
Alice has published several papers on fault-tolerant distributed systems.
""" * 8
await memory.retain_async(
bank_id=bank_id,
content=test_content,
context="profile notes",
request_context=request_context,
)
# Trigger consolidation explicitly to ensure observations exist
await memory.run_consolidation(bank_id=bank_id, request_context=request_context)
# Recall observations only with chunks enabled
result = await memory.recall_async(
bank_id=bank_id,
query="Alice software engineer",
fact_type=["observation"],
max_tokens=4096,
include_chunks=True,
max_chunk_tokens=2000,
budget=Budget.MID,
request_context=request_context,
)
# If observations were created, chunks should be resolved from source memories
if len(result.results) > 0:
assert result.chunks is not None, "Should include chunks dict when observations are found"
assert len(result.chunks) > 0, "Should return chunks resolved from observation source memories"
for chunk_id, chunk_info in result.chunks.items():
assert len(chunk_info.chunk_text) > 0, "Chunks should contain text"
assert chunk_info.chunk_index >= 0, "Chunk should have valid index"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_recall_chunks_without_include_flag(memory, request_context):
"""
@@ -1,170 +0,0 @@
"""Tests for source_facts token limiting in recall.
Covers:
- max_source_facts_tokens: total token budget across all source facts
- max_source_facts_tokens_per_observation: per-observation cap
Both parameters are tested at the recall_async level and verified to produce
fewer source facts when the budget is tight vs. unlimited.
"""
import pytest
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.memory_engine import Budget
@pytest.fixture(autouse=True)
def enable_observations():
config = _get_raw_config()
original = config.enable_observations
config.enable_observations = True
yield
config.enable_observations = original
async def _setup_bank_with_observations(memory, bank_id, request_context):
"""Retain several memories and trigger consolidation to produce observations with source facts."""
contents = [
"Alice is a software engineer who loves Python programming.",
"Alice has been working at TechCorp for 5 years.",
"Alice recently completed a machine learning certification course.",
"Alice mentors junior developers on the team.",
"Alice prefers functional programming patterns in her code.",
]
for content in contents:
await memory.retain_async(
bank_id=bank_id,
content=content,
request_context=request_context,
)
await memory.run_consolidation(bank_id=bank_id, request_context=request_context)
class TestRecallSourceFactsPerObservationCap:
@pytest.mark.asyncio
async def test_per_observation_cap_reduces_source_facts(self, memory, request_context):
"""A tight per-observation token cap should return fewer source facts than unlimited."""
bank_id = "test-sf-per-obs-cap"
try:
await _setup_bank_with_observations(memory, bank_id, request_context)
result_limited = await memory.recall_async(
bank_id=bank_id,
query="Alice engineer",
fact_type=["observation"],
max_tokens=4096,
include_source_facts=True,
max_source_facts_tokens_per_observation=1, # Effectively cuts all source facts
budget=Budget.MID,
request_context=request_context,
)
result_unlimited = await memory.recall_async(
bank_id=bank_id,
query="Alice engineer",
fact_type=["observation"],
max_tokens=4096,
include_source_facts=True,
max_source_facts_tokens_per_observation=-1,
budget=Budget.MID,
request_context=request_context,
)
unlimited_count = len(result_unlimited.source_facts) if result_unlimited.source_facts else 0
limited_count = len(result_limited.source_facts) if result_limited.source_facts else 0
if unlimited_count > 0:
assert limited_count <= unlimited_count, (
f"Per-observation cap should yield fewer source facts ({limited_count} <= {unlimited_count})"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_per_observation_cap_does_not_mix_between_observations(self, memory, request_context):
"""Each observation's source facts are capped independently — not as a shared pool."""
bank_id = "test-sf-per-obs-independent"
try:
await _setup_bank_with_observations(memory, bank_id, request_context)
# With a generous per-observation limit each observation can have facts;
# with a global limit of 1 token the first observation would consume the whole budget.
result_per_obs = await memory.recall_async(
bank_id=bank_id,
query="Alice engineer",
fact_type=["observation"],
max_tokens=4096,
include_source_facts=True,
max_source_facts_tokens=4096, # large global budget
max_source_facts_tokens_per_observation=512, # reasonable per-obs limit
budget=Budget.MID,
request_context=request_context,
)
# Should not raise; source_facts may be populated for multiple observations
assert result_per_obs.source_facts is not None or len(result_per_obs.results) == 0
finally:
await memory.delete_bank(bank_id, request_context=request_context)
class TestRecallSourceFactsTotalBudget:
@pytest.mark.asyncio
async def test_total_budget_limits_source_facts(self, memory, request_context):
"""A tight total token budget should return fewer source facts than unlimited."""
bank_id = "test-sf-total-budget"
try:
await _setup_bank_with_observations(memory, bank_id, request_context)
result_tight = await memory.recall_async(
bank_id=bank_id,
query="Alice engineer",
fact_type=["observation"],
max_tokens=4096,
include_source_facts=True,
max_source_facts_tokens=1, # Effectively cuts all source facts
budget=Budget.MID,
request_context=request_context,
)
result_unlimited = await memory.recall_async(
bank_id=bank_id,
query="Alice engineer",
fact_type=["observation"],
max_tokens=4096,
include_source_facts=True,
max_source_facts_tokens=-1,
budget=Budget.MID,
request_context=request_context,
)
unlimited_count = len(result_unlimited.source_facts) if result_unlimited.source_facts else 0
tight_count = len(result_tight.source_facts) if result_tight.source_facts else 0
if unlimited_count > 0:
assert tight_count <= unlimited_count, (
f"Total budget should yield fewer source facts ({tight_count} <= {unlimited_count})"
)
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_no_source_facts_without_flag(self, memory, request_context):
"""source_facts should be None when include_source_facts is not set."""
bank_id = "test-sf-no-flag"
try:
await _setup_bank_with_observations(memory, bank_id, request_context)
result = await memory.recall_async(
bank_id=bank_id,
query="Alice engineer",
fact_type=["observation"],
max_tokens=4096,
include_source_facts=False, # default
budget=Budget.MID,
request_context=request_context,
)
assert result.source_facts is None or len(result.source_facts) == 0
finally:
await memory.delete_bank(bank_id, request_context=request_context)
-780
View File
@@ -1,780 +0,0 @@
"""Tests for the webhook system.
Covers:
- Unit tests for HMAC signing and retry constants (no DB required)
- Integration tests for fire_event() using a real DB (inserts into async_operations)
- Integration tests for _handle_webhook_delivery() on the memory engine
- HTTP API integration tests for CRUD and delivery listing endpoints
"""
import json
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
import pytest_asyncio
from hindsight_api.api import create_app
from hindsight_api.engine.memory_engine import MemoryEngine
from hindsight_api.webhooks.manager import MAX_ATTEMPTS, RETRY_DELAYS, WebhookManager
from hindsight_api.webhooks.models import (
ConsolidationEventData,
RetainEventData,
WebhookConfig,
WebhookEvent,
WebhookEventType,
)
from hindsight_api.worker.exceptions import RetryTaskAt
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_event(bank_id: str = "bank-1") -> WebhookEvent:
return WebhookEvent(
event=WebhookEventType.CONSOLIDATION_COMPLETED,
bank_id=bank_id,
operation_id=uuid.uuid4().hex,
status="completed",
timestamp=datetime.now(timezone.utc),
data=ConsolidationEventData(observations_created=1),
)
def _make_delivery_task(
bank_id: str = "bank-1",
url: str = "https://example.com/hook",
retry_count: int = 0,
webhook_id: str | None = None,
) -> dict:
return {
"type": "webhook_delivery",
"bank_id": bank_id,
"url": url,
"secret": None,
"event_type": "consolidation.completed",
"payload": '{"event":"consolidation.completed"}',
"webhook_id": webhook_id,
"_retry_count": retry_count,
}
# ---------------------------------------------------------------------------
# Unit tests (no DB)
# ---------------------------------------------------------------------------
class TestHmacSigning:
"""Unit tests for WebhookManager._sign_payload()."""
def _make_manager(self) -> WebhookManager:
"""Create a WebhookManager with a dummy pool (not used for signing)."""
pool = MagicMock()
return WebhookManager(pool=pool, global_webhooks=[])
def test_hmac_signing_format(self):
"""_sign_payload should return a string starting with 'sha256='."""
manager = self._make_manager()
sig = manager._sign_payload("my-secret", b"hello world")
assert sig.startswith("sha256="), f"Expected 'sha256=' prefix, got: {sig!r}"
hex_part = sig[len("sha256="):]
# SHA-256 hex digest is always 64 characters
assert len(hex_part) == 64
# Hex characters only
assert all(c in "0123456789abcdef" for c in hex_part)
def test_hmac_signing_is_deterministic(self):
"""Same secret + payload always produces the same signature."""
manager = self._make_manager()
payload = b'{"event":"consolidation.completed"}'
sig1 = manager._sign_payload("secret-key", payload)
sig2 = manager._sign_payload("secret-key", payload)
assert sig1 == sig2
def test_hmac_signing_differs_with_different_secret(self):
"""Different secrets must produce different signatures."""
manager = self._make_manager()
payload = b"payload"
sig1 = manager._sign_payload("secret-a", payload)
sig2 = manager._sign_payload("secret-b", payload)
assert sig1 != sig2
def test_hmac_signing_differs_with_different_payload(self):
"""Different payloads must produce different signatures."""
manager = self._make_manager()
sig1 = manager._sign_payload("secret", b"payload-one")
sig2 = manager._sign_payload("secret", b"payload-two")
assert sig1 != sig2
class TestRetryConstants:
"""Unit tests to verify retry schedule constants."""
def test_retry_delays_values(self):
"""RETRY_DELAYS must match the documented schedule."""
assert RETRY_DELAYS == [5, 300, 1800, 7200, 18000]
def test_max_attempts(self):
"""MAX_ATTEMPTS should be len(RETRY_DELAYS) + 1."""
assert MAX_ATTEMPTS == 6
assert MAX_ATTEMPTS == len(RETRY_DELAYS) + 1
# ---------------------------------------------------------------------------
# DB integration tests
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def webhook_manager(memory: MemoryEngine) -> WebhookManager:
"""Return a WebhookManager backed by the test pool with no global webhooks."""
return WebhookManager(pool=memory._pool, global_webhooks=[])
class TestFireEvent:
"""Integration tests for WebhookManager.fire_event()."""
@pytest.mark.asyncio
async def test_fire_event_creates_delivery(
self, memory: MemoryEngine, webhook_manager: WebhookManager
):
"""fire_event() inserts a pending webhook_delivery task in async_operations."""
bank_id = f"wh-test-{uuid.uuid4().hex[:8]}"
webhook_id = uuid.uuid4()
async with memory._pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO webhooks (id, bank_id, url, secret, event_types, enabled, created_at, updated_at)
VALUES ($1, $2, $3, NULL, $4, true, NOW(), NOW())
""",
webhook_id,
bank_id,
"https://example.com/hook",
["consolidation.completed"],
)
try:
event = _make_event(bank_id)
await webhook_manager.fire_event(event)
async with memory._pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT status, task_payload
FROM async_operations
WHERE operation_type = 'webhook_delivery'
AND bank_id = $1
AND task_payload->>'webhook_id' = $2
""",
bank_id,
str(webhook_id),
)
assert len(rows) == 1
assert rows[0]["status"] == "pending"
payload = rows[0]["task_payload"]
if isinstance(payload, str):
payload = json.loads(payload)
assert payload["event_type"] == "consolidation.completed"
finally:
async with memory._pool.acquire() as conn:
await conn.execute(
"DELETE FROM async_operations WHERE operation_type = 'webhook_delivery' AND bank_id = $1",
bank_id,
)
await conn.execute("DELETE FROM webhooks WHERE id = $1", webhook_id)
@pytest.mark.asyncio
async def test_fire_event_global_webhook(
self, memory: MemoryEngine
):
"""fire_event() also queues delivery tasks for global webhooks (not stored in DB)."""
bank_id = f"wh-global-{uuid.uuid4().hex[:8]}"
global_webhook = WebhookConfig(
id="", # No DB row
bank_id=None,
url="https://global.example.com/hook",
secret=None,
event_types=["consolidation.completed"],
enabled=True,
)
manager = WebhookManager(pool=memory._pool, global_webhooks=[global_webhook])
event = _make_event(bank_id)
await manager.fire_event(event)
async with memory._pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT status, task_payload
FROM async_operations
WHERE operation_type = 'webhook_delivery'
AND bank_id = $1
AND task_payload->>'url' = 'https://global.example.com/hook'
ORDER BY created_at DESC
LIMIT 1
"""
,
bank_id,
)
assert len(rows) == 1
assert rows[0]["status"] == "pending"
payload = rows[0]["task_payload"]
if isinstance(payload, str):
payload = json.loads(payload)
assert payload["webhook_id"] is None # global webhook has no DB row
# Cleanup
async with memory._pool.acquire() as conn:
await conn.execute(
"DELETE FROM async_operations WHERE operation_type = 'webhook_delivery' AND bank_id = $1",
bank_id,
)
@pytest.mark.asyncio
async def test_fire_event_no_match_if_event_type_mismatch(
self, memory: MemoryEngine, webhook_manager: WebhookManager
):
"""Webhooks registered for a different event type receive no delivery task."""
bank_id = f"wh-mismatch-{uuid.uuid4().hex[:8]}"
webhook_id = uuid.uuid4()
async with memory._pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO webhooks (id, bank_id, url, secret, event_types, enabled, created_at, updated_at)
VALUES ($1, $2, $3, NULL, $4, true, NOW(), NOW())
""",
webhook_id,
bank_id,
"https://example.com/other-hook",
["other.event"],
)
try:
event = _make_event(bank_id)
await webhook_manager.fire_event(event)
async with memory._pool.acquire() as conn:
count = await conn.fetchval(
"""
SELECT COUNT(*) FROM async_operations
WHERE operation_type = 'webhook_delivery' AND bank_id = $1
""",
bank_id,
)
assert count == 0
finally:
async with memory._pool.acquire() as conn:
await conn.execute("DELETE FROM webhooks WHERE id = $1", webhook_id)
class TestHandleWebhookDelivery:
"""Integration tests for MemoryEngine._handle_webhook_delivery()."""
@pytest.mark.asyncio
async def test_deliver_success(self, memory: MemoryEngine):
"""A successful HTTP POST completes without raising."""
task_dict = _make_delivery_task(retry_count=0)
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
with patch.object(memory._http_client, "post", new=AsyncMock(return_value=mock_response)):
# Should not raise
await memory._handle_webhook_delivery(task_dict)
@pytest.mark.asyncio
async def test_deliver_failure_raises_retry_task_at(self, memory: MemoryEngine):
"""A failed HTTP POST raises RetryTaskAt when retries remain."""
task_dict = _make_delivery_task(retry_count=0)
with patch.object(
memory._http_client, "post", new=AsyncMock(side_effect=Exception("connection refused"))
):
with pytest.raises(RetryTaskAt):
await memory._handle_webhook_delivery(task_dict)
@pytest.mark.asyncio
async def test_deliver_exhausted_retries_raises(self, memory: MemoryEngine):
"""When retry_count reaches MAX_ATTEMPTS-1, a failure raises the original exception."""
task_dict = _make_delivery_task(retry_count=MAX_ATTEMPTS - 1)
with patch.object(
memory._http_client, "post", new=AsyncMock(side_effect=Exception("server error"))
):
with pytest.raises(Exception, match="server error"):
await memory._handle_webhook_delivery(task_dict)
@pytest.mark.asyncio
async def test_deliver_retry_at_uses_delay_schedule(self, memory: MemoryEngine):
"""RetryTaskAt.retry_at is approximately now + RETRY_DELAYS[retry_count]."""
from datetime import timedelta
task_dict = _make_delivery_task(retry_count=1)
with patch.object(
memory._http_client, "post", new=AsyncMock(side_effect=Exception("fail"))
):
before = datetime.now(timezone.utc)
with pytest.raises(RetryTaskAt) as exc_info:
await memory._handle_webhook_delivery(task_dict)
after = datetime.now(timezone.utc)
retry_at = exc_info.value.retry_at
expected_delay = RETRY_DELAYS[1] # retry_count=1
assert retry_at >= before + timedelta(seconds=expected_delay - 2)
assert retry_at <= after + timedelta(seconds=expected_delay + 2)
@pytest.mark.asyncio
async def test_execute_task_marks_operation_completed(self, memory: MemoryEngine):
"""After a successful delivery, execute_task marks the async_operations row as completed."""
operation_id = str(uuid.uuid4())
bank_id = f"wh-exec-{uuid.uuid4().hex[:8]}"
# Insert a real async_operations row so _mark_operation_completed has something to update
async with memory._pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO async_operations
(operation_id, bank_id, operation_type, status, task_payload, result_metadata, created_at, updated_at)
VALUES ($1, $2, 'webhook_delivery', 'processing', '{}'::jsonb, '{}'::jsonb, NOW(), NOW())
""",
uuid.UUID(operation_id),
bank_id,
)
task_dict = {
**_make_delivery_task(bank_id=bank_id, retry_count=0),
"operation_id": operation_id,
}
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
with patch.object(memory._http_client, "post", new=AsyncMock(return_value=mock_response)):
await memory.execute_task(task_dict)
async with memory._pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT status FROM async_operations WHERE operation_id = $1",
uuid.UUID(operation_id),
)
assert row is not None
assert row["status"] == "completed", f"Expected 'completed', got '{row['status']}'"
# Cleanup
async with memory._pool.acquire() as conn:
await conn.execute(
"DELETE FROM async_operations WHERE operation_id = $1",
uuid.UUID(operation_id),
)
# ---------------------------------------------------------------------------
# HTTP API integration tests
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def api_client(memory: MemoryEngine):
"""Async HTTP test client wired to the FastAPI app."""
app = create_app(memory, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
class TestWebhookHttpApi:
"""HTTP API integration tests for webhook CRUD endpoints."""
@pytest.mark.asyncio
async def test_http_create_webhook(self, api_client: httpx.AsyncClient):
"""POST /webhooks returns 201 and an id."""
bank_id = f"http-wh-{uuid.uuid4().hex[:8]}"
response = await api_client.post(
f"/v1/default/banks/{bank_id}/webhooks",
json={
"url": "https://example.com/create",
"event_types": ["consolidation.completed"],
},
)
assert response.status_code == 201, response.text
data = response.json()
assert "id" in data
assert data["url"] == "https://example.com/create"
assert data["bank_id"] == bank_id
assert data["secret"] is None # secrets are never echoed back
# Cleanup
await api_client.delete(
f"/v1/default/banks/{bank_id}/webhooks/{data['id']}"
)
@pytest.mark.asyncio
async def test_http_list_webhooks(self, api_client: httpx.AsyncClient):
"""GET /webhooks returns the webhooks registered for a bank."""
bank_id = f"http-wh-{uuid.uuid4().hex[:8]}"
create_resp = await api_client.post(
f"/v1/default/banks/{bank_id}/webhooks",
json={"url": "https://example.com/list", "event_types": ["consolidation.completed"]},
)
assert create_resp.status_code == 201
webhook_id = create_resp.json()["id"]
list_resp = await api_client.get(f"/v1/default/banks/{bank_id}/webhooks")
assert list_resp.status_code == 200
items = list_resp.json()["items"]
assert any(item["id"] == webhook_id for item in items)
# Cleanup
await api_client.delete(f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}")
@pytest.mark.asyncio
async def test_http_delete_webhook(self, api_client: httpx.AsyncClient):
"""DELETE /webhooks/{id} removes the webhook; subsequent list returns empty for that bank."""
bank_id = f"http-wh-{uuid.uuid4().hex[:8]}"
create_resp = await api_client.post(
f"/v1/default/banks/{bank_id}/webhooks",
json={"url": "https://example.com/delete", "event_types": ["consolidation.completed"]},
)
assert create_resp.status_code == 201
webhook_id = create_resp.json()["id"]
delete_resp = await api_client.delete(
f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}"
)
assert delete_resp.status_code == 200
assert delete_resp.json()["success"] is True
list_resp = await api_client.get(f"/v1/default/banks/{bank_id}/webhooks")
assert list_resp.status_code == 200
ids = [item["id"] for item in list_resp.json()["items"]]
assert webhook_id not in ids
@pytest.mark.asyncio
async def test_http_delete_webhook_not_found(self, api_client: httpx.AsyncClient):
"""DELETE with a non-existent webhook id returns 404."""
bank_id = f"http-wh-{uuid.uuid4().hex[:8]}"
missing_id = str(uuid.uuid4())
response = await api_client.delete(
f"/v1/default/banks/{bank_id}/webhooks/{missing_id}"
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_http_list_deliveries(
self, memory: MemoryEngine, api_client: httpx.AsyncClient
):
"""GET /webhooks/{id}/deliveries returns delivery records for a webhook."""
bank_id = f"http-wh-{uuid.uuid4().hex[:8]}"
# Create webhook via HTTP API
create_resp = await api_client.post(
f"/v1/default/banks/{bank_id}/webhooks",
json={
"url": "https://example.com/deliveries",
"event_types": ["consolidation.completed"],
},
)
assert create_resp.status_code == 201
webhook_id = create_resp.json()["id"]
# Insert a delivery row directly into async_operations
delivery_id = uuid.uuid4()
now = datetime.now(timezone.utc)
task_payload = json.dumps(
{
"type": "webhook_delivery",
"bank_id": bank_id,
"url": "https://example.com/deliveries",
"secret": None,
"event_type": "consolidation.completed",
"payload": '{"event":"consolidation.completed"}',
"webhook_id": webhook_id,
}
)
async with memory._pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO async_operations
(operation_id, bank_id, operation_type, status, retry_count, task_payload, result_metadata, created_at, updated_at)
VALUES ($1, $2, 'webhook_delivery', 'completed', 0, $3::jsonb, '{}'::jsonb, $4, $4)
""",
delivery_id,
bank_id,
task_payload,
now,
)
try:
deliveries_resp = await api_client.get(
f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}/deliveries"
)
assert deliveries_resp.status_code == 200
items = deliveries_resp.json()["items"]
ids = [item["id"] for item in items]
assert str(delivery_id) in ids
# Verify shape of a delivery item
delivery = next(item for item in items if item["id"] == str(delivery_id))
assert delivery["status"] == "completed"
assert delivery["event_type"] == "consolidation.completed"
assert delivery["attempts"] == 1
finally:
async with memory._pool.acquire() as conn:
await conn.execute(
"DELETE FROM async_operations WHERE operation_id = $1", delivery_id
)
await api_client.delete(
f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}"
)
@pytest.mark.asyncio
async def test_http_list_deliveries_webhook_not_found(self, api_client: httpx.AsyncClient):
"""GET /webhooks/{id}/deliveries for a non-existent webhook returns 404."""
bank_id = f"http-wh-{uuid.uuid4().hex[:8]}"
missing_id = str(uuid.uuid4())
response = await api_client.get(
f"/v1/default/banks/{bank_id}/webhooks/{missing_id}/deliveries"
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_http_update_webhook_url(self, api_client: httpx.AsyncClient):
"""PATCH /webhooks/{id} updates only the provided fields."""
bank_id = f"http-wh-{uuid.uuid4().hex[:8]}"
create_resp = await api_client.post(
f"/v1/default/banks/{bank_id}/webhooks",
json={"url": "https://example.com/original", "event_types": ["consolidation.completed"]},
)
assert create_resp.status_code == 201
webhook_id = create_resp.json()["id"]
patch_resp = await api_client.patch(
f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}",
json={"url": "https://example.com/updated"},
)
assert patch_resp.status_code == 200
data = patch_resp.json()
assert data["url"] == "https://example.com/updated"
# event_types should be unchanged
assert "consolidation.completed" in data["event_types"]
# Cleanup
await api_client.delete(f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}")
@pytest.mark.asyncio
async def test_http_update_webhook_event_types(self, api_client: httpx.AsyncClient):
"""PATCH /webhooks/{id} can update event_types."""
bank_id = f"http-wh-{uuid.uuid4().hex[:8]}"
create_resp = await api_client.post(
f"/v1/default/banks/{bank_id}/webhooks",
json={"url": "https://example.com/hook", "event_types": ["consolidation.completed"]},
)
assert create_resp.status_code == 201
webhook_id = create_resp.json()["id"]
patch_resp = await api_client.patch(
f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}",
json={"event_types": ["retain.completed"]},
)
assert patch_resp.status_code == 200
data = patch_resp.json()
assert data["event_types"] == ["retain.completed"]
# Cleanup
await api_client.delete(f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}")
@pytest.mark.asyncio
async def test_http_update_webhook_enabled(self, api_client: httpx.AsyncClient):
"""PATCH /webhooks/{id} can toggle enabled."""
bank_id = f"http-wh-{uuid.uuid4().hex[:8]}"
create_resp = await api_client.post(
f"/v1/default/banks/{bank_id}/webhooks",
json={"url": "https://example.com/hook", "event_types": ["consolidation.completed"]},
)
assert create_resp.status_code == 201
webhook_id = create_resp.json()["id"]
assert create_resp.json()["enabled"] is True
patch_resp = await api_client.patch(
f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}",
json={"enabled": False},
)
assert patch_resp.status_code == 200
assert patch_resp.json()["enabled"] is False
# Cleanup
await api_client.delete(f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}")
@pytest.mark.asyncio
async def test_http_update_webhook_http_config(self, api_client: httpx.AsyncClient):
"""PATCH /webhooks/{id} can update http_config."""
bank_id = f"http-wh-{uuid.uuid4().hex[:8]}"
create_resp = await api_client.post(
f"/v1/default/banks/{bank_id}/webhooks",
json={"url": "https://example.com/hook", "event_types": ["consolidation.completed"]},
)
assert create_resp.status_code == 201
webhook_id = create_resp.json()["id"]
patch_resp = await api_client.patch(
f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}",
json={
"http_config": {
"method": "POST",
"timeout_seconds": 10,
"headers": {"X-Custom": "value"},
"params": {},
}
},
)
assert patch_resp.status_code == 200
data = patch_resp.json()
assert data["http_config"]["timeout_seconds"] == 10
assert data["http_config"]["headers"] == {"X-Custom": "value"}
# Cleanup
await api_client.delete(f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}")
@pytest.mark.asyncio
async def test_http_update_webhook_not_found(self, api_client: httpx.AsyncClient):
"""PATCH /webhooks/{id} returns 404 for a non-existent webhook."""
bank_id = f"http-wh-{uuid.uuid4().hex[:8]}"
missing_id = str(uuid.uuid4())
response = await api_client.patch(
f"/v1/default/banks/{bank_id}/webhooks/{missing_id}",
json={"url": "https://example.com/new"},
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_http_update_webhook_no_fields(self, api_client: httpx.AsyncClient):
"""PATCH /webhooks/{id} with empty body returns 422."""
bank_id = f"http-wh-{uuid.uuid4().hex[:8]}"
create_resp = await api_client.post(
f"/v1/default/banks/{bank_id}/webhooks",
json={"url": "https://example.com/hook", "event_types": ["consolidation.completed"]},
)
assert create_resp.status_code == 201
webhook_id = create_resp.json()["id"]
patch_resp = await api_client.patch(
f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}",
json={},
)
assert patch_resp.status_code == 422
# Cleanup
await api_client.delete(f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}")
# ---------------------------------------------------------------------------
# retain.completed webhook tests
# ---------------------------------------------------------------------------
class TestRetainCompletedWebhook:
"""Tests for the retain.completed webhook event."""
def test_retain_event_data_model(self):
"""RetainEventData can be constructed with optional fields."""
data = RetainEventData(document_id="doc-123", tags=["tag1", "tag2"])
assert data.document_id == "doc-123"
assert data.tags == ["tag1", "tag2"]
empty = RetainEventData()
assert empty.document_id is None
assert empty.tags is None
def test_retain_event_type_value(self):
"""WebhookEventType.RETAIN_COMPLETED has the correct string value."""
assert WebhookEventType.RETAIN_COMPLETED == "retain.completed"
@pytest.mark.asyncio
async def test_fire_retain_webhook_queues_per_document(
self, memory: MemoryEngine, webhook_manager: WebhookManager
):
"""_fire_retain_webhook queues one delivery task per content item."""
bank_id = f"wh-retain-{uuid.uuid4().hex[:8]}"
webhook_id = uuid.uuid4()
async with memory._pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO webhooks (id, bank_id, url, secret, event_types, enabled, created_at, updated_at)
VALUES ($1, $2, $3, NULL, $4, true, NOW(), NOW())
""",
webhook_id,
bank_id,
"https://example.com/retain-hook",
["retain.completed"],
)
try:
contents = [
{"content": "Alice works at Google", "document_id": "doc-1"},
{"content": "Bob loves Python", "document_id": "doc-2"},
]
# Temporarily replace webhook manager on memory engine
original_manager = memory._webhook_manager
memory._webhook_manager = webhook_manager
try:
callback = memory._build_retain_outbox_callback(
bank_id=bank_id,
contents=contents,
operation_id="test-op-123",
)
assert callback is not None
async with memory._pool.acquire() as conn:
await callback(conn)
finally:
memory._webhook_manager = original_manager
async with memory._pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT task_payload
FROM async_operations
WHERE operation_type = 'webhook_delivery'
AND bank_id = $1
AND task_payload->>'event_type' = 'retain.completed'
ORDER BY created_at
""",
bank_id,
)
assert len(rows) == 2
payloads = []
for row in rows:
p = row["task_payload"]
if isinstance(p, str):
p = json.loads(p)
payloads.append(p)
doc_ids_in_payloads = [json.loads(p["payload"]).get("data", {}).get("document_id") for p in payloads]
assert "doc-1" in doc_ids_in_payloads
assert "doc-2" in doc_ids_in_payloads
finally:
async with memory._pool.acquire() as conn:
await conn.execute(
"DELETE FROM async_operations WHERE operation_type = 'webhook_delivery' AND bank_id = $1",
bank_id,
)
await conn.execute("DELETE FROM webhooks WHERE id = $1", webhook_id)
+15 -13
View File
@@ -294,17 +294,14 @@ class TestWorkerPoller:
payload,
)
from datetime import datetime, timezone
from hindsight_api.worker.exceptions import RetryTaskAt
async def failing_executor(task_dict):
raise RetryTaskAt(retry_at=datetime.now(timezone.utc), message="TimeoutError during recall")
raise ValueError("TimeoutError during recall")
poller = WorkerPoller(
pool=pool,
worker_id="test-worker-1",
executor=failing_executor,
max_retries=3,
)
task_dict = json.loads(payload)
@@ -329,35 +326,39 @@ class TestWorkerPoller:
assert row["retry_count"] == 1
@pytest.mark.asyncio
async def test_executor_exception_marks_failed_immediately(self, pool, clean_operations):
"""Test that a plain exception (not RetryTaskAt) permanently marks a task as 'failed'.
async def test_executor_exception_marks_failed_after_max_retries(self, pool, clean_operations):
"""Test that a task is permanently marked 'failed' once retry_count hits max_retries.
With the task-owned retry model, plain exceptions are non-retryable the poller
marks them as 'failed' immediately. Tasks that want to be retried must raise RetryTaskAt.
After max_retries exhaustion the task must NOT be reset to 'pending' it should
be marked 'failed' with an error message so it stops consuming retry budget.
"""
from hindsight_api.worker import WorkerPoller
from hindsight_api.worker.poller import ClaimedTask
max_retries = 3
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
op_id = uuid.uuid4()
payload = json.dumps({"type": "consolidation", "operation_id": str(op_id), "bank_id": bank_id})
# Insert with retry_count already at the limit
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id, claimed_at, retry_count)
VALUES ($1, $2, 'consolidation', 'processing', $3::jsonb, 'test-worker-1', now(), 0)
VALUES ($1, $2, 'consolidation', 'processing', $3::jsonb, 'test-worker-1', now(), $4)
""",
op_id,
bank_id,
payload,
max_retries,
)
async def failing_executor(task_dict):
raise ValueError("Non-retryable error")
raise ValueError("Still failing after all retries")
poller = WorkerPoller(
pool=pool,
worker_id="test-worker-1",
executor=failing_executor,
max_retries=max_retries,
)
task_dict = json.loads(payload)
@@ -372,10 +373,11 @@ class TestWorkerPoller:
op_id,
)
assert row["status"] == "failed", (
f"Expected 'failed' for plain exception, got '{row['status']}'"
f"Expected 'failed' after max retries, got '{row['status']}'"
)
assert row["error_message"] is not None
assert row["retry_count"] == 0 # not incremented; plain exception = immediate fail
assert "Max retries" in row["error_message"]
assert row["retry_count"] == max_retries # not incremented further
@pytest.mark.asyncio
async def test_executor_failed_status_not_overridden(self, pool, clean_operations):
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.4.17"
version = "0.4.15"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
+2 -2
View File
@@ -242,7 +242,7 @@ impl ApiClient {
pub fn poll_operation(&self, agent_id: &str, operation_id: &str, verbose: bool) -> Result<(bool, Option<String>)> {
self.runtime.block_on(async {
loop {
let response = self.client.list_operations(agent_id, None, None, None, None, None).await?;
let response = self.client.list_operations(agent_id, None, None, None, None).await?;
let ops = response.into_inner();
// Find our operation
@@ -329,7 +329,7 @@ impl ApiClient {
pub fn list_operations(&self, agent_id: &str, _verbose: bool) -> Result<OperationsResponse> {
self.runtime.block_on(async {
let response = self.client.list_operations(agent_id, None, None, None, None, None).await?;
let response = self.client.list_operations(agent_id, None, None, None, None).await?;
let value = response.into_inner();
// Convert to JSON Value first, then parse into our type
let json_value = serde_json::to_value(&value)?;
+7 -820
View File
@@ -7,7 +7,7 @@ info:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
title: Hindsight HTTP API
version: 0.4.17
version: 0.4.15
servers:
- url: /
paths:
@@ -210,9 +210,8 @@ paths:
- Memory
/v1/default/banks/{bank_id}/memories/{memory_id}:
get:
description: "Get a single memory unit by ID with all its metadata including\
\ entities and tags. Note: the 'history' field is deprecated and always returns\
\ an empty list - use GET /memories/{memory_id}/history instead."
description: Get a single memory unit by ID with all its metadata including
entities and tags.
operationId: get_memory
parameters:
- explode: false
@@ -254,51 +253,6 @@ paths:
summary: Get memory unit
tags:
- Memory
/v1/default/banks/{bank_id}/memories/{memory_id}/history:
get:
description: "Get the full history of an observation, with each change's source\
\ facts resolved to their text."
operationId: get_observation_history
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: memory_id
required: true
schema:
title: Memory Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema: {}
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Get observation history
tags:
- Memory
/v1/default/banks/{bank_id}/memories/recall:
post:
description: |-
@@ -884,51 +838,6 @@ paths:
summary: Update mental model
tags:
- Mental Models
/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history:
get:
description: "Get the refresh history of a mental model, showing content changes\
\ over time."
operationId: get_mental_model_history
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: mental_model_id
required: true
schema:
title: Mental Model Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema: {}
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Get mental model history
tags:
- Mental Models
/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/refresh:
post:
description: Submit an async task to re-run the source query through reflect
@@ -1435,61 +1344,6 @@ paths:
summary: Get document details
tags:
- Documents
patch:
description: |-
Update mutable fields on a document without re-processing its content.
**Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset.
At least one field must be provided.
operationId: update_document
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: document_id
required: true
schema:
title: Document Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateDocumentRequest'
required: true
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateDocumentResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Update document
tags:
- Documents
/v1/default/banks/{bank_id}/tags:
get:
description: "List all unique tags in a memory bank with usage counts. Supports\
@@ -1600,8 +1454,7 @@ paths:
/v1/default/banks/{bank_id}/operations:
get:
description: "Get a list of async operations for a specific agent, with optional\
\ filtering by status and operation type. Results are sorted by most recent\
\ first."
\ filtering by status. Results are sorted by most recent first."
operationId: list_operations
parameters:
- explode: false
@@ -1621,16 +1474,6 @@ paths:
nullable: true
type: string
style: form
- description: "Filter by operation type: retain, consolidation, refresh_mental_model,\
\ file_convert_retain, webhook_delivery"
explode: true
in: query
name: type
required: false
schema:
nullable: true
type: string
style: form
- description: Maximum number of operations to return
explode: true
in: query
@@ -1771,51 +1614,6 @@ paths:
summary: Get operation status
tags:
- Operations
/v1/default/banks/{bank_id}/operations/{operation_id}/retry:
post:
description: Re-queue a failed async operation so the worker picks it up again
operationId: retry_operation
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: operation_id
required: true
schema:
title: Operation Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/RetryOperationResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Retry a failed async operation
tags:
- Operations
/v1/default/banks/{bank_id}/profile:
get:
deprecated: true
@@ -2313,248 +2111,6 @@ paths:
summary: Trigger consolidation
tags:
- Banks
/v1/default/banks/{bank_id}/webhooks:
get:
description: List all webhooks registered for a bank.
operationId: list_webhooks
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookListResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: List webhooks
tags:
- Webhooks
post:
description: Register a webhook endpoint to receive event notifications for
this bank.
operationId: create_webhook
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CreateWebhookRequest'
required: true
responses:
"201":
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Register webhook
tags:
- Webhooks
/v1/default/banks/{bank_id}/webhooks/{webhook_id}:
delete:
description: Remove a registered webhook.
operationId: delete_webhook
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: webhook_id
required: true
schema:
title: Webhook Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/DeleteResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Delete webhook
tags:
- Webhooks
patch:
description: Update one or more fields of a registered webhook. Only provided
fields are changed.
operationId: update_webhook
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: webhook_id
required: true
schema:
title: Webhook Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateWebhookRequest'
required: true
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Update webhook
tags:
- Webhooks
/v1/default/banks/{bank_id}/webhooks/{webhook_id}/deliveries:
get:
description: Inspect delivery history for a webhook (useful for debugging).
operationId: list_webhook_deliveries
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: webhook_id
required: true
schema:
title: Webhook Id
type: string
style: simple
- description: Maximum number of deliveries to return
explode: true
in: query
name: limit
required: false
schema:
default: 50
description: Maximum number of deliveries to return
maximum: 200
title: Limit
type: integer
style: form
- description: Pagination cursor (created_at of last item)
explode: true
in: query
name: cursor
required: false
schema:
nullable: true
type: string
style: form
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookDeliveryListResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: List webhook deliveries
tags:
- Webhooks
/v1/default/banks/{bank_id}/memories:
delete:
description: "Delete memory units for a memory bank. Optionally filter by type\
@@ -2694,14 +2250,9 @@ paths:
**Request format:** multipart/form-data with:
- `files`: One or more files to upload
- `request`: JSON string with FileRetainRequest model
- `request`: JSON string with FileRetainRequest model (files_metadata)
**Parser selection:**
- Set `parser` in the request body to override the server default for all files.
- Set `parser` inside a `files_metadata` entry for per-file control.
- Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — each parser is tried in sequence until one succeeds.
- Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified.
- Only parsers enabled on the server may be requested; others return HTTP 400.
**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).
operationId: file_retain
parameters:
- explode: false
@@ -3328,47 +2879,6 @@ components:
required:
- operation_id
title: CreateMentalModelResponse
CreateWebhookRequest:
description: Request model for registering a webhook.
example:
event_types:
- event_types
- event_types
secret: secret
http_config:
headers:
key: headers
method: POST
timeout_seconds: 0
params:
key: params
url: url
enabled: true
properties:
url:
description: HTTP(S) endpoint URL to deliver events to
title: Url
type: string
secret:
nullable: true
type: string
event_types:
default:
- consolidation.completed
description: "List of event types to deliver. Currently supported: 'consolidation.completed'"
items:
type: string
type: array
enabled:
default: true
description: Whether this webhook is active
title: Enabled
type: boolean
http_config:
$ref: '#/components/schemas/WebhookHttpConfig'
required:
- url
title: CreateWebhookRequest
DeleteDocumentResponse:
description: Response model for delete document endpoint.
example:
@@ -4793,41 +4303,14 @@ components:
- items_count
- success
title: RetainResponse
RetryOperationResponse:
description: Response model for retry operation endpoint.
example:
message: Operation 550e8400-e29b-41d4-a716-446655440000 queued for retry
operation_id: 550e8400-e29b-41d4-a716-446655440000
success: true
properties:
success:
title: Success
type: boolean
message:
title: Message
type: string
operation_id:
title: Operation Id
type: string
required:
- message
- operation_id
- success
title: RetryOperationResponse
SourceFactsIncludeOptions:
description: Options for including source facts for observation-type results.
properties:
max_tokens:
default: 4096
description: Maximum total tokens for source facts across all observations
(-1 = unlimited)
description: Maximum tokens for source facts
title: Max Tokens
type: integer
max_tokens_per_observation:
default: -1
description: Maximum tokens of source facts per observation (-1 = unlimited)
title: Max Tokens Per Observation
type: integer
title: SourceFactsIncludeOptions
TagItem:
description: Single tag with usage count.
@@ -4923,29 +4406,6 @@ components:
required:
- disposition
title: UpdateDispositionRequest
UpdateDocumentRequest:
description: Request model for updating a document's mutable fields.
example:
tags:
- team-a
- team-b
properties:
tags:
items:
type: string
nullable: true
type: array
title: UpdateDocumentRequest
UpdateDocumentResponse:
description: Response model for update document endpoint.
example:
success: true
properties:
success:
default: true
title: Success
type: boolean
title: UpdateDocumentResponse
UpdateMentalModelRequest:
description: Request model for updating a mental model.
example:
@@ -4977,41 +4437,6 @@ components:
trigger:
$ref: '#/components/schemas/MentalModelTrigger'
title: UpdateMentalModelRequest
UpdateWebhookRequest:
description: Request model for updating a webhook. Only provided fields are
updated.
example:
event_types:
- event_types
- event_types
secret: secret
http_config:
headers:
key: headers
method: POST
timeout_seconds: 0
params:
key: params
url: url
enabled: true
properties:
url:
nullable: true
type: string
secret:
nullable: true
type: string
event_types:
items:
type: string
nullable: true
type: array
enabled:
nullable: true
type: boolean
http_config:
$ref: '#/components/schemas/WebhookHttpConfig'
title: UpdateWebhookRequest
ValidationError:
example:
msg: msg
@@ -5056,244 +4481,6 @@ components:
- api_version
- features
title: VersionResponse
WebhookDeliveryListResponse:
description: Response model for listing webhook deliveries.
example:
next_cursor: next_cursor
items:
- last_response_body: last_response_body
last_attempt_at: last_attempt_at
created_at: created_at
last_response_status: 6
url: url
event_type: event_type
updated_at: updated_at
webhook_id: webhook_id
next_retry_at: next_retry_at
id: id
last_error: last_error
status: status
attempts: 0
- last_response_body: last_response_body
last_attempt_at: last_attempt_at
created_at: created_at
last_response_status: 6
url: url
event_type: event_type
updated_at: updated_at
webhook_id: webhook_id
next_retry_at: next_retry_at
id: id
last_error: last_error
status: status
attempts: 0
properties:
items:
items:
$ref: '#/components/schemas/WebhookDeliveryResponse'
type: array
next_cursor:
nullable: true
type: string
required:
- items
title: WebhookDeliveryListResponse
WebhookDeliveryResponse:
description: Response model for a webhook delivery record.
example:
last_response_body: last_response_body
last_attempt_at: last_attempt_at
created_at: created_at
last_response_status: 6
url: url
event_type: event_type
updated_at: updated_at
webhook_id: webhook_id
next_retry_at: next_retry_at
id: id
last_error: last_error
status: status
attempts: 0
properties:
id:
title: Id
type: string
webhook_id:
nullable: true
type: string
url:
title: Url
type: string
event_type:
title: Event Type
type: string
status:
title: Status
type: string
attempts:
title: Attempts
type: integer
next_retry_at:
nullable: true
type: string
last_error:
nullable: true
type: string
last_response_status:
nullable: true
type: integer
last_response_body:
nullable: true
type: string
last_attempt_at:
nullable: true
type: string
created_at:
nullable: true
type: string
updated_at:
nullable: true
type: string
required:
- attempts
- event_type
- id
- status
- url
- webhook_id
title: WebhookDeliveryResponse
WebhookHttpConfig:
description: HTTP delivery configuration for a webhook.
example:
headers:
key: headers
method: POST
timeout_seconds: 0
params:
key: params
properties:
method:
default: POST
description: "HTTP method: GET or POST"
title: Method
type: string
timeout_seconds:
default: 30
description: HTTP request timeout in seconds
title: Timeout Seconds
type: integer
headers:
additionalProperties:
type: string
description: Custom HTTP headers
title: Headers
params:
additionalProperties:
type: string
description: Custom HTTP query parameters
title: Params
title: WebhookHttpConfig
WebhookListResponse:
description: Response model for listing webhooks.
example:
items:
- event_types:
- event_types
- event_types
updated_at: updated_at
bank_id: bank_id
created_at: created_at
id: id
secret: secret
http_config:
headers:
key: headers
method: POST
timeout_seconds: 0
params:
key: params
url: url
enabled: true
- event_types:
- event_types
- event_types
updated_at: updated_at
bank_id: bank_id
created_at: created_at
id: id
secret: secret
http_config:
headers:
key: headers
method: POST
timeout_seconds: 0
params:
key: params
url: url
enabled: true
properties:
items:
items:
$ref: '#/components/schemas/WebhookResponse'
type: array
required:
- items
title: WebhookListResponse
WebhookResponse:
description: Response model for a webhook.
example:
event_types:
- event_types
- event_types
updated_at: updated_at
bank_id: bank_id
created_at: created_at
id: id
secret: secret
http_config:
headers:
key: headers
method: POST
timeout_seconds: 0
params:
key: params
url: url
enabled: true
properties:
id:
title: Id
type: string
bank_id:
nullable: true
type: string
url:
title: Url
type: string
secret:
nullable: true
type: string
event_types:
items:
type: string
type: array
enabled:
title: Enabled
type: boolean
http_config:
$ref: '#/components/schemas/WebhookHttpConfig'
created_at:
nullable: true
type: string
updated_at:
nullable: true
type: string
required:
- bank_id
- enabled
- event_types
- id
- url
title: WebhookResponse
Timestamp:
anyOf:
- format: date-time
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -142
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -591,144 +591,3 @@ func (a *DocumentsAPIService) ListDocumentsExecute(r ApiListDocumentsRequest) (*
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiUpdateDocumentRequest struct {
ctx context.Context
ApiService *DocumentsAPIService
bankId string
documentId string
updateDocumentRequest *UpdateDocumentRequest
authorization *string
}
func (r ApiUpdateDocumentRequest) UpdateDocumentRequest(updateDocumentRequest UpdateDocumentRequest) ApiUpdateDocumentRequest {
r.updateDocumentRequest = &updateDocumentRequest
return r
}
func (r ApiUpdateDocumentRequest) Authorization(authorization string) ApiUpdateDocumentRequest {
r.authorization = &authorization
return r
}
func (r ApiUpdateDocumentRequest) Execute() (*UpdateDocumentResponse, *http.Response, error) {
return r.ApiService.UpdateDocumentExecute(r)
}
/*
UpdateDocument Update document
Update mutable fields on a document without re-processing its content.
**Tags** (`tags`): Propagated to all associated memory units. Observations derived from those units are invalidated and queued for re-consolidation under the new tags. Co-source memories from other documents that shared those observations are also reset.
At least one field must be provided.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@param documentId
@return ApiUpdateDocumentRequest
*/
func (a *DocumentsAPIService) UpdateDocument(ctx context.Context, bankId string, documentId string) ApiUpdateDocumentRequest {
return ApiUpdateDocumentRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
documentId: documentId,
}
}
// Execute executes the request
// @return UpdateDocumentResponse
func (a *DocumentsAPIService) UpdateDocumentExecute(r ApiUpdateDocumentRequest) (*UpdateDocumentResponse, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodPatch
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *UpdateDocumentResponse
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "DocumentsAPIService.UpdateDocument")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/documents/{document_id}"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarPath = strings.Replace(localVarPath, "{"+"document_id"+"}", url.PathEscape(parameterValueToString(r.documentId, "documentId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.updateDocumentRequest == nil {
return localVarReturnValue, nil, reportError("updateDocumentRequest is required and must be specified")
}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{"application/json"}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.authorization != nil {
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
}
// body params
localVarPostBody = r.updateDocumentRequest
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 422 {
var v HTTPValidationError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+3 -8
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -78,14 +78,9 @@ Use the operations endpoint to monitor progress.
**Request format:** multipart/form-data with:
- `files`: One or more files to upload
- `request`: JSON string with FileRetainRequest model
- `request`: JSON string with FileRetainRequest model (files_metadata)
**Parser selection:**
- Set `parser` in the request body to override the server default for all files.
- Set `parser` inside a `files_metadata` entry for per-file control.
- Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain each parser is tried in sequence until one succeeds.
- Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified.
- Only parsers enabled on the server may be requested; others return HTTP 400.
**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
+2 -128
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -483,7 +483,7 @@ func (r ApiGetMemoryRequest) Execute() (interface{}, *http.Response, error) {
/*
GetMemory Get memory unit
Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead.
Get a single memory unit by ID with all its metadata including entities and tags.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@@ -589,132 +589,6 @@ func (a *MemoryAPIService) GetMemoryExecute(r ApiGetMemoryRequest) (interface{},
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiGetObservationHistoryRequest struct {
ctx context.Context
ApiService *MemoryAPIService
bankId string
memoryId string
authorization *string
}
func (r ApiGetObservationHistoryRequest) Authorization(authorization string) ApiGetObservationHistoryRequest {
r.authorization = &authorization
return r
}
func (r ApiGetObservationHistoryRequest) Execute() (interface{}, *http.Response, error) {
return r.ApiService.GetObservationHistoryExecute(r)
}
/*
GetObservationHistory Get observation history
Get the full history of an observation, with each change's source facts resolved to their text.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@param memoryId
@return ApiGetObservationHistoryRequest
*/
func (a *MemoryAPIService) GetObservationHistory(ctx context.Context, bankId string, memoryId string) ApiGetObservationHistoryRequest {
return ApiGetObservationHistoryRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
memoryId: memoryId,
}
}
// Execute executes the request
// @return interface{}
func (a *MemoryAPIService) GetObservationHistoryExecute(r ApiGetObservationHistoryRequest) (interface{}, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue interface{}
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MemoryAPIService.GetObservationHistory")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/memories/{memory_id}/history"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarPath = strings.Replace(localVarPath, "{"+"memory_id"+"}", url.PathEscape(parameterValueToString(r.memoryId, "memoryId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.authorization != nil {
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 422 {
var v HTTPValidationError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiListMemoriesRequest struct {
ctx context.Context
ApiService *MemoryAPIService
+1 -127
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -409,132 +409,6 @@ func (a *MentalModelsAPIService) GetMentalModelExecute(r ApiGetMentalModelReques
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiGetMentalModelHistoryRequest struct {
ctx context.Context
ApiService *MentalModelsAPIService
bankId string
mentalModelId string
authorization *string
}
func (r ApiGetMentalModelHistoryRequest) Authorization(authorization string) ApiGetMentalModelHistoryRequest {
r.authorization = &authorization
return r
}
func (r ApiGetMentalModelHistoryRequest) Execute() (interface{}, *http.Response, error) {
return r.ApiService.GetMentalModelHistoryExecute(r)
}
/*
GetMentalModelHistory Get mental model history
Get the refresh history of a mental model, showing content changes over time.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@param mentalModelId
@return ApiGetMentalModelHistoryRequest
*/
func (a *MentalModelsAPIService) GetMentalModelHistory(ctx context.Context, bankId string, mentalModelId string) ApiGetMentalModelHistoryRequest {
return ApiGetMentalModelHistoryRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
mentalModelId: mentalModelId,
}
}
// Execute executes the request
// @return interface{}
func (a *MentalModelsAPIService) GetMentalModelHistoryExecute(r ApiGetMentalModelHistoryRequest) (interface{}, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue interface{}
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MentalModelsAPIService.GetMentalModelHistory")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/mental-models/{mental_model_id}/history"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarPath = strings.Replace(localVarPath, "{"+"mental_model_id"+"}", url.PathEscape(parameterValueToString(r.mentalModelId, "mentalModelId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.authorization != nil {
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 422 {
var v HTTPValidationError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiListMentalModelsRequest struct {
ctx context.Context
ApiService *MentalModelsAPIService
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+2 -138
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -280,7 +280,6 @@ type ApiListOperationsRequest struct {
ApiService *OperationsAPIService
bankId string
status *string
type_ *string
limit *int32
offset *int32
authorization *string
@@ -292,12 +291,6 @@ func (r ApiListOperationsRequest) Status(status string) ApiListOperationsRequest
return r
}
// Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery
func (r ApiListOperationsRequest) Type_(type_ string) ApiListOperationsRequest {
r.type_ = &type_
return r
}
// Maximum number of operations to return
func (r ApiListOperationsRequest) Limit(limit int32) ApiListOperationsRequest {
r.limit = &limit
@@ -322,7 +315,7 @@ func (r ApiListOperationsRequest) Execute() (*OperationsListResponse, *http.Resp
/*
ListOperations List async operations
Get a list of async operations for a specific agent, with optional filtering by status and operation type. Results are sorted by most recent first.
Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@@ -361,9 +354,6 @@ func (a *OperationsAPIService) ListOperationsExecute(r ApiListOperationsRequest)
if r.status != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "status", r.status, "form", "")
}
if r.type_ != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "type", r.type_, "form", "")
}
if r.limit != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "limit", r.limit, "form", "")
} else {
@@ -442,129 +432,3 @@ func (a *OperationsAPIService) ListOperationsExecute(r ApiListOperationsRequest)
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiRetryOperationRequest struct {
ctx context.Context
ApiService *OperationsAPIService
bankId string
operationId string
authorization *string
}
func (r ApiRetryOperationRequest) Authorization(authorization string) ApiRetryOperationRequest {
r.authorization = &authorization
return r
}
func (r ApiRetryOperationRequest) Execute() (*RetryOperationResponse, *http.Response, error) {
return r.ApiService.RetryOperationExecute(r)
}
/*
RetryOperation Retry a failed async operation
Re-queue a failed async operation so the worker picks it up again
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@param operationId
@return ApiRetryOperationRequest
*/
func (a *OperationsAPIService) RetryOperation(ctx context.Context, bankId string, operationId string) ApiRetryOperationRequest {
return ApiRetryOperationRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
operationId: operationId,
}
}
// Execute executes the request
// @return RetryOperationResponse
func (a *OperationsAPIService) RetryOperationExecute(r ApiRetryOperationRequest) (*RetryOperationResponse, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodPost
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *RetryOperationResponse
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "OperationsAPIService.RetryOperation")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/operations/{operation_id}/retry"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarPath = strings.Replace(localVarPath, "{"+"operation_id"+"}", url.PathEscape(parameterValueToString(r.operationId, "operationId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.authorization != nil {
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 422 {
var v HTTPValidationError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
-691
View File
@@ -1,691 +0,0 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"bytes"
"context"
"io"
"net/http"
"net/url"
"strings"
)
// WebhooksAPIService WebhooksAPI service
type WebhooksAPIService service
type ApiCreateWebhookRequest struct {
ctx context.Context
ApiService *WebhooksAPIService
bankId string
createWebhookRequest *CreateWebhookRequest
authorization *string
}
func (r ApiCreateWebhookRequest) CreateWebhookRequest(createWebhookRequest CreateWebhookRequest) ApiCreateWebhookRequest {
r.createWebhookRequest = &createWebhookRequest
return r
}
func (r ApiCreateWebhookRequest) Authorization(authorization string) ApiCreateWebhookRequest {
r.authorization = &authorization
return r
}
func (r ApiCreateWebhookRequest) Execute() (*WebhookResponse, *http.Response, error) {
return r.ApiService.CreateWebhookExecute(r)
}
/*
CreateWebhook Register webhook
Register a webhook endpoint to receive event notifications for this bank.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@return ApiCreateWebhookRequest
*/
func (a *WebhooksAPIService) CreateWebhook(ctx context.Context, bankId string) ApiCreateWebhookRequest {
return ApiCreateWebhookRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
}
}
// Execute executes the request
// @return WebhookResponse
func (a *WebhooksAPIService) CreateWebhookExecute(r ApiCreateWebhookRequest) (*WebhookResponse, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodPost
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *WebhookResponse
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "WebhooksAPIService.CreateWebhook")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/webhooks"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.createWebhookRequest == nil {
return localVarReturnValue, nil, reportError("createWebhookRequest is required and must be specified")
}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{"application/json"}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.authorization != nil {
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
}
// body params
localVarPostBody = r.createWebhookRequest
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 422 {
var v HTTPValidationError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiDeleteWebhookRequest struct {
ctx context.Context
ApiService *WebhooksAPIService
bankId string
webhookId string
authorization *string
}
func (r ApiDeleteWebhookRequest) Authorization(authorization string) ApiDeleteWebhookRequest {
r.authorization = &authorization
return r
}
func (r ApiDeleteWebhookRequest) Execute() (*DeleteResponse, *http.Response, error) {
return r.ApiService.DeleteWebhookExecute(r)
}
/*
DeleteWebhook Delete webhook
Remove a registered webhook.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@param webhookId
@return ApiDeleteWebhookRequest
*/
func (a *WebhooksAPIService) DeleteWebhook(ctx context.Context, bankId string, webhookId string) ApiDeleteWebhookRequest {
return ApiDeleteWebhookRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
webhookId: webhookId,
}
}
// Execute executes the request
// @return DeleteResponse
func (a *WebhooksAPIService) DeleteWebhookExecute(r ApiDeleteWebhookRequest) (*DeleteResponse, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodDelete
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *DeleteResponse
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "WebhooksAPIService.DeleteWebhook")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/webhooks/{webhook_id}"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarPath = strings.Replace(localVarPath, "{"+"webhook_id"+"}", url.PathEscape(parameterValueToString(r.webhookId, "webhookId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.authorization != nil {
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 422 {
var v HTTPValidationError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiListWebhookDeliveriesRequest struct {
ctx context.Context
ApiService *WebhooksAPIService
bankId string
webhookId string
limit *int32
cursor *string
authorization *string
}
// Maximum number of deliveries to return
func (r ApiListWebhookDeliveriesRequest) Limit(limit int32) ApiListWebhookDeliveriesRequest {
r.limit = &limit
return r
}
// Pagination cursor (created_at of last item)
func (r ApiListWebhookDeliveriesRequest) Cursor(cursor string) ApiListWebhookDeliveriesRequest {
r.cursor = &cursor
return r
}
func (r ApiListWebhookDeliveriesRequest) Authorization(authorization string) ApiListWebhookDeliveriesRequest {
r.authorization = &authorization
return r
}
func (r ApiListWebhookDeliveriesRequest) Execute() (*WebhookDeliveryListResponse, *http.Response, error) {
return r.ApiService.ListWebhookDeliveriesExecute(r)
}
/*
ListWebhookDeliveries List webhook deliveries
Inspect delivery history for a webhook (useful for debugging).
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@param webhookId
@return ApiListWebhookDeliveriesRequest
*/
func (a *WebhooksAPIService) ListWebhookDeliveries(ctx context.Context, bankId string, webhookId string) ApiListWebhookDeliveriesRequest {
return ApiListWebhookDeliveriesRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
webhookId: webhookId,
}
}
// Execute executes the request
// @return WebhookDeliveryListResponse
func (a *WebhooksAPIService) ListWebhookDeliveriesExecute(r ApiListWebhookDeliveriesRequest) (*WebhookDeliveryListResponse, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *WebhookDeliveryListResponse
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "WebhooksAPIService.ListWebhookDeliveries")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/webhooks/{webhook_id}/deliveries"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarPath = strings.Replace(localVarPath, "{"+"webhook_id"+"}", url.PathEscape(parameterValueToString(r.webhookId, "webhookId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.limit != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "limit", r.limit, "form", "")
} else {
var defaultValue int32 = 50
r.limit = &defaultValue
}
if r.cursor != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "cursor", r.cursor, "form", "")
}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.authorization != nil {
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 422 {
var v HTTPValidationError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiListWebhooksRequest struct {
ctx context.Context
ApiService *WebhooksAPIService
bankId string
authorization *string
}
func (r ApiListWebhooksRequest) Authorization(authorization string) ApiListWebhooksRequest {
r.authorization = &authorization
return r
}
func (r ApiListWebhooksRequest) Execute() (*WebhookListResponse, *http.Response, error) {
return r.ApiService.ListWebhooksExecute(r)
}
/*
ListWebhooks List webhooks
List all webhooks registered for a bank.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@return ApiListWebhooksRequest
*/
func (a *WebhooksAPIService) ListWebhooks(ctx context.Context, bankId string) ApiListWebhooksRequest {
return ApiListWebhooksRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
}
}
// Execute executes the request
// @return WebhookListResponse
func (a *WebhooksAPIService) ListWebhooksExecute(r ApiListWebhooksRequest) (*WebhookListResponse, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodGet
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *WebhookListResponse
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "WebhooksAPIService.ListWebhooks")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/webhooks"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.authorization != nil {
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 422 {
var v HTTPValidationError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiUpdateWebhookRequest struct {
ctx context.Context
ApiService *WebhooksAPIService
bankId string
webhookId string
updateWebhookRequest *UpdateWebhookRequest
authorization *string
}
func (r ApiUpdateWebhookRequest) UpdateWebhookRequest(updateWebhookRequest UpdateWebhookRequest) ApiUpdateWebhookRequest {
r.updateWebhookRequest = &updateWebhookRequest
return r
}
func (r ApiUpdateWebhookRequest) Authorization(authorization string) ApiUpdateWebhookRequest {
r.authorization = &authorization
return r
}
func (r ApiUpdateWebhookRequest) Execute() (*WebhookResponse, *http.Response, error) {
return r.ApiService.UpdateWebhookExecute(r)
}
/*
UpdateWebhook Update webhook
Update one or more fields of a registered webhook. Only provided fields are changed.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@param webhookId
@return ApiUpdateWebhookRequest
*/
func (a *WebhooksAPIService) UpdateWebhook(ctx context.Context, bankId string, webhookId string) ApiUpdateWebhookRequest {
return ApiUpdateWebhookRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
webhookId: webhookId,
}
}
// Execute executes the request
// @return WebhookResponse
func (a *WebhooksAPIService) UpdateWebhookExecute(r ApiUpdateWebhookRequest) (*WebhookResponse, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodPatch
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *WebhookResponse
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "WebhooksAPIService.UpdateWebhook")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/webhooks/{webhook_id}"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarPath = strings.Replace(localVarPath, "{"+"webhook_id"+"}", url.PathEscape(parameterValueToString(r.webhookId, "webhookId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
if r.updateWebhookRequest == nil {
return localVarReturnValue, nil, reportError("updateWebhookRequest is required and must be specified")
}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{"application/json"}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.authorization != nil {
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
}
// body params
localVarPostBody = r.updateWebhookRequest
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 422 {
var v HTTPValidationError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}
+2 -5
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -41,7 +41,7 @@ var (
queryDescape = strings.NewReplacer( "%5B", "[", "%5D", "]" )
)
// APIClient manages communication with the Hindsight HTTP API API v0.4.17
// APIClient manages communication with the Hindsight HTTP API API v0.4.15
// In most cases there should be only one, shared, APIClient.
type APIClient struct {
cfg *Configuration
@@ -66,8 +66,6 @@ type APIClient struct {
MonitoringAPI *MonitoringAPIService
OperationsAPI *OperationsAPIService
WebhooksAPI *WebhooksAPIService
}
type service struct {
@@ -95,7 +93,6 @@ func NewAPIClient(cfg *Configuration) *APIClient {
c.MentalModelsAPI = (*MentalModelsAPIService)(&c.common)
c.MonitoringAPI = (*MonitoringAPIService)(&c.common)
c.OperationsAPI = (*OperationsAPIService)(&c.common)
c.WebhooksAPI = (*WebhooksAPIService)(&c.common)
return c
}
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -1,320 +0,0 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the CreateWebhookRequest type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &CreateWebhookRequest{}
// CreateWebhookRequest Request model for registering a webhook.
type CreateWebhookRequest struct {
// HTTP(S) endpoint URL to deliver events to
Url string `json:"url"`
Secret NullableString `json:"secret,omitempty"`
// List of event types to deliver. Currently supported: 'consolidation.completed'
EventTypes []string `json:"event_types,omitempty"`
// Whether this webhook is active
Enabled *bool `json:"enabled,omitempty"`
// HTTP delivery configuration (method, timeout, headers, params)
HttpConfig *WebhookHttpConfig `json:"http_config,omitempty"`
}
type _CreateWebhookRequest CreateWebhookRequest
// NewCreateWebhookRequest instantiates a new CreateWebhookRequest object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewCreateWebhookRequest(url string) *CreateWebhookRequest {
this := CreateWebhookRequest{}
this.Url = url
var enabled bool = true
this.Enabled = &enabled
return &this
}
// NewCreateWebhookRequestWithDefaults instantiates a new CreateWebhookRequest object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewCreateWebhookRequestWithDefaults() *CreateWebhookRequest {
this := CreateWebhookRequest{}
var enabled bool = true
this.Enabled = &enabled
return &this
}
// GetUrl returns the Url field value
func (o *CreateWebhookRequest) GetUrl() string {
if o == nil {
var ret string
return ret
}
return o.Url
}
// GetUrlOk returns a tuple with the Url field value
// and a boolean to check if the value has been set.
func (o *CreateWebhookRequest) GetUrlOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Url, true
}
// SetUrl sets field value
func (o *CreateWebhookRequest) SetUrl(v string) {
o.Url = v
}
// GetSecret returns the Secret field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *CreateWebhookRequest) GetSecret() string {
if o == nil || IsNil(o.Secret.Get()) {
var ret string
return ret
}
return *o.Secret.Get()
}
// GetSecretOk returns a tuple with the Secret field value if set, nil otherwise
// and a boolean to check if the value has been set.
// NOTE: If the value is an explicit nil, `nil, true` will be returned
func (o *CreateWebhookRequest) GetSecretOk() (*string, bool) {
if o == nil {
return nil, false
}
return o.Secret.Get(), o.Secret.IsSet()
}
// HasSecret returns a boolean if a field has been set.
func (o *CreateWebhookRequest) HasSecret() bool {
if o != nil && o.Secret.IsSet() {
return true
}
return false
}
// SetSecret gets a reference to the given NullableString and assigns it to the Secret field.
func (o *CreateWebhookRequest) SetSecret(v string) {
o.Secret.Set(&v)
}
// SetSecretNil sets the value for Secret to be an explicit nil
func (o *CreateWebhookRequest) SetSecretNil() {
o.Secret.Set(nil)
}
// UnsetSecret ensures that no value is present for Secret, not even an explicit nil
func (o *CreateWebhookRequest) UnsetSecret() {
o.Secret.Unset()
}
// GetEventTypes returns the EventTypes field value if set, zero value otherwise.
func (o *CreateWebhookRequest) GetEventTypes() []string {
if o == nil || IsNil(o.EventTypes) {
var ret []string
return ret
}
return o.EventTypes
}
// GetEventTypesOk returns a tuple with the EventTypes field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *CreateWebhookRequest) GetEventTypesOk() ([]string, bool) {
if o == nil || IsNil(o.EventTypes) {
return nil, false
}
return o.EventTypes, true
}
// HasEventTypes returns a boolean if a field has been set.
func (o *CreateWebhookRequest) HasEventTypes() bool {
if o != nil && !IsNil(o.EventTypes) {
return true
}
return false
}
// SetEventTypes gets a reference to the given []string and assigns it to the EventTypes field.
func (o *CreateWebhookRequest) SetEventTypes(v []string) {
o.EventTypes = v
}
// GetEnabled returns the Enabled field value if set, zero value otherwise.
func (o *CreateWebhookRequest) GetEnabled() bool {
if o == nil || IsNil(o.Enabled) {
var ret bool
return ret
}
return *o.Enabled
}
// GetEnabledOk returns a tuple with the Enabled field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *CreateWebhookRequest) GetEnabledOk() (*bool, bool) {
if o == nil || IsNil(o.Enabled) {
return nil, false
}
return o.Enabled, true
}
// HasEnabled returns a boolean if a field has been set.
func (o *CreateWebhookRequest) HasEnabled() bool {
if o != nil && !IsNil(o.Enabled) {
return true
}
return false
}
// SetEnabled gets a reference to the given bool and assigns it to the Enabled field.
func (o *CreateWebhookRequest) SetEnabled(v bool) {
o.Enabled = &v
}
// GetHttpConfig returns the HttpConfig field value if set, zero value otherwise.
func (o *CreateWebhookRequest) GetHttpConfig() WebhookHttpConfig {
if o == nil || IsNil(o.HttpConfig) {
var ret WebhookHttpConfig
return ret
}
return *o.HttpConfig
}
// GetHttpConfigOk returns a tuple with the HttpConfig field value if set, nil otherwise
// and a boolean to check if the value has been set.
func (o *CreateWebhookRequest) GetHttpConfigOk() (*WebhookHttpConfig, bool) {
if o == nil || IsNil(o.HttpConfig) {
return nil, false
}
return o.HttpConfig, true
}
// HasHttpConfig returns a boolean if a field has been set.
func (o *CreateWebhookRequest) HasHttpConfig() bool {
if o != nil && !IsNil(o.HttpConfig) {
return true
}
return false
}
// SetHttpConfig gets a reference to the given WebhookHttpConfig and assigns it to the HttpConfig field.
func (o *CreateWebhookRequest) SetHttpConfig(v WebhookHttpConfig) {
o.HttpConfig = &v
}
func (o CreateWebhookRequest) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o CreateWebhookRequest) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["url"] = o.Url
if o.Secret.IsSet() {
toSerialize["secret"] = o.Secret.Get()
}
if !IsNil(o.EventTypes) {
toSerialize["event_types"] = o.EventTypes
}
if !IsNil(o.Enabled) {
toSerialize["enabled"] = o.Enabled
}
if !IsNil(o.HttpConfig) {
toSerialize["http_config"] = o.HttpConfig
}
return toSerialize, nil
}
func (o *CreateWebhookRequest) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"url",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varCreateWebhookRequest := _CreateWebhookRequest{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varCreateWebhookRequest)
if err != nil {
return err
}
*o = CreateWebhookRequest(varCreateWebhookRequest)
return err
}
type NullableCreateWebhookRequest struct {
value *CreateWebhookRequest
isSet bool
}
func (v NullableCreateWebhookRequest) Get() *CreateWebhookRequest {
return v.value
}
func (v *NullableCreateWebhookRequest) Set(val *CreateWebhookRequest) {
v.value = val
v.isSet = true
}
func (v NullableCreateWebhookRequest) IsSet() bool {
return v.isSet
}
func (v *NullableCreateWebhookRequest) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableCreateWebhookRequest(val *CreateWebhookRequest) *NullableCreateWebhookRequest {
return &NullableCreateWebhookRequest{value: val, isSet: true}
}
func (v NullableCreateWebhookRequest) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableCreateWebhookRequest) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.17
API version: 0.4.15
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.

Some files were not shown because too many files have changed in this diff Show More