Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ceda76401 | ||
|
|
fa3501d448 | ||
|
|
f88b50a45e | ||
|
|
1b4ad7f435 | ||
|
|
d2504ac5ed | ||
|
|
d9d7021a49 | ||
|
|
e2baca8bfe | ||
|
|
576473b6aa | ||
|
|
99220d0527 | ||
|
|
8540c33236 | ||
|
|
5d05962db0 | ||
|
|
1d17dea2f1 | ||
|
|
928dc696e8 | ||
|
|
e4dd654ec5 | ||
|
|
0ad8c2d09c | ||
|
|
1e40cd22a6 | ||
|
|
1e5aa7de4d | ||
|
|
58fdac44f7 | ||
|
|
891c33b1d7 | ||
|
|
7ed57fdd85 | ||
|
|
e0a2ac63e7 | ||
|
|
75b95106ba | ||
|
|
d425e93cb4 | ||
|
|
cb6d1c469c | ||
|
|
4c058b4b98 | ||
|
|
aa8e5475c4 | ||
|
|
ad2cf72aab | ||
|
|
3f2a6ec9ce | ||
|
|
f17406fdf0 | ||
|
|
66423b85f5 | ||
|
|
abbf874d84 | ||
|
|
51d2fc5309 | ||
|
|
ea27ef95ec | ||
|
|
edf60e0f3c | ||
|
|
719e79a4d9 | ||
|
|
3857a30491 | ||
|
|
3d87ef5cee | ||
|
|
5c3d3274d7 |
@@ -387,6 +387,10 @@ 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
|
||||
|
||||
+54
-10
@@ -686,6 +686,30 @@ 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:
|
||||
@@ -1297,7 +1321,11 @@ jobs:
|
||||
|
||||
test-doc-examples:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-rust-cli
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [python, node, cli, go]
|
||||
name: test-doc-examples (${{ matrix.language }})
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
|
||||
@@ -1315,14 +1343,26 @@ jobs:
|
||||
PROJECT_ID=$(jq -r '.project_id' /tmp/gcp-credentials.json)
|
||||
echo "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$PROJECT_ID" >> $GITHUB_ENV
|
||||
|
||||
- name: Download CLI artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: hindsight-cli
|
||||
path: /usr/local/bin
|
||||
- name: Install Rust
|
||||
if: matrix.language == 'cli'
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Make CLI executable
|
||||
run: chmod +x /usr/local/bin/hindsight
|
||||
- name: Cache cargo
|
||||
if: matrix.language == 'cli'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
hindsight-cli/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Build CLI
|
||||
if: matrix.language == 'cli'
|
||||
working-directory: hindsight-cli
|
||||
run: |
|
||||
cargo build --release
|
||||
cp target/release/hindsight /usr/local/bin/hindsight
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
@@ -1336,6 +1376,7 @@ jobs:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Set up Node.js
|
||||
if: matrix.language == 'node'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
@@ -1349,10 +1390,12 @@ 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
|
||||
@@ -1404,10 +1447,11 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Configure CLI
|
||||
if: matrix.language == 'cli'
|
||||
run: hindsight configure --api-url http://localhost:8888
|
||||
|
||||
- name: Run all doc examples
|
||||
run: ./scripts/test-doc-examples.sh
|
||||
- name: Run doc examples (${{ matrix.language }})
|
||||
run: ./scripts/test-doc-examples.sh --lang ${{ matrix.language }}
|
||||
|
||||
- name: Show API server logs
|
||||
if: always()
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: hindsight
|
||||
description: Hindsight helm chart
|
||||
type: application
|
||||
version: 0.4.15
|
||||
appVersion: "0.4.15"
|
||||
version: 0.4.16
|
||||
appVersion: "0.4.16"
|
||||
keywords:
|
||||
- ai
|
||||
- memory
|
||||
|
||||
@@ -46,4 +46,4 @@ __all__ = [
|
||||
"RemoteTEICrossEncoder",
|
||||
"LLMConfig",
|
||||
]
|
||||
__version__ = "0.4.15"
|
||||
__version__ = "0.4.16"
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
"""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")
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""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")
|
||||
@@ -71,9 +71,7 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
|
||||
|
||||
|
||||
from hindsight_api.config import get_config
|
||||
from hindsight_api.engine.db_utils import acquire_with_retry
|
||||
from hindsight_api.engine.memory_engine import Budget, _get_tiktoken_encoding, fq_table
|
||||
from hindsight_api.engine.reflect.observations import Observation
|
||||
from hindsight_api.engine.memory_engine import Budget, _current_schema, _get_tiktoken_encoding, fq_table
|
||||
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
|
||||
@@ -100,7 +98,12 @@ class ChunkIncludeOptions(BaseModel):
|
||||
class SourceFactsIncludeOptions(BaseModel):
|
||||
"""Options for including source facts for observation-type results."""
|
||||
|
||||
max_tokens: int = Field(default=4096, description="Maximum tokens for source facts")
|
||||
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)"
|
||||
)
|
||||
|
||||
|
||||
class IncludeOptions(BaseModel):
|
||||
@@ -474,6 +477,11 @@ 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):
|
||||
@@ -482,14 +490,21 @@ class FileRetainRequest(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"parser": "iris",
|
||||
"files_metadata": [
|
||||
{"document_id": "report_2024", "tags": ["quarterly"]},
|
||||
{"context": "meeting notes"},
|
||||
{"context": "meeting notes", "parser": ["iris", "markitdown"]},
|
||||
],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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)",
|
||||
@@ -759,14 +774,6 @@ 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."""
|
||||
|
||||
@@ -1199,6 +1206,30 @@ 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."""
|
||||
|
||||
@@ -1305,15 +1336,6 @@ 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
|
||||
# =========================================================================
|
||||
@@ -1627,6 +1649,123 @@ 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,
|
||||
@@ -1726,7 +1865,6 @@ 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,
|
||||
@@ -2023,7 +2161,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.",
|
||||
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.",
|
||||
operation_id="get_memory",
|
||||
tags=["Memory"],
|
||||
)
|
||||
@@ -2053,6 +2191,39 @@ 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,
|
||||
@@ -2108,6 +2279,9 @@ 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)
|
||||
@@ -2129,6 +2303,7 @@ 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,
|
||||
@@ -2597,6 +2772,41 @@ 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,
|
||||
@@ -3118,6 +3328,55 @@ 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,
|
||||
@@ -3756,6 +4015,318 @@ 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,
|
||||
@@ -3848,6 +4419,12 @@ 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(
|
||||
@@ -3897,8 +4474,14 @@ 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 (files_metadata)\n\n"
|
||||
"**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).",
|
||||
"- `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.",
|
||||
operation_id="file_retain",
|
||||
tags=["Files"],
|
||||
)
|
||||
@@ -3944,20 +4527,39 @@ 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()
|
||||
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)
|
||||
total_batch_size += len(file_content)
|
||||
|
||||
# Create a mock UploadFile with the necessary attributes
|
||||
class FileWrapper:
|
||||
@@ -3965,7 +4567,6 @@ 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
|
||||
@@ -3976,6 +4577,12 @@ 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,
|
||||
@@ -3983,6 +4590,7 @@ 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)
|
||||
|
||||
@@ -3997,7 +4605,6 @@ 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,
|
||||
)
|
||||
|
||||
@@ -280,6 +280,7 @@ 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"
|
||||
@@ -292,7 +293,19 @@ 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"
|
||||
@@ -429,7 +442,8 @@ 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" # File parser to use (markitdown is the only supported parser)
|
||||
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_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
|
||||
@@ -437,9 +451,17 @@ 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
|
||||
@@ -497,6 +519,12 @@ 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.
|
||||
@@ -528,6 +556,11 @@ 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()
|
||||
@@ -687,7 +720,8 @@ 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: str # File parser to use (e.g., "markitdown", "iris")
|
||||
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_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)
|
||||
@@ -697,9 +731,13 @@ 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)
|
||||
@@ -750,6 +788,12 @@ 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
|
||||
@@ -794,6 +838,9 @@ 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",
|
||||
@@ -1118,7 +1165,10 @@ 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=os.getenv(ENV_FILE_PARSER, DEFAULT_FILE_PARSER),
|
||||
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_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(
|
||||
@@ -1135,6 +1185,14 @@ 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))
|
||||
),
|
||||
@@ -1144,6 +1202,15 @@ 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,
|
||||
@@ -1187,6 +1254,20 @@ 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,6 +9,10 @@ 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
|
||||
@@ -67,6 +71,42 @@ 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."""
|
||||
|
||||
@@ -180,11 +220,12 @@ 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 = {
|
||||
stats: dict[str, int] = {
|
||||
"memories_processed": 0,
|
||||
"observations_created": 0,
|
||||
"observations_updated": 0,
|
||||
"observations_merged": 0,
|
||||
"observations_deleted": 0,
|
||||
"actions_executed": 0,
|
||||
"skipped": 0,
|
||||
}
|
||||
@@ -273,11 +314,12 @@ 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 = await _process_memory_batch(
|
||||
pass_results, pass_deleted = await _process_memory_batch(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
llm_config=llm_config,
|
||||
@@ -288,6 +330,7 @@ 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
|
||||
@@ -315,7 +358,7 @@ async def run_consolidation_job(
|
||||
}
|
||||
else:
|
||||
# Normal single pass using the memory's own tags
|
||||
results = await _process_memory_batch(
|
||||
results, batch_deleted = await _process_memory_batch(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
llm_config=llm_config,
|
||||
@@ -325,6 +368,7 @@ 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",
|
||||
@@ -521,7 +565,7 @@ async def _process_memory_batch(
|
||||
perf: ConsolidationPerfLog | None = None,
|
||||
config: Any = None,
|
||||
obs_tags_override: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""
|
||||
Process a batch of memories in a single LLM call.
|
||||
|
||||
@@ -612,17 +656,18 @@ 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=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),
|
||||
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,
|
||||
perf=perf,
|
||||
)
|
||||
for m in source_mems:
|
||||
@@ -639,6 +684,7 @@ 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,
|
||||
@@ -647,15 +693,16 @@ async def _process_memory_batch(
|
||||
observation_id=update.observation_id,
|
||||
new_text=update.text,
|
||||
observations=union_observations,
|
||||
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),
|
||||
source_fact_tags=agg.tags,
|
||||
source_occurred_start=agg.occurred_start,
|
||||
source_occurred_end=agg.occurred_end,
|
||||
source_mentioned_at=agg.mentioned_at,
|
||||
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):
|
||||
@@ -664,6 +711,7 @@ 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]] = []
|
||||
@@ -680,7 +728,7 @@ async def _process_memory_batch(
|
||||
else:
|
||||
results.append({"action": "skipped", "reason": "no_durable_knowledge"})
|
||||
|
||||
return results
|
||||
return results, deleted_count
|
||||
|
||||
|
||||
def _min_date(dates: "Any") -> "datetime | None":
|
||||
@@ -718,13 +766,17 @@ async def _execute_update_action(
|
||||
logger.debug(f"Update skipped: observation {observation_id} not found in recall results")
|
||||
return
|
||||
|
||||
history = [
|
||||
{
|
||||
"previous_text": model.text,
|
||||
"changed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"source_memory_ids": [str(mid) for mid in source_memory_ids],
|
||||
}
|
||||
]
|
||||
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],
|
||||
}
|
||||
|
||||
source_ids = list(model.source_fact_ids or []) + source_memory_ids
|
||||
|
||||
@@ -739,13 +791,18 @@ 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 = $3,
|
||||
{history_clause}
|
||||
source_memory_ids = $4,
|
||||
proof_count = $5,
|
||||
tags = $10,
|
||||
@@ -757,7 +814,7 @@ async def _execute_update_action(
|
||||
""",
|
||||
new_text,
|
||||
embedding_str,
|
||||
json.dumps(history),
|
||||
json.dumps([history_entry]),
|
||||
source_ids,
|
||||
len(source_ids),
|
||||
uuid.UUID(observation_id),
|
||||
@@ -869,10 +926,9 @@ 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 = get_config()
|
||||
config = await memory_engine._config_resolver.resolve_full_config(bank_id, request_context)
|
||||
|
||||
# SECURITY: Use all_strict matching if tags provided to prevent cross-scope consolidation
|
||||
tags_match = "all_strict" if tags else "any"
|
||||
@@ -897,7 +953,8 @@ 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=-1, # No token limit — we need all source facts for consolidation
|
||||
max_source_facts_tokens=config.consolidation_source_facts_max_tokens,
|
||||
max_source_facts_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation,
|
||||
_quiet=True, # Suppress logging
|
||||
)
|
||||
finally:
|
||||
@@ -961,14 +1018,17 @@ async def _consolidate_batch_with_llm(
|
||||
observations_text = "[]"
|
||||
|
||||
def _fact_line(m: dict[str, Any]) -> str:
|
||||
parts = [f"[{m['id']}] {m['text']}"]
|
||||
text = f"[{m['id']}] {m['text']}"
|
||||
temporal_parts = []
|
||||
if m.get("occurred_start"):
|
||||
parts.append(f"occurred_start={m['occurred_start']}")
|
||||
temporal_parts.append(f"occurred_start={m['occurred_start']}")
|
||||
if m.get("occurred_end"):
|
||||
parts.append(f"occurred_end={m['occurred_end']}")
|
||||
temporal_parts.append(f"occurred_end={m['occurred_end']}")
|
||||
if m.get("mentioned_at"):
|
||||
parts.append(f"mentioned_at={m['mentioned_at']}")
|
||||
return " | ".join(parts)
|
||||
temporal_parts.append(f"mentioned_at={m['mentioned_at']}")
|
||||
if temporal_parts:
|
||||
text += f" ({', '.join(temporal_parts)})"
|
||||
return text
|
||||
|
||||
facts_lines = "\n".join(_fact_line(m) for m in memories)
|
||||
|
||||
@@ -1029,8 +1089,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 or now
|
||||
obs_occurred_end = occurred_end or now
|
||||
obs_occurred_start = occurred_start
|
||||
obs_occurred_end = occurred_end
|
||||
obs_mentioned_at = mentioned_at or now
|
||||
obs_tags = tags or []
|
||||
|
||||
|
||||
@@ -35,8 +35,25 @@ Compare the facts against existing observations:
|
||||
_BATCH_OUTPUT_FORMAT = """
|
||||
Output a JSON object with three arrays.
|
||||
|
||||
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"]}}],
|
||||
## 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"]}}],
|
||||
"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"}}]}}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
"""
|
||||
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"]
|
||||
@@ -1,53 +0,0 @@
|
||||
"""
|
||||
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,10 +1,31 @@
|
||||
"""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"]
|
||||
__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__)
|
||||
|
||||
|
||||
class FileParserRegistry:
|
||||
@@ -57,6 +78,51 @@ 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() as client:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, read=120.0)) as client:
|
||||
# Step 1: Request a presigned upload URL
|
||||
init_resp = await client.post(
|
||||
f"{_IRIS_BASE_URL}/org/{self._org_id}/files",
|
||||
@@ -75,9 +75,10 @@ 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=file_data,
|
||||
content=bytes(file_data),
|
||||
headers={"Content-Type": content_type},
|
||||
)
|
||||
_raise_for_status(upload_resp, filename, "file upload")
|
||||
|
||||
@@ -944,16 +944,15 @@ 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
|
||||
max_retries = 2
|
||||
last_error = None
|
||||
# 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
|
||||
|
||||
usage = TokenUsage() # Track cumulative usage across retries
|
||||
for attempt in range(max_retries):
|
||||
for attempt in range(llm_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
|
||||
@@ -969,7 +968,7 @@ async def _extract_facts_from_chunk(
|
||||
scope="retain_extract_facts",
|
||||
temperature=0.1,
|
||||
max_completion_tokens=config.retain_max_completion_tokens,
|
||||
max_retries=max_retries,
|
||||
max_retries=llm_max_retries,
|
||||
initial_backoff=initial_backoff,
|
||||
max_backoff=max_backoff,
|
||||
skip_validation=True, # Get raw JSON, we'll validate leniently
|
||||
@@ -983,14 +982,14 @@ async def _extract_facts_from_chunk(
|
||||
|
||||
# Handle malformed LLM responses
|
||||
if not isinstance(extraction_response_json, dict):
|
||||
if attempt < max_retries - 1:
|
||||
if attempt < llm_max_retries - 1:
|
||||
logger.warning(
|
||||
f"LLM returned non-dict JSON on attempt {attempt + 1}/{max_retries}: {type(extraction_response_json).__name__}. Retrying..."
|
||||
f"LLM returned non-dict JSON on attempt {attempt + 1}/{llm_max_retries}: {type(extraction_response_json).__name__}. Retrying..."
|
||||
)
|
||||
continue
|
||||
else:
|
||||
logger.warning(
|
||||
f"LLM returned non-dict JSON after {max_retries} attempts: {type(extraction_response_json).__name__}. "
|
||||
f"LLM returned non-dict JSON after {llm_max_retries} attempts: {type(extraction_response_json).__name__}. "
|
||||
f"Raw: {str(extraction_response_json)[:500]}"
|
||||
)
|
||||
return [], usage
|
||||
@@ -1206,9 +1205,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 < max_retries - 1:
|
||||
if has_malformed_facts and len(chunk_facts) < len(raw_facts) * 0.8 and attempt < llm_max_retries - 1:
|
||||
logger.warning(
|
||||
f"Got {len(raw_facts) - len(chunk_facts)} malformed facts out of {len(raw_facts)} on attempt {attempt + 1}/{max_retries}. Retrying..."
|
||||
f"Got {len(raw_facts) - len(chunk_facts)} malformed facts out of {len(raw_facts)} on attempt {attempt + 1}/{llm_max_retries}. Retrying..."
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -1241,16 +1240,18 @@ async def _extract_facts_from_chunk(
|
||||
|
||||
if "json_validate_failed" in str(e):
|
||||
logger.warning(
|
||||
f" [1.3.{chunk_index + 1}] Attempt {attempt + 1}/{max_retries} failed with JSON validation error: {e}"
|
||||
f" [1.3.{chunk_index + 1}] Attempt {attempt + 1}/{llm_max_retries} failed with JSON validation error: {e}"
|
||||
)
|
||||
if attempt < max_retries - 1:
|
||||
if attempt < llm_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
|
||||
raise last_error
|
||||
# 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")
|
||||
|
||||
|
||||
async def _extract_facts_with_auto_split(
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
|
||||
@@ -52,6 +53,8 @@ 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,
|
||||
@@ -82,6 +85,7 @@ 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.
|
||||
@@ -484,6 +488,11 @@ async def retain_batch(
|
||||
# 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()
|
||||
|
||||
@@ -395,27 +395,28 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
WHERE id = ANY($1::uuid[])
|
||||
AND source_memory_ids IS NOT NULL
|
||||
),
|
||||
source_entities AS (
|
||||
SELECT DISTINCT ue.entity_id
|
||||
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
|
||||
FROM seed_sources ss
|
||||
JOIN {fq_table("unit_entities")} ue ON ss.source_id = ue.unit_id
|
||||
JOIN {fq_table("memory_links")} ml ON ml.from_unit_id = ss.source_id
|
||||
WHERE ml.link_type = 'entity'
|
||||
),
|
||||
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
|
||||
connected_array AS (
|
||||
SELECT array_agg(source_id) AS source_ids FROM connected_sources
|
||||
)
|
||||
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,
|
||||
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]
|
||||
(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
|
||||
WHERE mu.fact_type = 'observation'
|
||||
AND mu.id != ALL($1::uuid[])
|
||||
GROUP BY mu.id
|
||||
AND ca.source_ids IS NOT NULL
|
||||
AND mu.source_memory_ids && ca.source_ids
|
||||
ORDER BY score DESC
|
||||
LIMIT $2
|
||||
""",
|
||||
|
||||
@@ -2,8 +2,72 @@
|
||||
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,7 +1,8 @@
|
||||
"""Google Cloud Storage backend using obstore."""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import obstore as obs
|
||||
from obstore.store import GCSStore
|
||||
@@ -11,6 +12,30 @@ 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.
|
||||
@@ -27,8 +52,29 @@ 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}"
|
||||
)
|
||||
|
||||
self._store = GCSStore(bucket, **kwargs)
|
||||
# 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
|
||||
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,6 +30,8 @@ from hindsight_api.extensions.operation_validator import (
|
||||
# Consolidation operation
|
||||
ConsolidateContext,
|
||||
ConsolidateResult,
|
||||
# File Conversion
|
||||
FileConvertResult,
|
||||
# Mental Model operations
|
||||
MentalModelGetContext,
|
||||
MentalModelGetResult,
|
||||
@@ -83,6 +85,8 @@ __all__ = [
|
||||
# Operation Validator - Consolidation
|
||||
"ConsolidateContext",
|
||||
"ConsolidateResult",
|
||||
# Operation Validator - File Conversion
|
||||
"FileConvertResult",
|
||||
# Operation Validator - Mental Model
|
||||
"MentalModelGetContext",
|
||||
"MentalModelGetResult",
|
||||
|
||||
@@ -96,6 +96,8 @@ 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,
|
||||
@@ -111,10 +113,14 @@ class DefaultExtensionContext(ExtensionContext):
|
||||
if engine_url:
|
||||
db_url = engine_url
|
||||
|
||||
run_migrations(db_url, schema=schema)
|
||||
|
||||
# Get config for vector extension setting
|
||||
# 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.
|
||||
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
|
||||
@@ -123,15 +129,23 @@ class DefaultExtensionContext(ExtensionContext):
|
||||
if embeddings is not None:
|
||||
dimension = getattr(embeddings, "dimension", None)
|
||||
if dimension is not None:
|
||||
ensure_embedding_dimension(
|
||||
db_url, dimension, schema=schema, vector_extension=config.vector_extension
|
||||
await asyncio.to_thread(
|
||||
ensure_embedding_dimension,
|
||||
db_url,
|
||||
dimension,
|
||||
schema=schema,
|
||||
vector_extension=config.vector_extension,
|
||||
)
|
||||
|
||||
# Ensure vector indexes match the configured extension
|
||||
ensure_vector_extension(db_url, vector_extension=config.vector_extension, schema=schema)
|
||||
await asyncio.to_thread(
|
||||
ensure_vector_extension, db_url, vector_extension=config.vector_extension, schema=schema
|
||||
)
|
||||
|
||||
# Ensure text search columns/indexes match the configured extension
|
||||
ensure_text_search_extension(db_url, text_search_extension=config.text_search_extension, schema=schema)
|
||||
await asyncio.to_thread(
|
||||
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,6 +289,28 @@ 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.
|
||||
@@ -496,6 +518,31 @@ 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)
|
||||
# =========================================================================
|
||||
|
||||
@@ -268,6 +268,7 @@ 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,
|
||||
@@ -275,9 +276,13 @@ 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,
|
||||
@@ -307,6 +312,10 @@ 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:
|
||||
@@ -385,6 +394,7 @@ 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
|
||||
|
||||
@@ -271,48 +271,71 @@ 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.
|
||||
|
||||
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.
|
||||
Compatible with FastMCP 2.x (_tool_manager pattern) and 3.x (provider pattern).
|
||||
"""
|
||||
try:
|
||||
tool_manager = mcp._tool_manager
|
||||
original_get_tools = tool_manager.get_tools
|
||||
|
||||
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}
|
||||
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)
|
||||
|
||||
setattr(tool_manager, "get_tools", _filtered_get_tools)
|
||||
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
|
||||
|
||||
# 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_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]
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
object.__setattr__(tool, "run", _filtered_run)
|
||||
except (AttributeError, KeyError) as e:
|
||||
logger.warning(f"Could not apply bank tool filtering: {e}")
|
||||
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")
|
||||
|
||||
|
||||
def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
|
||||
@@ -18,6 +18,7 @@ No alembic.ini required - all configuration is done programmatically.
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
@@ -33,6 +34,13 @@ 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:
|
||||
"""
|
||||
@@ -144,9 +152,12 @@ def _run_migrations_internal(database_url: str, script_location: str, schema: st
|
||||
if schema:
|
||||
alembic_cfg.set_main_option("target_schema", schema)
|
||||
|
||||
# Run migrations
|
||||
# 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.
|
||||
try:
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
with _alembic_lock:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -0,0 +1,242 @@
|
||||
"""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
|
||||
@@ -0,0 +1,51 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,9 @@
|
||||
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,7 +219,6 @@ 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,
|
||||
|
||||
@@ -14,6 +14,8 @@ 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
|
||||
|
||||
@@ -57,7 +59,6 @@ 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,
|
||||
@@ -71,7 +72,6 @@ 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,7 +82,6 @@ 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:
|
||||
@@ -218,11 +217,12 @@ class WorkerPoller:
|
||||
# 1. Claim non-consolidation tasks (up to limit)
|
||||
non_consolidation_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT operation_id, task_payload
|
||||
SELECT operation_id, task_payload, retry_count
|
||||
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,11 +238,12 @@ class WorkerPoller:
|
||||
if consolidation_limit > 0 and remaining_limit > 0:
|
||||
consolidation_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT operation_id, task_payload
|
||||
SELECT operation_id, task_payload, retry_count
|
||||
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
|
||||
@@ -274,14 +275,19 @@ class WorkerPoller:
|
||||
)
|
||||
|
||||
# Parse and return task payloads with schema context
|
||||
return [
|
||||
ClaimedTask(
|
||||
operation_id=str(row["operation_id"]),
|
||||
task_dict=json.loads(row["task_payload"]),
|
||||
schema=schema,
|
||||
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,
|
||||
)
|
||||
)
|
||||
for row in all_rows
|
||||
]
|
||||
return result
|
||||
|
||||
async def _mark_completed(self, operation_id: str, schema: str | None):
|
||||
"""Mark a task as completed."""
|
||||
@@ -310,40 +316,22 @@ class WorkerPoller:
|
||||
error_message,
|
||||
)
|
||||
|
||||
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."""
|
||||
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."""
|
||||
table = fq_table("async_operations", schema)
|
||||
|
||||
# Get current retry count
|
||||
row = await self._pool.fetchrow(
|
||||
f"SELECT retry_count FROM {table} WHERE operation_id = $1",
|
||||
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
|
||||
""",
|
||||
operation_id,
|
||||
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})")
|
||||
logger.warning(f"Task {operation_id} scheduled for retry at {retry_at}: {error_message}")
|
||||
|
||||
async def execute_task(self, task: ClaimedTask):
|
||||
"""Execute a single task as a background job (fire-and-forget)."""
|
||||
@@ -378,11 +366,10 @@ class WorkerPoller:
|
||||
async def _execute_task_inner(self, task: ClaimedTask):
|
||||
"""Inner task execution with retry/fail handling.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
task_type = task.task_dict.get("type", "unknown")
|
||||
bank_id = task.task_dict.get("bank_id", "unknown")
|
||||
@@ -394,10 +381,12 @@ 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._retry_or_fail(task.operation_id, str(e), task.schema)
|
||||
await self._mark_failed(task.operation_id, str(e), task.schema)
|
||||
|
||||
async def recover_own_tasks(self) -> int:
|
||||
"""
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api"
|
||||
version = "0.4.15"
|
||||
version = "0.4.16"
|
||||
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,11 +53,16 @@ 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.5", # Serialization injection vulnerability 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
|
||||
"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]
|
||||
|
||||
@@ -413,7 +413,6 @@ 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,
|
||||
|
||||
@@ -1,334 +1,171 @@
|
||||
"""
|
||||
Tests for combined scoring functionality.
|
||||
Tests for combined scoring (apply_combined_scoring).
|
||||
|
||||
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)
|
||||
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.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class TestRRFNormalization:
|
||||
"""Test that RRF scores are properly normalized."""
|
||||
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
|
||||
|
||||
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]
|
||||
candidate = MagicMock(spec=MergedCandidate)
|
||||
candidate.retrieval = retrieval
|
||||
candidate.rrf_score = 0.05
|
||||
|
||||
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}"
|
||||
return ScoredResult(
|
||||
candidate=candidate,
|
||||
cross_encoder_score=1.0,
|
||||
cross_encoder_score_normalized=ce_norm,
|
||||
weight=ce_norm,
|
||||
)
|
||||
|
||||
|
||||
class TestCombinedScoringFormula:
|
||||
"""Test that the combined scoring formula is applied correctly."""
|
||||
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
|
||||
|
||||
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_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
|
||||
|
||||
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_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
|
||||
|
||||
# Test case 2: All components at 0.0
|
||||
ce_norm = 0.0
|
||||
rrf_norm = 0.0
|
||||
temporal = 0.0
|
||||
recency = 0.0
|
||||
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
|
||||
|
||||
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_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
|
||||
|
||||
# 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_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
|
||||
|
||||
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}"
|
||||
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)
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
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_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
|
||||
|
||||
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_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
|
||||
|
||||
# Low RRF
|
||||
score_low_rrf = 0.6 * ce_norm + 0.2 * 0.0 + 0.1 * temporal + 0.1 * recency
|
||||
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
|
||||
|
||||
# High RRF
|
||||
score_high_rrf = 0.6 * ce_norm + 0.2 * 1.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.
|
||||
|
||||
# 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}"
|
||||
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)
|
||||
|
||||
# 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"
|
||||
|
||||
@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()}"
|
||||
# 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"
|
||||
|
||||
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_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
|
||||
|
||||
# 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_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
|
||||
|
||||
assert result.trace is not None, "Trace should be present"
|
||||
trace = result.trace
|
||||
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
|
||||
|
||||
# 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_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
|
||||
|
||||
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)
|
||||
def test_empty_list_is_noop(self):
|
||||
apply_combined_scoring([], now=NOW) # must not raise
|
||||
|
||||
@@ -5,11 +5,17 @@ Note: Consolidation runs automatically after retain via SyncTaskBackend in tests
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
|
||||
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.memory_engine import MemoryEngine
|
||||
from hindsight_api.engine.reflect.tools import (
|
||||
tool_recall,
|
||||
@@ -2317,3 +2323,178 @@ 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)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
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,12 +2,22 @@
|
||||
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():
|
||||
@@ -393,13 +403,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,
|
||||
)
|
||||
@@ -511,6 +521,7 @@ async def test_file_conversion_failure_sets_status_to_failed(memory_no_llm_verif
|
||||
"metadata": {},
|
||||
"tags": [],
|
||||
"timestamp": None,
|
||||
"parser": ["failing_converter"],
|
||||
}
|
||||
]
|
||||
|
||||
@@ -518,7 +529,6 @@ 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,
|
||||
)
|
||||
@@ -551,3 +561,207 @@ 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,6 +75,9 @@ 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
|
||||
@@ -86,7 +89,7 @@ async def test_hierarchical_fields_categorization():
|
||||
assert "entity_labels" in configurable
|
||||
|
||||
# Verify count is correct
|
||||
assert len(configurable) == 14
|
||||
assert len(configurable) == 17
|
||||
|
||||
# Verify credential fields (NEVER exposed)
|
||||
assert "llm_api_key" in credentials
|
||||
|
||||
@@ -656,6 +656,113 @@ 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."""
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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,3 +455,281 @@ 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,6 +231,61 @@ 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):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,780 @@
|
||||
"""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)
|
||||
@@ -294,14 +294,17 @@ class TestWorkerPoller:
|
||||
payload,
|
||||
)
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from hindsight_api.worker.exceptions import RetryTaskAt
|
||||
|
||||
async def failing_executor(task_dict):
|
||||
raise ValueError("TimeoutError during recall")
|
||||
raise RetryTaskAt(retry_at=datetime.now(timezone.utc), message="TimeoutError during recall")
|
||||
|
||||
poller = WorkerPoller(
|
||||
pool=pool,
|
||||
worker_id="test-worker-1",
|
||||
executor=failing_executor,
|
||||
max_retries=3,
|
||||
)
|
||||
|
||||
task_dict = json.loads(payload)
|
||||
@@ -326,39 +329,35 @@ class TestWorkerPoller:
|
||||
assert row["retry_count"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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.
|
||||
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'.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
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(), $4)
|
||||
VALUES ($1, $2, 'consolidation', 'processing', $3::jsonb, 'test-worker-1', now(), 0)
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
payload,
|
||||
max_retries,
|
||||
)
|
||||
|
||||
async def failing_executor(task_dict):
|
||||
raise ValueError("Still failing after all retries")
|
||||
raise ValueError("Non-retryable error")
|
||||
|
||||
poller = WorkerPoller(
|
||||
pool=pool,
|
||||
worker_id="test-worker-1",
|
||||
executor=failing_executor,
|
||||
max_retries=max_retries,
|
||||
)
|
||||
|
||||
task_dict = json.loads(payload)
|
||||
@@ -373,11 +372,10 @@ class TestWorkerPoller:
|
||||
op_id,
|
||||
)
|
||||
assert row["status"] == "failed", (
|
||||
f"Expected 'failed' after max retries, got '{row['status']}'"
|
||||
f"Expected 'failed' for plain exception, got '{row['status']}'"
|
||||
)
|
||||
assert row["error_message"] is not None
|
||||
assert "Max retries" in row["error_message"]
|
||||
assert row["retry_count"] == max_retries # not incremented further
|
||||
assert row["retry_count"] == 0 # not incremented; plain exception = immediate fail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_executor_failed_status_not_overridden(self, pool, clean_operations):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hindsight-cli"
|
||||
version = "0.4.15"
|
||||
version = "0.4.16"
|
||||
edition = "2021"
|
||||
authors = ["Hindsight Team"]
|
||||
description = "A beautiful CLI for Hindsight - semantic memory system"
|
||||
|
||||
@@ -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.15
|
||||
version: 0.4.16
|
||||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
@@ -210,8 +210,9 @@ 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.
|
||||
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."
|
||||
operationId: get_memory
|
||||
parameters:
|
||||
- explode: false
|
||||
@@ -253,6 +254,51 @@ 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: |-
|
||||
@@ -838,6 +884,51 @@ 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
|
||||
@@ -1344,6 +1435,61 @@ 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\
|
||||
@@ -2111,6 +2257,248 @@ 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\
|
||||
@@ -2250,9 +2638,14 @@ paths:
|
||||
|
||||
**Request format:** multipart/form-data with:
|
||||
- `files`: One or more files to upload
|
||||
- `request`: JSON string with FileRetainRequest model (files_metadata)
|
||||
- `request`: JSON string with FileRetainRequest model
|
||||
|
||||
**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).
|
||||
**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.
|
||||
operationId: file_retain
|
||||
parameters:
|
||||
- explode: false
|
||||
@@ -2879,6 +3272,47 @@ 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:
|
||||
@@ -4308,9 +4742,15 @@ components:
|
||||
properties:
|
||||
max_tokens:
|
||||
default: 4096
|
||||
description: Maximum tokens for source facts
|
||||
description: Maximum total tokens for source facts across all observations
|
||||
(-1 = unlimited)
|
||||
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.
|
||||
@@ -4406,6 +4846,29 @@ 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:
|
||||
@@ -4437,6 +4900,41 @@ 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
|
||||
@@ -4481,6 +4979,244 @@ 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
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
@@ -591,3 +591,144 @@ 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
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
@@ -78,9 +78,14 @@ 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 (files_metadata)
|
||||
- `request`: JSON string with FileRetainRequest model
|
||||
|
||||
**Note:** File parser is configured server-side via `HINDSIGHT_API_FILE_PARSER` (default: markitdown).
|
||||
**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.
|
||||
|
||||
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
|
||||
@param bankId
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.
|
||||
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.
|
||||
|
||||
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
|
||||
@param bankId
|
||||
@@ -589,6 +589,132 @@ 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
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
@@ -409,6 +409,132 @@ 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
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
/*
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
// APIClient manages communication with the Hindsight HTTP API API v0.4.16
|
||||
// In most cases there should be only one, shared, APIClient.
|
||||
type APIClient struct {
|
||||
cfg *Configuration
|
||||
@@ -66,6 +66,8 @@ type APIClient struct {
|
||||
MonitoringAPI *MonitoringAPIService
|
||||
|
||||
OperationsAPI *OperationsAPIService
|
||||
|
||||
WebhooksAPI *WebhooksAPIService
|
||||
}
|
||||
|
||||
type service struct {
|
||||
@@ -93,6 +95,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
/*
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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.15
|
||||
API version: 0.4.16
|
||||
*/
|
||||
|
||||
// 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
Reference in New Issue
Block a user