Compare commits

...
36 Commits
Author SHA1 Message Date
Nicolò Boschi d03b00e28b doc: add 0.4.16 release blog post and changelog 2026-03-05 18:07:37 +01:00
Nicolò Boschi 58fdac44f7 Release v0.4.16
- Update version to 0.4.16 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm, hindsight-crewai, hindsight-pydantic-ai, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- OpenClaw integration: hindsight-integrations/openclaw
- AI SDK integration: hindsight-integrations/ai-sdk
- Chat SDK integration: hindsight-integrations/chat
- Helm chart
- Sync documentation to version-0.4
2026-03-05 17:54:59 +01:00
Nicolò Boschi 891c33b1d7 fix: preserve None temporal fields for observations without source dates (#501) 2026-03-05 17:42:23 +01:00
BenandClaude Opus 4.6 7ed57fdd85 doc: Give Your OpenAI App a Memory in 5 Minutes (#498)
* doc: add add-memory-to-openai-application blog post


---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-05 11:34:04 -05:00
Nicolò Boschi e0a2ac63e7 fix: run schema migrations in thread to prevent event loop deadlock (#500)
When a new tenant schema is provisioned while retain/recall operations
are in-flight, run_migration() was calling synchronous migration
functions directly on the asyncio event loop. These functions execute
CREATE INDEX CONCURRENTLY, which waits for all active transactions to
commit. But in-flight asyncpg transactions cannot flush their COMMIT
because the event loop is blocked — deadlock.

Fix: wrap all four sync migration calls in asyncio.to_thread() so they
run in the thread pool, keeping the event loop free.

Reproduced with the unfixed code: test_retain_memory timed out with
httpx.ReadTimeout when run concurrently with test_create_tenant.
All 75 integration tests pass after the fix.
2026-03-05 17:19:51 +01:00
Chris Bartholomew 75b95106ba fix: use correct schema name in webhook outbox callback to prevent silent transaction rollback (#499)
The retain outbox callback was passing context.tenant_id (raw UUID like
0f3ad4ec-8b88-...) instead of the PostgreSQL schema name (tenant_0f3ad4ec_...).
This caused the webhook manager to query a non-existent schema, triggering a
PostgreSQL error that silently aborted the entire retain transaction — rolling
back all inserted memory data with no clear indication of data loss.

Fixed both the async worker path (memory_engine.py) and sync HTTP path (http.py)
to use _current_schema.get() which holds the correct tenant-prefixed schema name.

Also changed fire_event_with_conn to re-raise exceptions instead of swallowing
them, since errors inside a caller's transaction poison it irreversibly.
2026-03-05 17:07:48 +01:00
Tian ZandClaude Sonnet 4.6 d425e93cb4 feat(openclaw): v2 recall/retention controls, scalability fixes, and Gemini safety settings (#480)
* feat(openclaw): squash branch updates for fork PR

* revert(api): drop memory_engine query normalization from this PR

* fix(openclaw): harden hook isolation and sanitize recall logging

* chore(openclaw): gate missing-senderId notice behind debug logger

* fix(openclaw): address remaining PR review follow-ups

* fix(openclaw): address upstream review comments on isolation and tests

* feat(openclaw): prepend current timestamp to recalled memory context

* chore(openclaw): sync package-lock version to 0.4.14

* chore(openclaw): format recall timestamp as yyyy-mm-dd HH:MM

* feat(openclaw): add configurable recall context composition

- Add recallRoles config to filter which message roles are included in recall query context
- Add recallContextTurns to control how many user turns of prior context to include
- Add recallMaxQueryChars to cap composed query length
- Reduce default max_tokens from 2048 to 1024 for recall responses
- Update documentation and plugin schema with new configuration options

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* fix(openclaw): put latest user message at end of recall query, add debug to schema

- Reorder composed recall query so latest user message is at the bottom,
  giving embedding models the most weight where it matters most
- Update truncateRecallQuery to trim oldest context lines first,
  always preserving the suffix (priority instruction + latest message)
- Add debug flag to openclaw.plugin.json schema
- Update tests to reflect new query order

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* fix(openclaw): add verbose debug logging for recall/retain

- Log full recall query (not just first 50 chars)
- Log all raw recall results with scores and content before topK trimming
- Log retain transcript preview and document ID

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* fix(openclaw): strip sender metadata envelope from prior context in recall query

Prior context messages passed to composeRecallQuery contained raw OpenClaw
envelope blocks (Sender/untrusted metadata JSON) which were diluting the
semantic signal of the recall query. Strip them the same way extractRecallQuery
already does for the latest message.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* fix(openclaw): add debug log for event.messages at recall time

Helps diagnose why recallContextTurns > 1 may not show extra context
by logging message count and roles available in event.messages.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* fix(openclaw): strip sender metadata envelope from rawMessage before recall query extraction

The rawMessage from Telegram group chats arrives wrapped in a:
  ---
  Sender (untrusted metadata):
  ```json {...}```

  <actual message>
  ---

envelope. This wasn't being stripped before extractRecallQuery used it,
so the full envelope including JSON metadata was being sent as the recall
query, severely diluting semantic relevance.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* fix(openclaw): warn when recallContextTurns > 1 but event.messages is empty

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* fix(openclaw): read messages from event.context.sessionEntry.messages for recall and retain

event.messages was always empty — the actual conversation history is at
event.context.sessionEntry.messages. Fall back to event.messages for
backwards compatibility. This fixes recallContextTurns and retain both
being unable to see the conversation history.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* fix(openclaw): extract stripMetadataEnvelopes helper and apply to retain path

- Add shared stripMetadataEnvelopes() to strip OpenClaw sender/conversation
  metadata blocks from message content in all paths (recall query extraction,
  prior context composition, and retain transcript)
- This prevents metadata-polluted memories (name/sender ID facts) from being
  stored and ensures recall queries contain clean user text only

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* fix(openclaw): strip metadata envelopes after channel envelope extraction too

The prompt format is: [ChannelName ...]\n<metadata envelope>\n<message>
After extracting content after [ChannelName], the metadata envelope was
still present. Now stripMetadataEnvelopes runs again after the channel
envelope extraction step.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* fix(openclaw): switch recall hook from before_agent_start to before_prompt_build

before_prompt_build runs after session load and has messages available,
enabling recallContextTurns to work correctly. before_agent_start runs
pre-session with no messages.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* fix(openclaw): move current time inside memory tag, simplify recall query format

- Move "Current time" line inside <hindsight_memories> so it's not exposed
  to the recall search as part of the query context
- Remove RECALL_QUERY_PRIORITY_INSTRUCTION and "Latest user message:" label
  from composed recall query — the raw message is more effective for
  semantic search without the extra prompt noise

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* fix(openclaw): address PR review comments on bank ID fallback and memory leaks

- Add early return in deriveBankId when ctx is undefined, falling back
  to static default bank instead of generating a placeholder-filled ID
- Remove unused RECALL_QUERY_PRIORITY_INSTRUCTION dead constant
- Evict from banksWithMissionSet when evicting from clientsByBankId
  to prevent unbounded memory growth in long-running instances
- Fix integration test hook name: before_agent_start → before_prompt_build
- Fix integration test assertions to match actual composeRecallQuery output

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* fix(openclaw): extract sender ID from inbound metadata blocks for bank ID derivation

Agent-phase hooks (before_prompt_build, agent_end) don't carry senderId in ctx
by design. Parse it from the "Conversation info / Sender (untrusted metadata)"
JSON blocks that OpenClaw injects into the prompt/messages instead.

- Add extractSenderIdFromText() helper that scans all metadata blocks and
  returns the first sender_id / id field found
- before_prompt_build: extract from event.prompt/rawMessage, spread into ctx
  before calling deriveBankId and getClientForContext
- agent_end: scan user messages for the metadata block, spread into effectiveCtx
  before calling deriveBankId and getClientForContext
- Gracefully skipped when senderId is already present in ctx

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* fix(openclaw): scan messages from end for sender ID to handle group chats

When multiple users have spoken in a session, scanning from the front
returns the first sender in history rather than the one who triggered
the current agent run. Reverse the slice before finding so we always
pick the most recent user message's sender ID.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* fix(openclaw): use event.messages for sender ID in agent_end, not sessionEntry

sessionEntry.messages is the cleaned-up history without OpenClaw's injected
metadata prefix blocks. event.messages is the raw payload that still contains
the "Conversation info (untrusted metadata)" JSON — so parse sender_id from
there instead.

Also removes the unnecessary senderIdBySession cache added in the previous
attempt, since event.messages has everything needed directly.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* fix(openclaw): cache sender ID from before_prompt_build for use in agent_end

event.prompt in before_prompt_build contains OpenClaw's injected metadata
blocks with sender_id. event.messages in agent_end is clean history without
them — so parsing messages in agent_end never finds a sender ID.

Fix: cache the resolved sender ID (keyed by sessionKey) when it's extracted
in before_prompt_build, then look it up by sessionKey in agent_end.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* docs(openclaw): revert Auto-Recall token count to 1024 as unchanged from main

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

* fix(openclaw): revert recallMaxTokens default from 2048 to 1024 to match main

Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <[email protected]>
2026-03-05 16:55:16 +01:00
Nicolò Boschi cb6d1c469c fix: resolve chunks for observation results via source_memory_ids (#496)
* fix: resolve chunks for observation results via source_memory_ids

Observations have no direct chunk_id (they are synthesized from source
memories). When include_chunks=True and fact_type includes 'observation',
chunks were silently returned as None.

Fix collects source chunk_ids via a single JOIN on source_memory_ids,
using array_position to preserve observation rank order so observation
source chunks are interleaved at the correct position rather than
appended after all direct-fact chunks.

* fix: use correct run_consolidation method name in test
2026-03-05 14:12:21 +01:00
Nicolò Boschi 4c058b4b98 fix: cap uvicorn graceful shutdown at 5s and enable force-kill on double Ctrl+C (#495)
Add timeout_graceful_shutdown=5 to uvicorn config to prevent the 30-second
shutdown delay and enable force-kill behavior on a second Ctrl+C signal.
2026-03-05 11:35:23 +01:00
Nicolò Boschi aa8e5475c4 fix: replace additive combined scoring with multiplicative CE boosts (#494) 2026-03-05 11:12:28 +01:00
Nicolò Boschi ad2cf72aab perf: add GIN index on source_memory_ids for observation lookup (#485)
* perf: add GIN index on source_memory_ids for observation lookup

Addresses a 927x performance regression (45ms → 0.049ms) reported by a
user with ~77k observations. The array overlap operator (&&) on
source_memory_ids was doing a full sequential scan over all observations,
causing recall timeouts (57-64s) and slow user recall (18-27s avg).

The partial GIN index reduces consolidation recall from timeout to ~15s
and user recall to ~6s.

* fix: use pre-bounded memory_links for observation graph expansion

Replace raw unit_entities join in _expand_observations() with the same
memory_links entity graph used by non-observation fact types. The previous
approach joined unit_entities twice (seeds→entities→connected_sources),
which explodes at scale (30-70s at 100k observations). The LIMIT 500
workaround was non-deterministic and dropped valid results.

Using memory_links (pre-bounded to MAX_LINKS_PER_ENTITY=50 at retain time)
is algorithmically identical to the non-observation entity expansion and
keeps graph retrieval at ~2s p50 even at 100k observations.

Also fix migration down_revision (z1u2v3w4x5y6 → d2e3f4a5b6c7) and add
observation generation + fact-type filtering to the recall perf benchmark.
2026-03-05 10:15:40 +01:00
Ben 3f2a6ec9ce doc: add MCP blog post (#492)
* doc: add MCP agent memory blog post
2026-03-04 15:02:58 -05:00
Chris Bartholomew f17406fdf0 Fix bank-level MCP tool filtering for FastMCP 3.x (#491)
FastMCP 3.x replaced _tool_manager.get_tools() with a provider pattern
(LocalProvider._list_tools via _components). The existing wrapper on
_tool_manager.get_tools() silently failed (caught AttributeError) since
_tool_manager no longer exists in v3.

Now wraps FastMCP.list_tools() and FastMCP.get_tool() for v3, while
preserving the _tool_manager approach for v2 compatibility.
2026-03-04 10:29:29 -05:00
Nicolò Boschi 66423b85f5 fix: resolve TypeError when LLM returns invalid JSON across all retries (#488) (#490)
- Rename shadowed `max_retries` variable to `llm_max_retries` and move
  config resolution outside the loop; the old code captured `range(2)`
  then overwrote `max_retries` inside the loop, so comparisons used a
  different value than the loop bound — causing `continue` on the final
  iteration, exhausting the loop, and reaching `raise last_error` where
  `last_error` was still None → TypeError
- Add fallback `raise RuntimeError(...)` after the retry loop so that if
  `last_error` is None a descriptive error is raised instead of None
- Add unit tests covering non-dict JSON responses with various retry counts
2026-03-04 14:17:55 +01:00
Nicolò Boschi abbf874d84 feat: webhook system with retain.completed event, UI, and docs (#487)
* doc: update cookbook

* fix(cookbook): preserve tag keys during sync, strip local .md links

- Fix extract_tags_from_readme/notebook to return dict[str,str] preserving
  sdk/topic keys instead of bare values, preventing topics like
  "Customer Service" from being misclassified as SDK
- Add strip_local_md_links() to remove relative .md references that
  would cause broken link errors in Docusaurus build

* ci: run test-doc-examples independently without waiting for test-rust-cli

Build the CLI directly in the job instead of downloading the artifact,
so test-doc-examples can start at the beginning in parallel with all other jobs.

* feat: webhook system with task-owned retry, retain.completed event, and UI

- New webhook system: register per-bank webhooks with HMAC signing, configurable
  HTTP method/timeout/headers/params (http_config JSONB), and PATCH support
- Webhook deliveries run as async_operations (webhook_delivery type) with
  task-owned retry via RetryTaskAt exception and exponential backoff
  (60s / 5m / 30m / 2h / 8h, max 6 attempts)
- New retain.completed event fires per-document for both sync and async retain
- Delivery debug info (status code, response body) stored in result_metadata
- Control plane UI: webhooks tab per bank with create/edit/delete and a
  deliveries table with cursor pagination and expandable response details
- 28 webhook tests covering HMAC signing, delivery retries, CRUD endpoints,
  PATCH update, and retain.completed queuing
- Docs page at developer/api/webhooks documenting event payloads and delivery
- OpenAPI spec and all client SDKs (Python, TypeScript, Rust, Go) regenerated

* fix: update tests for task-owned retry model and guard _webhook_manager attribute

- test_worker.py: test_executor_exception_triggers_retry now raises RetryTaskAt
  (plain exceptions are immediate failures in the new system); rename
  test_executor_exception_marks_failed_after_max_retries to
  test_executor_exception_marks_failed_immediately to reflect new semantics
- test_batch_api.py: remove max_retries kwarg from WorkerPoller constructor
- memory_engine.py: use getattr for _webhook_manager in _fire_retain_webhook
  to avoid AttributeError when engine is created without __init__ (tests)

* fix: remove max_retries from benchmark WorkerPoller call

* fix(webhooks): transactional outbox, observations_deleted tracking, sidebar

- Queue webhook delivery rows atomically with the primary operation using the
  transactional outbox pattern — prevents lost events on process crash:
  - Retain (sync + async): outbox_callback passed into orchestrator.retain_batch
    and called inside the DB transaction, replacing the post-commit fire call
  - Consolidation: new _mark_operation_completed_and_fire_webhook combines the
    status UPDATE and webhook INSERT in one transaction
  - Added fire_event_with_conn() to WebhookManager for in-connection delivery

- Track observations_deleted count in consolidation stats and expose it in the
  consolidation.completed webhook payload (was always None)

- Add Webhooks page to docs sidebar

- Document at-least-once delivery guarantee with operation_id dedup guidance

* fix(ui): add retain.completed to available webhook event types

* feat(ui): add delete confirmation dialog for webhooks

* fix(webhooks): include operation_id in task_payload so delivery is marked completed

The task_payload JSON was missing the operation_id field, causing execute_task
to see operation_id=None and skip _mark_operation_completed — leaving every
delivery row stuck in 'pending' forever.

Added a test that inserts a real async_operations row and verifies the status
transitions to 'completed' after a successful execute_task call.

* style: fix prettier formatting in webhooks-view
2026-03-04 14:17:01 +01:00
Nicolò Boschi 51d2fc5309 doc: add ZeroEntropy reranker to models.md (#489) 2026-03-04 13:28:25 +01:00
Nicolò Boschi ea27ef95ec fix: resolve all Dependabot security vulnerabilities (#486)
* fix: resolve all Dependabot security vulnerabilities

npm (package-lock.json):
- fast-xml-parser: 4.5.3 → 4.5.4 (critical entity encoding bypass + DoS)
- serialize-javascript: 6.0.2 → 7.0.4 (high RCE via RegExp/Date)
- minimatch: 3.1.2 → 3.1.5, 5.1.6 → 5.1.9, 9.0.5 → 9.0.9 (high ReDoS)
- ajv: 6.12.6 → 6.14.0, 8.17.1 → 8.18.0 (medium ReDoS with $data option)
- qs: 6.14.1 → 6.15.0 (low arrayLimit bypass DoS)
- rollup: 4.57.x → 4.59.0 in ai-sdk and openclaw integrations (high path traversal)

Python (uv.lock / pyproject.toml):
- cryptography: 46.0.3 → 46.0.5 (high subgroup attack on SECT curves)
- pillow: 12.0.0 → 12.1.1 (high out-of-bounds write in PSD loading)
- langchain-core: 1.2.7 → 1.2.17 (low SSRF in ChatOpenAI token counting)
- langsmith: 0.4.42 → 0.7.11 (medium SSRF via tracing header injection)
- protobuf: 6.33.1 → 6.33.5 (high JSON recursion depth bypass)

Rust (Cargo.lock):
- bytes: 1.11.0 → 1.11.1 in hindsight-clients/rust (medium integer overflow)

Remaining unfixable: diskcache <= 5.6.3 (no patched version available)

* fix: remove over-broad schema-utils ajv override that broke docs build

The 'schema-utils': {'ajv': '^8.18.0'} override was forcing [email protected]
(used by url-loader/file-loader with [email protected]) to use [email protected].0.
In 8.18.0, internal property _formats was renamed to formats, breaking
[email protected]'s _formatLimit.js which accesses ajv._formats.date.

Removing the broad override: [email protected] (root level) already has
[email protected].0 in its nested install from the prior npm update, while
[email protected] correctly falls back to the hoisted root [email protected].0.
2026-03-04 13:14:50 +01:00
Nicolò Boschi edf60e0f3c ci: add linux-arm64 binary to release and CI (#484)
Add aarch64-unknown-linux-gnu build using native ubuntu-24.04-arm
GitHub-hosted runner, avoiding cross-compilation entirely.

- release.yml: add hindsight-linux-arm64 matrix entry
- test.yml: add build-rust-cli-arm64 job to verify compilation on PRs

Closes #483
2026-03-04 09:54:54 +01:00
Ben 719e79a4d9 doc: fix LiteLLM blog — OPINION → OBSERVATION, update title (#482)
* doc: fix OPINION → OBSERVATION and update blog title

* doc: update title to final version
2026-03-03 14:47:44 -05:00
BenandClaude Opus 4.6 3857a30491 blog: add LiteLLM persistent memory post (#481)
* Add LiteLLM persistent memory blog post

* doc: add blog image for LiteLLM post

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-03 14:22:42 -05:00
Nicolò Boschi 3d87ef5cee doc: update cookbook (#479)
* doc: update cookbook

* fix(cookbook): preserve tag keys during sync, strip local .md links

- Fix extract_tags_from_readme/notebook to return dict[str,str] preserving
  sdk/topic keys instead of bare values, preventing topics like
  "Customer Service" from being misclassified as SDK
- Add strip_local_md_links() to remove relative .md references that
  would cause broken link errors in Docusaurus build

* ci: run test-doc-examples independently without waiting for test-rust-cli

Build the CLI directly in the job instead of downloading the artifact,
so test-doc-examples can start at the beginning in parallel with all other jobs.
2026-03-03 18:46:59 +01:00
Nicolò Boschi 5c3d3274d7 docs: 0.4.15 release blog post and changelog (#477)
* docs: add 0.4.15 release blog post and changelog

* docs: update 0.4.15 blog cover image
2026-03-03 15:52:56 +01:00
Nicolò Boschi 144e4c49d1 Release v0.4.15
- Update version to 0.4.15 in all components
- Regenerate OpenAPI spec and client SDKs
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm, hindsight-crewai, hindsight-pydantic-ai, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- OpenClaw integration: hindsight-integrations/openclaw
- AI SDK integration: hindsight-integrations/ai-sdk
- Chat SDK integration: hindsight-integrations/chat
- Helm chart
- Sync documentation to version-0.4
2026-03-03 15:03:42 +01:00
Nicolò Boschi 861295dd7c refactor: replace set_gemini_safety_settings() with LLMProvider.with_config() (#474)
* refactor: replace set_gemini_safety_settings() with LLMProvider.with_config()

Removes the fragile ContextVar-setter pattern where callers had to remember
to call set_gemini_safety_settings() at every operation entry point.

Instead, LLMProvider.with_config(resolved_config) returns a
ConfiguredLLMProvider wrapper that:
- injects per-bank settings (Gemini safety settings) on every call via
  token-based ContextVar set/reset — properly scoped, no leakage
- proxies all attribute access to the underlying provider via __getattr__
- requires zero changes to LLMInterface or any provider implementations

Call sites (retain, reflect, consolidation) now pass
llm_config.with_config(resolved_config) to sub-components instead of
setting a global context var and hoping nothing else runs in between.
This pattern also composes naturally with a future per-bank provider
factory: callers always receive something with a .call() method.

* fix: pass messages/tools as kwargs in ConfiguredLLMProvider to preserve class-level patch compatibility
2026-03-03 15:00:32 +01:00
Nicolò Boschi 15f4b8769b fix(ts-sdk): send null instead of undefined when includeEntities is false (#476)
* fix(ts-sdk): send null instead of undefined when includeEntities is false

When `includeEntities: false` was passed, the client serialized `entities`
as `undefined`, which is stripped from JSON. The API then applied its
default (`EntityIncludeOptions()` — enabled), silently ignoring the flag.

Fix: send `null` explicitly when `includeEntities === false` so the API
correctly interprets it as "disable entities".

chunks and source_facts are unaffected since their API defaults are null
(disabled), so omitting them from JSON produces the correct behaviour.

Also adds integration tests covering all three states of includeEntities.

* fix(ts-sdk): use toBeFalsy for null entity check in test
2026-03-03 14:54:48 +01:00
Nicolò Boschi 61bf428ba9 perf: fetch all recall chunks in a single query instead of batched while-loop (#475)
Replace the multi-round-trip while-loop in step 5.5 of recall_async with a
single WHERE chunk_id = ANY($1) query covering all candidate chunk IDs.
Token-budget accounting happens in Python after the single fetch.

Measured on a 97K-unit / 98M-link bank (budget=HIGH, include_chunks,
include_entities):
  p50:  1.209s → 0.611s  (−49%)
  mean: 1.534s → 0.772s  (−50%)
  p95:  3.366s → 2.316s  (−31%)

Also update recall_perf.py benchmark to use Budget.HIGH, include_chunks,
include_entities, and a realistic mixed fact_type distribution.
2026-03-03 14:52:48 +01:00
Nicolò Boschi 73ef99e7b1 feat: add configurable Gemini/Vertex AI safety settings (#473)
Adds per-bank configurable safety settings for Gemini/Vertex AI:
- New `HINDSIGHT_API_LLM_GEMINI_SAFETY_SETTINGS` env var (JSON array)
- Hierarchical config field so banks can override via Config API
- ContextVar pattern for zero-signature-change per-request override
- All 6 thresholds supported: UNSPECIFIED, OFF, BLOCK_NONE, BLOCK_LOW_AND_ABOVE, BLOCK_MEDIUM_AND_ABOVE, BLOCK_ONLY_HIGH
- UI: Models > Gemini/Vertex AI section with per-category threshold selectors and link to Google docs
- Graceful handling when bank_config_api feature is disabled
- 12 new tests covering config parsing, GeminiLLM behaviour, and context var override
2026-03-03 13:48:29 +01:00
Nicolò Boschi 7942f181c2 fix(performance): improve recall and retain performance on large banks (#469) 2026-03-03 13:35:22 +01:00
Anton EvseevandClaude Opus 4.6 5aff8e0c70 refactor(openclaw): replace console.log with debug() helper gated by plugin config (#456)
Replace ~73 console.log calls with a debug() helper that is silent by default.
Debug output is now controlled via plugin config param (debug: true) instead of
environment variables, making it easier for users to configure.

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-03 11:05:04 +01:00
DK09876andClaude Opus 4.6 e407f4bc55 feat: add extension hooks for root routing and error headers (#470)
* feat: add OAuth extension hooks for MCP authentication

Add extension points in core that allow cloud extensions to support
OAuth 2.1 (RFC 9728 / RFC 7591) for MCP server authentication:

- HttpExtension.get_root_router() for well-known endpoint mounting
- AuthenticationError.headers for WWW-Authenticate propagation
- MCP middleware forwards auth error headers to clients

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* docs: document get_root_router and AuthenticationError.headers

Add documentation for the new extension points introduced in the
OAuth extension hooks commit.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* Remove OAuth-specific wording from extension docs

Make the AuthenticationError headers example generic instead of
OAuth-specific, since these are general-purpose extension hooks.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-03 10:50:03 +01:00
Ben 8138fa9002 blog: add CrewAI persistent memory post (#471)
* Add CrewAI persistent memory blog post

* Update blog: add image, remove full example and alternatives sections

* Add CrewAI blog hero image
2026-03-02 16:00:27 -05:00
Nicolò Boschi 1d70abfe85 feat: add tags filtering and q description fix for list documents API (#468)
* feat: add Pydantic AI integration to CI, release pipeline, and docs

- Add test-pydantic-ai-integration job to CI (test.yml)
- Add build, publish, and artifact steps to release workflow (release.yml)
- Add hindsight-integrations/pydantic-ai to release.sh version bumping
- Add Pydantic AI documentation page (sdks/integrations/pydantic-ai.md)
- Add Pydantic AI entry to sidebar with icon

* docs: remove Requirements section from pydantic-ai integration page

* feat: add tags filtering and fix offset pagination docs for list documents API

- Add `tags` and `tags_match` query params to GET /banks/{bank_id}/documents
- Supports any, all, any_strict, all_strict matching modes (default: any_strict)
- Fix `q` param description — it's a case-insensitive substring match on document ID only
- Add tests for offset pagination and all tags_match modes
- Regenerate OpenAPI spec and Python/TypeScript/Go clients
- Document the new filtering options in docs/developer/api/documents.mdx

* fix(cli): pass new tags/tags_match args to list_documents
2026-03-02 17:03:16 +01:00
Nicolò Boschi ecf609c8aa feat: add Pydantic AI integration to CI, release pipeline, and docs (#467)
* feat: add Pydantic AI integration to CI, release pipeline, and docs

- Add test-pydantic-ai-integration job to CI (test.yml)
- Add build, publish, and artifact steps to release workflow (release.yml)
- Add hindsight-integrations/pydantic-ai to release.sh version bumping
- Add Pydantic AI documentation page (sdks/integrations/pydantic-ai.md)
- Add Pydantic AI entry to sidebar with icon

* docs: remove Requirements section from pydantic-ai integration page
2026-03-02 15:40:37 +01:00
BenandClaude Opus 4.6 cab5a40f3a feat: add Pydantic AI integration for persistent agent memory (#441)
* feat: add Pydantic AI integration for persistent agent memory

Adds hindsight-pydantic-ai package providing Hindsight-backed memory
tools for Pydantic AI agents. Since Pydantic AI is async-native, tools
use the hindsight-client async API directly (no thread-pool compat layer).

- create_hindsight_tools(): factory returning retain/recall/reflect Tool instances
- memory_instructions(): auto-injects relevant memories via Agent instructions
- Global configure()/get_config()/reset_config() following existing integration pattern

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* doc: add README for Pydantic AI integration

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-02 15:14:00 +01:00
Nicolò Boschi ab70da1ead docs: entities vs tags vs metadata (#466)
* docs: move entity labels detail to memory-banks, simplify retain overview

* docs: move entity labels blurb under entity-recognition section in retain

* docs: update metadata filtering FAQ to cover entity graph retrieval and entity labels tag option

* docs: enable TOC and fix missing separators in FAQ

* docs: add benchmarks leaderboard screenshot and link to models page

* docs: add 'Which model should I use?' FAQ entry with leaderboard screenshot

* docs: fix leaderboard description to cover retain, reflect, and observations
2026-03-02 14:47:40 +01:00
Nicolò Boschi 9b96becc5c feat: entity labels — optional, free_values, multi_value, UI polish (#450)
* feat: entity labels

* feat: entity labels — optional, free_values, multi_value, UI polish

Completes the entity labels system:

**Schema & extraction**
- Dynamic Pydantic Labels model per fact: each group becomes a typed
  field (Literal | None, list[Literal], str | None, or list[str])
- `optional: bool` flag per group — non-optional enum fields appear in
  JSON schema required array so structured-output providers enforce them
- `free_values: bool` flag per group — accepts any LLM-generated string
  instead of a predefined enum; example values shown as hints in prompt
- New `is_label_entity()` helper for labels-only mode filtering that
  handles both enum lookup and free_values key-prefix matching
- Sentinel rejection: "None"/"null"/"n/a" strings dropped in post-processing

**BM25 / dense retrieval**
- `text_signals` column on memory_units: entity names + date tokens for
  enriched BM25 indexing without polluting stored fact text
- Dense embedding includes occurred_end when it differs from occurred_start
- Alembic migration z1u2v3w4x5y6 (merge revision fixing two heads)

**UI (bank-config-view)**
- Shadcn Switch replaces custom Toggle for both entity-labels and observations
- Shadcn Checkbox for multi/optional/free_values per group
- Input heights bumped to h-8 throughout the editor
- "Label Groups" → "Entity Labels", "Free-form entities" → "Entities"
- Free-text groups show "Example hints" banner in values section

**Tests (45 unit + 3 LLM integration)**
- build_labels_model: single, multi, mixed, free_values optional/required/multi
- is_label_entity: enum match, free_values prefix match, no false positives
- Post-processing: null/absent/string-None/free_values/sentinels/multi-value
- Schema: labels in required, structured object, no labels when unconfigured
- LLM integration: single-value enum, multi-value enum, free_values retain

**Docs**
- retain.md: new Entity Labels section covering groups, flags, examples
- configuration.md: retain_free_form_entities env var + entity_labels note

* fix(tests): update hierarchical fields count for entity_labels additions

entity_labels and retain_free_form_entities are hierarchical fields,
bumping the expected count from 11 to 13.

* fix(migration): rename text_signals revision to avoid collision with main

Main branch claimed z1u2v3w4x5y6 for observation_scopes. Rename our
text_signals migration to a2b3c4d5e6f7, chaining after z1u2v3w4x5y6.

* refactor(entity-labels): simplify free_values — always str|None, no multi

- free_values groups always produce str | None (multi_value and optional
  flags are ignored for free text groups — always optional, never multi)
- Prompt section for free_values groups shows only key + description,
  no values list (users put examples in the description instead)
- UI: section title "Entities", toggle "Free Form Entities", replace
  per-group checkboxes with a type dropdown (Enum / Free text); only
  show multi checkbox and values list when type is Enum
- Update tests to reflect new behaviour

* refactor(entity-labels): replace free_values/multi_value booleans with type field

- LabelGroup now uses type: "value" | "multi-values" | "text" instead of
  free_values/multi_value boolean pair
- Backward-compat migration converts legacy dicts automatically
- Rename retain_free_form_entities → entities_allow_free_form throughout
- Update UI dropdown to show Single value / Multi-values / Free text
- Remove separate multi checkbox (captured by type selection)
- Update docs examples and configuration.md
- Update all tests to use new field names

* fix(migration): backfill observation_scopes column for DBs with swapped z1u2v3w4x5y6

Local DBs that had z1u2v3w4x5y6 applied when it referred to the old
text_signals migration (before it was renamed to a2b3c4d5e6f7) won't have
observation_scopes in their memory_units table. This migration adds the
column with IF NOT EXISTS so it's a no-op on clean installs.

* feat(entity-labels): add tag field to auto-populate memory unit tags from labels

When a LabelGroup has tag=True, extracted key:value entities for that group
are automatically written to the memory unit's tags array. This lets entity
labels double as tags, enabling immediate filtering via the existing
tags/tags_match API params with no extra infrastructure.

- Add tag: bool = False to LabelGroup
- _inject_label_tags() helper called in both sync and batch extraction paths
- UI: add Tag checkbox per label group row
- Docs: document the new tag field
- Tests: 4 new unit tests covering all tag injection paths

* style: ruff format migration file

* fix(migration): fix multiple alembic heads after rebase — point text_signals after nullable_event_date

* fix(clients): update timestamp field to use Timestamp wrapper type after timestamp=unset feature

* style: ruff format agent.py

* fix(docs): update Go quickstart example to use NullableTimestamp for timestamp field
2026-03-02 13:05:25 +01:00
425 changed files with 26365 additions and 8043 deletions
+16
View File
@@ -50,6 +50,10 @@ jobs:
working-directory: ./hindsight-integrations/crewai
run: uv build --out-dir dist
- name: Build hindsight-pydantic-ai
working-directory: ./hindsight-integrations/pydantic-ai
run: uv build --out-dir dist
# Publish in order (client and api first, then hindsight-all which depends on them)
- name: Publish hindsight-client to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
@@ -87,6 +91,12 @@ jobs:
packages-dir: ./hindsight-integrations/crewai/dist
skip-existing: true
- name: Publish hindsight-pydantic-ai to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/pydantic-ai/dist
skip-existing: true
# Upload artifacts for GitHub release
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -99,6 +109,7 @@ jobs:
hindsight-integrations/litellm/dist/*
hindsight-embed/dist/*
hindsight-integrations/crewai/dist/*
hindsight-integrations/pydantic-ai/dist/*
retention-days: 1
release-typescript-client:
@@ -376,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
@@ -629,6 +644,7 @@ jobs:
cp artifacts/python-packages/hindsight-api/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-integrations/litellm/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-integrations/pydantic-ai/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
# TypeScript client
cp artifacts/typescript-client/*.tgz release-assets/ || true
+69 -8
View File
@@ -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:
@@ -1162,6 +1186,35 @@ jobs:
working-directory: ./hindsight-integrations/litellm
run: uv run pytest tests -v
test-pydantic-ai-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Build pydantic-ai integration
working-directory: ./hindsight-integrations/pydantic-ai
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/pydantic-ai
run: uv sync --frozen
- name: Run tests
working-directory: ./hindsight-integrations/pydantic-ai
run: uv run pytest tests -v
test-embed:
runs-on: ubuntu-latest
env:
@@ -1268,7 +1321,6 @@ jobs:
test-doc-examples:
runs-on: ubuntu-latest
needs: test-rust-cli
env:
HINDSIGHT_API_LLM_PROVIDER: vertexai
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
@@ -1286,14 +1338,23 @@ 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
uses: dtolnay/rust-toolchain@stable
- name: Make CLI executable
run: chmod +x /usr/local/bin/hindsight
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
hindsight-cli/target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build 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
+2 -2
View File
@@ -97,7 +97,7 @@ fi
if [ "$ENABLE_CP" = "true" ]; then
echo "🎛️ Starting Control Plane..."
cd /app/control-plane
PORT=9999 node server.js &
PORT="${HINDSIGHT_CP_PORT:-9999}" node server.js &
CP_PID=$!
PIDS+=($CP_PID)
else
@@ -110,7 +110,7 @@ echo "✅ Hindsight is running!"
echo ""
echo "📍 Access:"
if [ "$ENABLE_CP" = "true" ]; then
echo " Control Plane: http://localhost:9999"
echo " Control Plane: http://localhost:${HINDSIGHT_CP_PORT:-9999}"
fi
if [ "$ENABLE_API" = "true" ]; then
echo " API: http://localhost:8888"
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.4.14
appVersion: "0.4.14"
version: 0.4.16
appVersion: "0.4.16"
keywords:
- ai
- memory
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.4.14"
__version__ = "0.4.16"
@@ -0,0 +1,88 @@
"""Add text_signals column to memory_units for enriched BM25 indexing.
text_signals stores a denormalized space-separated string of entity names
(and future signals) to improve full-text search recall without polluting
the stored fact text.
- vchord: text_signals included in tokenize() at insert time
- native: search_vector GENERATED column regenerated to include text_signals
- pg_textsearch: no change (index only supports a single base column)
Revision ID: a2b3c4d5e6f7
Revises: z1u2v3w4x5y6
Create Date: 2026-02-28
"""
import os
from collections.abc import Sequence
from alembic import context, op
revision: str = "a2b3c4d5e6f7"
down_revision: str | Sequence[str] | None = "aa2b3c4d5e6f"
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 _detect_text_search_extension() -> str:
return os.getenv("HINDSIGHT_API_TEXT_SEARCH_EXTENSION", "native").lower()
def upgrade() -> None:
schema = _get_schema_prefix()
table = f"{schema}memory_units"
text_search_ext = _detect_text_search_extension()
# Add text_signals column (nullable TEXT, populated at retain time)
op.execute(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS text_signals TEXT")
if text_search_ext == "native":
# Native PostgreSQL: drop and recreate the GENERATED tsvector column to include text_signals
op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS search_vector")
op.execute(f"""
ALTER TABLE {table}
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english',
COALESCE(text, '') || ' ' ||
COALESCE(context, '') || ' ' ||
COALESCE(text_signals, '')
)
) STORED
""")
# Recreate GIN index (was dropped with the column)
op.execute(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_text_search
ON {table} USING gin(search_vector)
""")
# vchord: tokenize() call in fact_storage.py is updated to include text_signals at insert time
# pg_textsearch: no change — index operates on the base `text` column only
def downgrade() -> None:
schema = _get_schema_prefix()
table = f"{schema}memory_units"
text_search_ext = _detect_text_search_extension()
if text_search_ext == "native":
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_text_search")
op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS search_vector")
op.execute(f"""
ALTER TABLE {table}
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english', COALESCE(text, '') || ' ' || COALESCE(context, ''))
) STORED
""")
op.execute(f"""
CREATE INDEX idx_memory_units_text_search
ON {table} USING gin(search_vector)
""")
op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS text_signals")
@@ -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")
@@ -0,0 +1,68 @@
"""Add partial indexes on memory_units temporal date fields for fast temporal retrieval
Revision ID: b3c4d5e6f7g8
Revises: c1a2b3d4e5f6
Create Date: 2026-03-02
The temporal retrieval entry-point query filters memory_units by occurred_start,
occurred_end, and mentioned_at using OR conditions. Without dedicated indexes the
planner falls back to a sequential scan of all bank rows after applying the
(bank_id, fact_type) index, then re-checks each date field.
These three partial indexes give the planner bitmap-index scan options for the
three most common date predicates, dramatically reducing the row set before any
embedding computation is required.
All indexes are created CONCURRENTLY so the migration does not block writes on
memory_units during production deployments. CONCURRENTLY requires running outside
a transaction block; see migrations.py for how this is handled safely.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "b3c4d5e6f7g8"
down_revision: str | Sequence[str] | None = "c1a2b3d4e5f6"
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()
# Partial index on occurred_start (covers "occurred_start BETWEEN $4 AND $5")
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_units_bank_occurred_start "
f"ON {schema}memory_units(bank_id, fact_type, occurred_start) "
f"WHERE occurred_start IS NOT NULL"
)
# Partial index on occurred_end (covers "occurred_end BETWEEN $4 AND $5")
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_units_bank_occurred_end "
f"ON {schema}memory_units(bank_id, fact_type, occurred_end) "
f"WHERE occurred_end IS NOT NULL"
)
# Partial index on mentioned_at (covers "mentioned_at BETWEEN $4 AND $5")
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_units_bank_mentioned_at "
f"ON {schema}memory_units(bank_id, fact_type, mentioned_at) "
f"WHERE mentioned_at 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_bank_mentioned_at")
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_units_bank_occurred_end")
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_units_bank_occurred_start")
@@ -0,0 +1,34 @@
"""Backfill observation_scopes column if missing.
This migration ensures observation_scopes exists even on databases that had
revision z1u2v3w4x5y6 applied when it referred to the old text_signals migration
(before it was renamed to a2b3c4d5e6f7). The ADD COLUMN IF NOT EXISTS makes this
a no-op on databases that already have the column.
Revision ID: b4c5d6e7f8a9
Revises: a2b3c4d5e6f7
Create Date: 2026-03-02
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "b4c5d6e7f8a9"
down_revision: str | Sequence[str] | None = "a2b3c4d5e6f7"
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}memory_units ADD COLUMN IF NOT EXISTS observation_scopes JSONB")
def downgrade() -> None:
pass # intentionally no-op — safe to leave the column in place
@@ -0,0 +1,46 @@
"""Enable pg_trgm extension and add GIN trigram index on entities.canonical_name
Revision ID: c1a2b3d4e5f6
Revises: b4c5d6e7f8a9
Create Date: 2026-03-02
Index is created CONCURRENTLY so the migration does not block writes on entities
during production deployments. CONCURRENTLY requires running outside a transaction
block; see migrations.py for how this is handled safely.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "c1a2b3d4e5f6"
down_revision: str | Sequence[str] | None = "b4c5d6e7f8a9"
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:
# pg_trgm ships with every standard PostgreSQL installation as a contrib module.
# It enables fast similarity lookups via GIN indexes, used for entity name matching.
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
schema = _get_schema_prefix()
# GIN index on canonical_name enables sub-millisecond trigram similarity queries
# (% operator, similarity()) instead of full-table scans across all bank entities.
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS entities_canonical_name_trgm_idx "
f"ON {schema}entities USING GIN (canonical_name gin_trgm_ops)"
)
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}entities_canonical_name_trgm_idx")
# Note: not dropping pg_trgm extension as other indexes may depend on it
@@ -0,0 +1,83 @@
"""Add covering and composite indexes to speed up link expansion graph retrieval.
Two indexes target the two bottlenecks identified by EXPLAIN ANALYZE on a 17M-row
memory_links table:
1. idx_memory_links_to_type_weight (to_unit_id, link_type, weight DESC)
The semantic incoming direction — finding facts that consider seeds as their
nearest neighbour — currently hits an expensive BitmapAnd of two separate
bitmap scans (to_unit_id bitmap ∩ link_type bitmap). A composite index
on (to_unit_id, link_type) turns this into a single index scan and reduces
latency from ~36 ms to < 5 ms per query.
2. idx_memory_links_entity_covering (from_unit_id) INCLUDE (to_unit_id, entity_id)
WHERE link_type = 'entity'
The entity co-occurrence expansion uses COUNT(DISTINCT ml.entity_id) and
joins on ml.to_unit_id. Without a covering index the planner must read
~2 500 heap pages to fetch entity_id and to_unit_id after the bitmap index
scan, adding ~230 ms of random I/O. INCLUDE adds those two columns to the
index leaf pages so the entire query can be served from the index (index-only
scan), eliminating the heap reads entirely.
Partial index (WHERE link_type = 'entity') keeps index size ~40 % smaller.
Both indexes are created with CONCURRENTLY so the migration does not block
concurrent reads or writes on memory_links. CONCURRENTLY requires running
outside a transaction block, so the migration emits an explicit COMMIT before
each statement and uses IF NOT EXISTS for idempotency.
Revision ID: d2e3f4a5b6c7
Revises: b3c4d5e6f7g8
Create Date: 2026-03-02
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "d2e3f4a5b6c7"
down_revision: str | Sequence[str] | None = "b3c4d5e6f7g8"
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, then issue each CONCURRENTLY
# statement in its own implicit autocommit transaction.
# IF NOT EXISTS makes each statement idempotent if the migration is retried.
# Index for the semantic *incoming* direction in link_expansion_retrieval.py.
# Replaces the BitmapAnd of idx_memory_links_to_unit ∩ idx_memory_links_link_type
# with a single composite index scan.
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_links_to_type_weight "
f"ON {schema}memory_links(to_unit_id, link_type, weight DESC)"
)
# Covering index for entity co-occurrence expansion.
# Enables an index-only scan: entity_id and to_unit_id are read from the
# index leaf pages instead of the heap, eliminating ~2 500 random heap-page
# reads per expansion query.
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_links_entity_covering "
f"ON {schema}memory_links(from_unit_id) "
f"INCLUDE (to_unit_id, entity_id) "
f"WHERE link_type = 'entity'"
)
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_links_entity_covering")
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_links_to_type_weight")
@@ -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")
+485 -147
View File
@@ -72,7 +72,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.memory_engine import Budget, _current_schema, _get_tiktoken_encoding, fq_table
from hindsight_api.engine.reflect.observations import Observation
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MemoryFact, TokenUsage
from hindsight_api.engine.search.tags import TagsMatch
@@ -1627,6 +1627,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 +1843,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,
@@ -1830,6 +1946,12 @@ def create_app(
app.include_router(extension_router, prefix="/ext", tags=["Extension"])
logging.info("HTTP extension router mounted at /ext/")
# Mount root router if provided (for well-known endpoints, etc.)
root_router = http_extension.get_root_router(memory)
if root_router:
app.include_router(root_router)
logging.info("HTTP extension root router mounted")
return app
@@ -2379,148 +2501,32 @@ def _register_routes(app: FastAPI):
):
"""Get statistics about memory nodes and links for a memory bank."""
try:
# Authenticate and set tenant schema
await app.state.memory._authenticate_tenant(request_context)
if app.state.memory._operation_validator:
from hindsight_api.extensions import BankReadContext
ctx = BankReadContext(bank_id=bank_id, operation="get_bank_stats", request_context=request_context)
await app.state.memory._validate_operation(
app.state.memory._operation_validator.validate_bank_read(ctx)
)
pool = await app.state.memory._get_pool()
async with acquire_with_retry(pool) as conn:
# Get node counts by fact_type
node_stats = await conn.fetch(
f"""
SELECT fact_type, COUNT(*) as count
FROM {fq_table("memory_units")}
WHERE bank_id = $1
GROUP BY fact_type
""",
bank_id,
)
# Get link counts by link_type
link_stats = await conn.fetch(
f"""
SELECT ml.link_type, COUNT(*) as count
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
WHERE mu.bank_id = $1
GROUP BY ml.link_type
""",
bank_id,
)
# Get link counts by fact_type (from nodes)
link_fact_type_stats = await conn.fetch(
f"""
SELECT mu.fact_type, COUNT(*) as count
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
WHERE mu.bank_id = $1
GROUP BY mu.fact_type
""",
bank_id,
)
# Get link counts by fact_type AND link_type
link_breakdown_stats = await conn.fetch(
f"""
SELECT mu.fact_type, ml.link_type, COUNT(*) as count
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
WHERE mu.bank_id = $1
GROUP BY mu.fact_type, ml.link_type
""",
bank_id,
)
# Get pending and failed operations counts
ops_stats = await conn.fetch(
f"""
SELECT status, COUNT(*) as count
FROM {fq_table("async_operations")}
WHERE bank_id = $1
GROUP BY status
""",
bank_id,
)
ops_by_status = {row["status"]: row["count"] for row in ops_stats}
pending_operations = ops_by_status.get("pending", 0)
failed_operations = ops_by_status.get("failed", 0)
# Get document count
doc_count_result = await conn.fetchrow(
f"""
SELECT COUNT(*) as count
FROM {fq_table("documents")}
WHERE bank_id = $1
""",
bank_id,
)
total_documents = doc_count_result["count"] if doc_count_result else 0
# Get consolidation stats from memory-level tracking
consolidation_stats = await conn.fetchrow(
f"""
SELECT
MAX(consolidated_at) as last_consolidated_at,
COUNT(*) FILTER (WHERE consolidated_at IS NULL AND fact_type IN ('experience', 'world')) as pending
FROM {fq_table("memory_units")}
WHERE bank_id = $1
""",
bank_id,
)
last_consolidated_at = consolidation_stats["last_consolidated_at"] if consolidation_stats else None
pending_consolidation = consolidation_stats["pending"] if consolidation_stats else 0
# Count total observations (consolidated knowledge)
observation_count_result = await conn.fetchrow(
f"""
SELECT COUNT(*) as count
FROM {fq_table("memory_units")}
WHERE bank_id = $1 AND fact_type = 'observation'
""",
bank_id,
)
total_observations = observation_count_result["count"] if observation_count_result else 0
# Format results
nodes_by_type = {row["fact_type"]: row["count"] for row in node_stats}
links_by_type = {row["link_type"]: row["count"] for row in link_stats}
links_by_fact_type = {row["fact_type"]: row["count"] for row in link_fact_type_stats}
# Build detailed breakdown: {fact_type: {link_type: count}}
links_breakdown = {}
for row in link_breakdown_stats:
fact_type = row["fact_type"]
link_type = row["link_type"]
count = row["count"]
if fact_type not in links_breakdown:
links_breakdown[fact_type] = {}
links_breakdown[fact_type][link_type] = count
total_nodes = sum(nodes_by_type.values())
total_links = sum(links_by_type.values())
return BankStatsResponse(
bank_id=bank_id,
total_nodes=total_nodes,
total_links=total_links,
total_documents=total_documents,
nodes_by_fact_type=nodes_by_type,
links_by_link_type=links_by_type,
links_by_fact_type=links_by_fact_type,
links_breakdown=links_breakdown,
pending_operations=pending_operations,
failed_operations=failed_operations,
last_consolidated_at=(last_consolidated_at.isoformat() if last_consolidated_at else None),
pending_consolidation=pending_consolidation,
total_observations=total_observations,
)
stats = await app.state.memory.get_bank_stats(bank_id, request_context=request_context)
nodes_by_type = stats["node_counts"]
links_by_type = stats["link_counts"]
links_by_fact_type = stats["link_counts_by_fact_type"]
links_breakdown: dict[str, dict[str, int]] = {}
for row in stats["link_breakdown"]:
ft = row["fact_type"]
if ft not in links_breakdown:
links_breakdown[ft] = {}
links_breakdown[ft][row["link_type"]] = row["count"]
ops = stats["operations"]
return BankStatsResponse(
bank_id=bank_id,
total_nodes=sum(nodes_by_type.values()),
total_links=sum(links_by_type.values()),
total_documents=stats["total_documents"],
nodes_by_fact_type=nodes_by_type,
links_by_link_type=links_by_type,
links_by_fact_type=links_by_fact_type,
links_breakdown=links_breakdown,
pending_operations=ops.get("pending", 0),
failed_operations=ops.get("failed", 0),
last_consolidated_at=stats["last_consolidated_at"],
pending_consolidation=stats["pending_consolidation"],
total_observations=stats["total_observations"],
)
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
@@ -3064,7 +3070,13 @@ def _register_routes(app: FastAPI):
)
async def api_list_documents(
bank_id: str,
q: str | None = None,
q: str | None = Query(
None, description="Case-insensitive substring filter on document ID (e.g. 'report' matches 'report-2024')"
),
tags: list[str] | None = Query(None, description="Filter documents by tags"),
tags_match: str = Query(
"any_strict", description="How to match tags: 'any', 'all', 'any_strict', 'all_strict'"
),
limit: int = 100,
offset: int = 0,
request_context: RequestContext = Depends(get_request_context),
@@ -3074,13 +3086,21 @@ def _register_routes(app: FastAPI):
Args:
bank_id: Memory Bank ID (from path)
q: Search query (searches document ID and metadata)
q: Case-insensitive substring filter on document ID
tags: Filter documents by tags
tags_match: How to match tags (any, all, any_strict, all_strict)
limit: Maximum number of results (default: 100)
offset: Offset for pagination (default: 0)
"""
try:
data = await app.state.memory.list_documents(
bank_id=bank_id, search_query=q, limit=limit, offset=offset, request_context=request_context
bank_id=bank_id,
search_query=q,
tags=tags,
tags_match=tags_match,
limit=limit,
offset=offset,
request_context=request_context,
)
return data
except OperationValidationError as e:
@@ -3852,6 +3872,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,
@@ -3944,6 +4276,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(
+6 -3
View File
@@ -331,7 +331,7 @@ class MCPMiddleware:
auth_tenant_id = auth_context.tenant_id
auth_api_key_id = auth_context.api_key_id
except AuthenticationError as e:
await self._send_error(send, 401, str(e))
await self._send_error(send, 401, str(e), extra_headers=e.headers)
return
# Set schema from tenant context so downstream DB queries use the correct schema
@@ -413,14 +413,17 @@ class MCPMiddleware:
if schema_token is not None:
_current_schema.reset(schema_token)
async def _send_error(self, send, status: int, message: str):
async def _send_error(self, send, status: int, message: str, extra_headers: dict[str, str] | None = None):
"""Send an error response."""
body = json.dumps({"error": message}).encode()
headers = [(b"content-type", b"application/json")]
for key, value in (extra_headers or {}).items():
headers.append((key.encode(), value.encode()))
await send(
{
"type": "http.response.start",
"status": status,
"headers": [(b"content-type", b"application/json")],
"headers": headers,
}
)
await send(
+61
View File
@@ -252,6 +252,9 @@ ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
ENV_LLM_VERTEXAI_REGION = "HINDSIGHT_API_LLM_VERTEXAI_REGION"
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY"
# Gemini safety settings
ENV_LLM_GEMINI_SAFETY_SETTINGS = "HINDSIGHT_API_LLM_GEMINI_SAFETY_SETTINGS"
# Retain settings
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
@@ -260,6 +263,7 @@ ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
ENV_RETAIN_MISSION = "HINDSIGHT_API_RETAIN_MISSION"
ENV_RETAIN_CUSTOM_INSTRUCTIONS = "HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"
ENV_RETAIN_BATCH_TOKENS = "HINDSIGHT_API_RETAIN_BATCH_TOKENS"
ENV_RETAIN_ENTITY_LOOKUP = "HINDSIGHT_API_RETAIN_ENTITY_LOOKUP"
ENV_RETAIN_BATCH_ENABLED = "HINDSIGHT_API_RETAIN_BATCH_ENABLED"
ENV_RETAIN_BATCH_POLL_INTERVAL_SECONDS = "HINDSIGHT_API_RETAIN_BATCH_POLL_INTERVAL_SECONDS"
@@ -290,6 +294,12 @@ ENV_CONSOLIDATION_LLM_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_LLM_BATCH_SIZE"
ENV_CONSOLIDATION_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS"
ENV_OBSERVATIONS_MISSION = "HINDSIGHT_API_OBSERVATIONS_MISSION"
# 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"
ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
@@ -352,6 +362,9 @@ DEFAULT_LLM_VERTEXAI_PROJECT_ID = None # Required for Vertex AI
DEFAULT_LLM_VERTEXAI_REGION = "us-central1"
DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = None # Optional, uses ADC if not set
# Gemini safety settings defaults
DEFAULT_LLM_GEMINI_SAFETY_SETTINGS = None # None = use Gemini default safety settings
DEFAULT_EMBEDDINGS_PROVIDER = "local"
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS)
@@ -416,6 +429,7 @@ RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom") # Allowed extraction
DEFAULT_RETAIN_MISSION = None # Declarative spec of what to retain (injected into any extraction mode)
DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS = None # Custom extraction guidelines (only used when mode="custom")
DEFAULT_RETAIN_BATCH_TOKENS = 10_000 # ~40KB of text # Max chars per sub-batch for async retain auto-splitting
DEFAULT_RETAIN_ENTITY_LOOKUP = "trigram" # "full" or "trigram"
DEFAULT_RETAIN_BATCH_ENABLED = False # Use LLM Batch API for fact extraction (only when async=True)
DEFAULT_RETAIN_BATCH_POLL_INTERVAL_SECONDS = 60 # Batch API polling interval in seconds
@@ -489,6 +503,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.
@@ -565,6 +585,9 @@ class HindsightConfig:
llm_vertexai_region: str
llm_vertexai_service_account_key: str | None
# Gemini safety settings (None = use Gemini defaults; list of dicts with category/threshold)
llm_gemini_safety_settings: list | None
# Per-operation LLM configuration (None = use default LLM config)
retain_llm_provider: str | None
retain_llm_api_key: str | None
@@ -662,6 +685,7 @@ class HindsightConfig:
retain_batch_tokens: int
retain_batch_enabled: bool
retain_batch_poll_interval_seconds: int
retain_entity_lookup: str # "full" or "trigram"
# File storage (static - server-level only)
file_storage_type: str # "native" (PostgreSQL) or "s3" (S3-compatible)
@@ -690,6 +714,13 @@ class HindsightConfig:
consolidation_max_tokens: int
observations_mission: str | None
# Entity labels (controlled vocabulary of key:value classification labels extracted at retain time)
# List of label group dicts: [{key, description, type, optional, values: [{value, description}]}]
entity_labels: list | None
# Whether to extract regular named entities alongside entity labels (default: True)
# When False: only label entities are extracted (or no entities at all if no labels configured)
entities_allow_free_form: bool
# Reflect agent settings
reflect_mission: str | None
@@ -731,6 +762,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
@@ -770,6 +807,9 @@ class HindsightConfig:
"retain_extraction_mode",
"retain_mission",
"retain_custom_instructions",
# Entity labels (controlled vocabulary for entity classification)
"entity_labels",
"entities_allow_free_form",
# Consolidation settings
"enable_observations",
"observations_mission",
@@ -779,6 +819,8 @@ class HindsightConfig:
"disposition_skepticism",
"disposition_literalism",
"disposition_empathy",
# Gemini safety settings (controls content filtering for Gemini/VertexAI providers)
"llm_gemini_safety_settings",
}
@property
@@ -899,6 +941,8 @@ class HindsightConfig:
llm_vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION, DEFAULT_LLM_VERTEXAI_REGION),
llm_vertexai_service_account_key=os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY)
or DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
# Gemini safety settings (JSON-encoded list of {category, threshold} dicts)
llm_gemini_safety_settings=json.loads(os.getenv(ENV_LLM_GEMINI_SAFETY_SETTINGS, "null")),
# Per-operation LLM config (None = use default)
retain_llm_provider=os.getenv(ENV_RETAIN_LLM_PROVIDER) or None,
retain_llm_api_key=os.getenv(ENV_RETAIN_LLM_API_KEY) or None,
@@ -1074,6 +1118,7 @@ class HindsightConfig:
retain_mission=os.getenv(ENV_RETAIN_MISSION) or DEFAULT_RETAIN_MISSION,
retain_custom_instructions=os.getenv(ENV_RETAIN_CUSTOM_INSTRUCTIONS) or DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS,
retain_batch_tokens=int(os.getenv(ENV_RETAIN_BATCH_TOKENS, str(DEFAULT_RETAIN_BATCH_TOKENS))),
retain_entity_lookup=os.getenv(ENV_RETAIN_ENTITY_LOOKUP, DEFAULT_RETAIN_ENTITY_LOOKUP),
retain_batch_enabled=os.getenv(ENV_RETAIN_BATCH_ENABLED, str(DEFAULT_RETAIN_BATCH_ENABLED)).lower()
== "true",
retain_batch_poll_interval_seconds=int(
@@ -1118,6 +1163,8 @@ class HindsightConfig:
os.getenv(ENV_CONSOLIDATION_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_MAX_TOKENS))
),
observations_mission=os.getenv(ENV_OBSERVATIONS_MISSION) or DEFAULT_OBSERVATIONS_MISSION,
entity_labels=None,
entities_allow_free_form=True,
# Database migrations
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
# Database connection pool
@@ -1158,6 +1205,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
@@ -67,6 +67,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."""
@@ -126,6 +162,11 @@ async def run_consolidation_job(
"""
# Resolve bank-specific config with hierarchical overrides
config = await memory_engine._config_resolver.resolve_full_config(bank_id, request_context)
# Build a configured LLM wrapper that applies per-bank settings (e.g. safety settings)
# to every call without leaking across operations.
llm_config = memory_engine._consolidation_llm_config.with_config(config)
perf = ConsolidationPerfLog(bank_id)
max_memories_per_batch = config.consolidation_batch_size
llm_batch_size = max(1, config.consolidation_llm_batch_size)
@@ -175,11 +216,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,
}
@@ -268,13 +310,15 @@ 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,
bank_id=bank_id,
memories=llm_batch,
request_context=request_context,
@@ -282,6 +326,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
@@ -309,15 +354,17 @@ 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,
bank_id=bank_id,
memories=llm_batch,
request_context=request_context,
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",
@@ -507,13 +554,14 @@ async def _trigger_mental_model_refreshes(
async def _process_memory_batch(
conn: "Connection",
memory_engine: "MemoryEngine",
llm_config: Any,
bank_id: str,
memories: list[dict[str, Any]],
request_context: "RequestContext",
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.
@@ -575,7 +623,7 @@ async def _process_memory_batch(
# 3. Single LLM call
t0 = time.time()
llm_result = await _consolidate_batch_with_llm(
memory_engine=memory_engine,
llm_config=llm_config,
memories=memories,
union_observations=union_observations,
union_source_facts=union_source_facts,
@@ -604,17 +652,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:
@@ -631,6 +680,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,
@@ -639,15 +689,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):
@@ -656,6 +707,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]] = []
@@ -672,7 +724,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":
@@ -939,7 +991,7 @@ def _build_observations_for_llm(
async def _consolidate_batch_with_llm(
memory_engine: "MemoryEngine",
llm_config: Any,
memories: list[dict[str, Any]],
union_observations: "list[MemoryFact]",
union_source_facts: "dict[str, MemoryFact]",
@@ -975,7 +1027,7 @@ async def _consolidate_batch_with_llm(
last_exc: Exception | None = None
for attempt in range(1, max_attempts + 1):
try:
response: _ConsolidationBatchResponse = await memory_engine._consolidation_llm_config.call(
response: _ConsolidationBatchResponse = await llm_config.call(
messages=[{"role": "user", "content": prompt}],
response_format=_ConsolidationBatchResponse,
scope="consolidation",
@@ -1021,8 +1073,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 []
@@ -20,6 +20,7 @@ RETRYABLE_EXCEPTIONS = (
asyncpg.exceptions.InterfaceError,
asyncpg.exceptions.ConnectionDoesNotExistError,
asyncpg.exceptions.TooManyConnectionsError,
asyncpg.exceptions.DeadlockDetectedError,
OSError,
ConnectionError,
asyncio.TimeoutError,
@@ -5,6 +5,10 @@ Uses spaCy for entity extraction and implements resolution logic
to disambiguate entities across memory units.
"""
import asyncio
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import UTC, datetime
from difflib import SequenceMatcher
@@ -12,6 +16,43 @@ import asyncpg
from .db_utils import acquire_with_retry
from .memory_engine import fq_table
from .retain.entity_labels import build_labels_lookup as _build_labels_lookup_from_config
logger = logging.getLogger(__name__)
@dataclass
class _EntityToCreate:
"""An entity that needs to be inserted (no matching candidate found)."""
idx: int
name: str
event_date: datetime | None
@dataclass
class _EntityStat:
"""Stat accumulation entry for a resolved entity (post-transaction update)."""
entity_id: str
event_date: datetime | None
@dataclass
class _EntityStatAgg:
"""Aggregated stats used when flushing pending updates."""
count: int = 0
max_date: datetime | None = None
@dataclass
class _CooccurrencePair:
"""A (entity_id_1, entity_id_2) pair observed in a retain batch (for post-txn flush)."""
entity_id_1: str
entity_id_2: str
# Load spaCy model (singleton)
_nlp = None
@@ -22,14 +63,95 @@ class EntityResolver:
Resolves entities to canonical IDs with disambiguation.
"""
def __init__(self, pool: asyncpg.Pool):
def __init__(self, pool: asyncpg.Pool, entity_lookup: str = "full"):
"""
Initialize entity resolver.
Args:
pool: asyncpg connection pool
entity_lookup: Lookup strategy — "full" loads all bank entities then
matches in Python; "trigram" uses pg_trgm GIN index to fetch only
similar candidates per entity name (much faster for large banks).
"""
self.pool = pool
self.entity_lookup = entity_lookup
# Keyed by asyncio task id so concurrent retain batches never mix their
# pending updates. flush_pending_stats() pops only the calling task's items.
self._pending_stats: dict[int, list[_EntityStat]] = {}
self._pending_cooccurrences: dict[int, list[_CooccurrencePair]] = {}
def _task_key(self) -> int:
"""Return a unique key for the current asyncio task (or 0 for non-task context)."""
task = asyncio.current_task()
return id(task) if task is not None else 0
async def flush_pending_stats(self) -> None:
"""
Flush accumulated entity stats and co-occurrence counts for the current task.
Must be called AFTER the retain transaction commits. Pops only the items
accumulated by the calling asyncio task so concurrent retain batches never
flush each other's uncommitted entity IDs.
"""
if self.pool is None:
return
key = self._task_key()
stats = self._pending_stats.pop(key, [])
cooccurrences = self._pending_cooccurrences.pop(key, [])
if not stats and not cooccurrences:
return
async with acquire_with_retry(self.pool) as conn:
if stats:
# Aggregate: sum counts and find max date per entity_id.
agg: dict[str, _EntityStatAgg] = defaultdict(_EntityStatAgg)
for s in stats:
entry = agg[s.entity_id]
entry.count += 1
if s.event_date is not None:
entry.max_date = s.event_date if entry.max_date is None else max(entry.max_date, s.event_date)
# Sort by entity_id so all concurrent workers acquire row locks in
# the same order — prevents circular lock dependencies (deadlocks).
rows = sorted((eid, a.count, a.max_date) for eid, a in agg.items())
await conn.executemany(
f"""
UPDATE {fq_table("entities")} SET
mention_count = mention_count + $2,
last_seen = GREATEST(last_seen, $3)
WHERE id = $1::uuid
""",
rows,
)
if cooccurrences:
# Aggregate: count occurrences per (entity_id_1, entity_id_2) pair.
coo_agg: dict[tuple[str, str], int] = {}
for c in cooccurrences:
pair = (c.entity_id_1, c.entity_id_2)
coo_agg[pair] = coo_agg.get(pair, 0) + 1
now = datetime.now(UTC)
# Sort by (entity_id_1, entity_id_2) for consistent lock ordering.
await conn.executemany(
f"""
INSERT INTO {fq_table("entity_cooccurrences")}
(entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
VALUES ($1, $2, $3, $4)
ON CONFLICT (entity_id_1, entity_id_2)
DO UPDATE SET
cooccurrence_count = {fq_table("entity_cooccurrences")}.cooccurrence_count + EXCLUDED.cooccurrence_count,
last_cooccurred = GREATEST({fq_table("entity_cooccurrences")}.last_cooccurred, EXCLUDED.last_cooccurred)
""",
sorted((e1, e2, count, now) for (e1, e2), count in coo_agg.items()),
)
@staticmethod
def _build_labels_lookup(entity_labels: list | None) -> set[str]:
"""Build a set of valid 'key:value' entity label strings for fast lookup."""
return _build_labels_lookup_from_config(entity_labels)
async def resolve_entities_batch(
self,
@@ -38,6 +160,7 @@ class EntityResolver:
context: str,
unit_event_date,
conn=None,
entity_labels: list | None = None,
) -> list[str]:
"""
Resolve multiple entities in batch (MUCH faster than sequential).
@@ -58,15 +181,34 @@ class EntityResolver:
if not entities_data:
return []
taxonomy_lookup = self._build_labels_lookup(entity_labels)
if conn is None:
async with acquire_with_retry(self.pool) as conn:
return await self._resolve_entities_batch_impl(conn, bank_id, entities_data, context, unit_event_date)
return await self._resolve_entities_batch_impl(
conn, bank_id, entities_data, context, unit_event_date, taxonomy_lookup
)
else:
return await self._resolve_entities_batch_impl(conn, bank_id, entities_data, context, unit_event_date)
return await self._resolve_entities_batch_impl(
conn, bank_id, entities_data, context, unit_event_date, taxonomy_lookup
)
async def _resolve_entities_batch_impl(
self, conn, bank_id: str, entities_data: list[dict], context: str, unit_event_date
self,
conn,
bank_id: str,
entities_data: list[dict],
context: str,
unit_event_date,
taxonomy_lookup: set[str] | None = None,
) -> list[str]:
if self.entity_lookup == "trigram":
return await self._resolve_entities_batch_trigram(conn, bank_id, entities_data, unit_event_date)
return await self._resolve_entities_batch_full(conn, bank_id, entities_data, unit_event_date)
async def _resolve_entities_batch_full(
self, conn, bank_id: str, entities_data: list[dict], unit_event_date
) -> list[str]:
"""Original strategy: load all bank entities then match in Python."""
# Query ALL candidates for this bank
all_entities = await conn.fetch(
f"""
@@ -130,10 +272,103 @@ class EntityResolver:
matching.append((ent_id, canonical_name, metadata, last_seen, mention_count))
all_candidates[entity_text] = matching
return await self._resolve_from_candidates(
conn, bank_id, entities_data, unit_event_date, all_candidates, cooccurrence_map
)
async def _resolve_entities_batch_trigram(
self, conn, bank_id: str, entities_data: list[dict], unit_event_date
) -> list[str]:
"""
Trigram strategy: fetch only similar candidates per entity name using pg_trgm.
Instead of loading all bank entities (O(N)), uses a GIN trigram index to fetch
only the small set of candidates that are textually similar to each input name.
Reduces DB data transfer from 165K rows to ~5-20 rows per entity.
"""
entity_texts = list(set(e["text"] for e in entities_data))
# Fetch candidates for all unique entity texts in a single batched query.
# The trigram % operator uses the GIN index; the substring conditions cover
# exact prefix/suffix matches that trigrams might miss at low similarity.
rows = await conn.fetch(
f"""
SELECT DISTINCT ON (e.id)
e.id, e.canonical_name, e.metadata, e.last_seen, e.mention_count,
q.query_text
FROM unnest($2::text[]) AS q(query_text)
JOIN {fq_table("entities")} e ON (
e.bank_id = $1
AND (
e.canonical_name % q.query_text
OR LOWER(e.canonical_name) LIKE '%' || LOWER(q.query_text) || '%'
OR LOWER(q.query_text) LIKE '%' || LOWER(e.canonical_name) || '%'
)
)
""",
bank_id,
entity_texts,
)
# Group candidates by query_text
all_candidates: dict[str, list] = {t: [] for t in entity_texts}
candidate_ids: set = set()
for row in rows:
query_text = row["query_text"]
all_candidates[query_text].append(
(row["id"], row["canonical_name"], row["metadata"], row["last_seen"], row["mention_count"])
)
candidate_ids.add(row["id"])
# Fetch co-occurrences only for the candidate entities (not all bank entities)
cooccurrence_map: dict[str, set[str]] = {}
if candidate_ids:
candidate_id_list = list(candidate_ids)
cooc_rows = await conn.fetch(
f"""
SELECT ec.entity_id_1, ec.entity_id_2
FROM {fq_table("entity_cooccurrences")} ec
WHERE ec.entity_id_1 = ANY($1::uuid[])
OR ec.entity_id_2 = ANY($1::uuid[])
""",
candidate_id_list,
)
# Build name lookup for co-occurrence mapping
id_to_name = {
row["id"]: row["canonical_name"].lower()
for cands in all_candidates.values()
for row in [{"id": c[0], "canonical_name": c[1]} for c in cands]
}
for row in cooc_rows:
eid1, eid2 = row["entity_id_1"], row["entity_id_2"]
if eid1 not in cooccurrence_map:
cooccurrence_map[eid1] = set()
if eid2 not in cooccurrence_map:
cooccurrence_map[eid2] = set()
if eid2 in id_to_name:
cooccurrence_map[eid1].add(id_to_name[eid2])
if eid1 in id_to_name:
cooccurrence_map[eid2].add(id_to_name[eid1])
return await self._resolve_from_candidates(
conn, bank_id, entities_data, unit_event_date, all_candidates, cooccurrence_map
)
async def _resolve_from_candidates(
self,
conn,
bank_id: str,
entities_data: list[dict],
unit_event_date,
all_candidates: dict[str, list],
cooccurrence_map: dict[str, set[str]],
) -> list[str]:
"""Shared scoring + upsert logic used by both lookup strategies."""
# Resolve each entity using pre-fetched candidates
entity_ids = [None] * len(entities_data)
entities_to_update = [] # (entity_id, event_date)
entities_to_create = [] # (idx, entity_data, event_date)
entities_to_update: list[_EntityStat] = []
entities_to_create: list[_EntityToCreate] = []
for idx, entity_data in enumerate(entities_data):
entity_text = entity_data["text"]
@@ -145,7 +380,7 @@ class EntityResolver:
if not candidates:
# Will create new entity
entities_to_create.append((idx, entity_data, entity_event_date))
entities_to_create.append(_EntityToCreate(idx=idx, name=entity_text, event_date=entity_event_date))
continue
# Score candidates
@@ -189,73 +424,83 @@ class EntityResolver:
if best_score > threshold:
entity_ids[idx] = best_candidate
entities_to_update.append((best_candidate, entity_event_date))
entities_to_update.append(_EntityStat(entity_id=best_candidate, event_date=entity_event_date))
else:
entities_to_create.append((idx, entity_data, entity_event_date))
entities_to_create.append(
_EntityToCreate(idx=idx, name=entity_data["text"], event_date=entity_event_date)
)
# Batch update existing entities
if entities_to_update:
await conn.executemany(
f"""
UPDATE {fq_table("entities")} SET
mention_count = mention_count + 1,
last_seen = $2
WHERE id = $1::uuid
""",
entities_to_update,
)
# Existing entities: IDs already known from the candidate SELECT above.
# No in-transaction UPDATE — mention_count/last_seen are stats deferred to
# flush_pending_stats() which the orchestrator calls after the transaction.
pending: list[_EntityStat] = list(entities_to_update)
# Batch create new entities using COPY + INSERT for maximum speed
# This handles duplicates via ON CONFLICT and returns all IDs
# New entities: INSERT with DO NOTHING to avoid row locks on concurrent races.
# ON CONFLICT DO NOTHING returns nothing for rows that conflicted; we handle
# that rare case with a fallback SELECT.
if entities_to_create:
# Group entities by canonical name (lowercase) to handle duplicates within batch
# For duplicates, we only insert once and reuse the ID, but track the count
unique_entities = {} # lowercase_name -> (entity_data, event_date, [indices])
for idx, entity_data, event_date in entities_to_create:
name_lower = entity_data["text"].lower()
if name_lower not in unique_entities:
unique_entities[name_lower] = (entity_data, event_date, [idx])
else:
# Same entity appears multiple times - add index to list
unique_entities[name_lower][2].append(idx)
# Group by lowercase name — deduplicate within the batch.
@dataclass
class _NameGroup:
name: str
event_date: datetime | None
indices: list[int] = field(default_factory=list)
# Batch insert unique entities and get their IDs
# Use a single query with unnest for speed
entity_names = []
entity_dates = []
entity_counts = [] # Track how many times each entity appears in this batch
indices_map = [] # Maps result index -> list of original indices
groups: dict[str, _NameGroup] = {}
for e in entities_to_create:
name_lower = e.name.lower()
if name_lower not in groups:
groups[name_lower] = _NameGroup(name=e.name, event_date=e.event_date)
groups[name_lower].indices.append(e.idx)
for name_lower, (entity_data, event_date, indices) in unique_entities.items():
entity_names.append(entity_data["text"])
entity_dates.append(event_date)
entity_counts.append(len(indices)) # Count of occurrences in this batch
indices_map.append(indices)
# Sort by lowercase name for deterministic ordering.
sorted_groups = sorted(groups.items())
entity_names = [g.name for _, g in sorted_groups]
entity_dates = [g.event_date for _, g in sorted_groups]
# Batch INSERT ... ON CONFLICT with RETURNING
# Uses the batch count for mention_count instead of always 1
rows = await conn.fetch(
# INSERT ... ON CONFLICT DO NOTHING — no row lock on already-existing entities.
inserted_rows = await conn.fetch(
f"""
INSERT INTO {fq_table("entities")} (bank_id, canonical_name, first_seen, last_seen, mention_count)
SELECT $1, name, COALESCE(event_date, now()), COALESCE(event_date, now()), cnt
FROM unnest($2::text[], $3::timestamptz[], $4::int[]) AS t(name, event_date, cnt)
SELECT $1, name, COALESCE(event_date, now()), COALESCE(event_date, now()), 1
FROM unnest($2::text[], $3::timestamptz[]) AS t(name, event_date)
ON CONFLICT (bank_id, LOWER(canonical_name))
DO UPDATE SET
mention_count = {fq_table("entities")}.mention_count + EXCLUDED.mention_count,
last_seen = EXCLUDED.last_seen
RETURNING id
DO NOTHING
RETURNING id, LOWER(canonical_name) AS name_lower
""",
bank_id,
entity_names,
entity_dates,
entity_counts,
)
id_by_name: dict[str, str] = {row["name_lower"]: row["id"] for row in inserted_rows}
# Map returned IDs back to original indices
for result_idx, row in enumerate(rows):
entity_id = row["id"]
for original_idx in indices_map[result_idx]:
entity_ids[original_idx] = entity_id
# Fallback SELECT for names that conflicted (another worker won the race).
missing = [n for n, _ in sorted_groups if n not in id_by_name]
if missing:
existing_rows = await conn.fetch(
f"""
SELECT id, LOWER(canonical_name) AS name_lower
FROM {fq_table("entities")}
WHERE bank_id = $1 AND LOWER(canonical_name) = ANY($2::text[])
""",
bank_id,
missing,
)
for row in existing_rows:
id_by_name[row["name_lower"]] = row["id"]
# Assign entity IDs back and queue for post-txn stats flush.
for name_lower, g in sorted_groups:
entity_id = id_by_name.get(name_lower)
if entity_id:
for original_idx in g.indices:
entity_ids[original_idx] = entity_id
pending.append(_EntityStat(entity_id=entity_id, event_date=g.event_date))
# Accumulate into the resolver's pending list; the orchestrator flushes
# these with await entity_resolver.flush_pending_stats() after the txn.
key = self._task_key()
self._pending_stats.setdefault(key, []).extend(pending)
return entity_ids
@@ -541,19 +786,14 @@ class EntityResolver:
entity_id_1, entity_id_2 = entity_id_2, entity_id_1
cooccurrence_pairs.add((entity_id_1, entity_id_2))
# Batch update co-occurrences
# Accumulate co-occurrence pairs for post-transaction flush.
# The actual INSERT/UPDATE is deferred to flush_pending_stats() to avoid
# row-level lock contention (ON CONFLICT DO UPDATE inside a long transaction
# serialises concurrent writers on popular entity pairs).
if cooccurrence_pairs:
now = datetime.now(UTC)
await conn.executemany(
f"""
INSERT INTO {fq_table("entity_cooccurrences")} (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
VALUES ($1, $2, $3, $4)
ON CONFLICT (entity_id_1, entity_id_2)
DO UPDATE SET
cooccurrence_count = {fq_table("entity_cooccurrences")}.cooccurrence_count + 1,
last_cooccurred = EXCLUDED.last_cooccurred
""",
[(e1, e2, 1, now) for e1, e2 in cooccurrence_pairs],
key = self._task_key()
self._pending_cooccurrences.setdefault(key, []).extend(
_CooccurrencePair(entity_id_1=e1, entity_id_2=e2) for e1, e2 in cooccurrence_pairs
)
async def get_units_by_entity(self, entity_id: str, limit: int = 100) -> list[str]:
@@ -12,6 +12,7 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import RecallResult, ReflectResult
from hindsight_api.engine.search.tags import TagsMatch
from hindsight_api.models import RequestContext
@@ -337,6 +338,8 @@ class MemoryEngineInterface(ABC):
bank_id: str,
*,
search_query: str | None = None,
tags: list[str] | None = None,
tags_match: "TagsMatch" = "any_strict",
limit: int = 100,
offset: int = 0,
request_context: "RequestContext",
@@ -346,7 +349,9 @@ class MemoryEngineInterface(ABC):
Args:
bank_id: The memory bank ID.
search_query: Search query.
search_query: Case-insensitive substring filter on document ID.
tags: Filter by tags.
tags_match: How to match tags (any, all, any_strict, all_strict).
limit: Maximum results.
offset: Pagination offset.
request_context: Request context for authentication.
@@ -124,6 +124,7 @@ def create_llm_provider(
vertexai_project_id: str | None = None,
vertexai_region: str | None = None,
vertexai_credentials: Any = None,
gemini_safety_settings: list | None = None,
) -> Any: # Returns LLMInterface
"""
Factory function to create the appropriate LLM provider implementation.
@@ -192,6 +193,7 @@ def create_llm_provider(
vertexai_project_id=vertexai_project_id,
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
gemini_safety_settings=gemini_safety_settings,
)
elif provider_lower == "anthropic":
@@ -234,6 +236,7 @@ class LLMProvider:
reasoning_effort: str = "low",
groq_service_tier: str | None = None,
openai_service_tier: str | None = None,
gemini_safety_settings: list | None = None,
):
"""
Initialize LLM provider.
@@ -246,6 +249,7 @@ class LLMProvider:
reasoning_effort: Reasoning effort level for supported providers.
groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config.
openai_service_tier: OpenAI service tier (None or "flex") - from config.
gemini_safety_settings: Safety settings for Gemini/VertexAI providers.
"""
self.provider = provider.lower()
self.api_key = api_key
@@ -255,6 +259,8 @@ class LLMProvider:
# Service tiers from hierarchical config (not env vars)
self.groq_service_tier = groq_service_tier
self.openai_service_tier = openai_service_tier
# Gemini safety settings (instance default; can be overridden per-request via context var)
self.gemini_safety_settings = gemini_safety_settings
# Validate provider
valid_providers = [
@@ -323,6 +329,18 @@ class LLMProvider:
f"model={self.model}, auth={'service_account' if service_account_key else 'ADC'}"
)
# For Gemini/VertexAI providers: read safety settings from global config if not explicitly provided
# Use _get_raw_config() to bypass StaticConfigProxy (which blocks configurable fields),
# since LLMProvider initialization legitimately needs the server-level default.
if self.provider in ("gemini", "vertexai") and self.gemini_safety_settings is None:
from ..config import _get_raw_config
try:
raw_config = _get_raw_config()
self.gemini_safety_settings = raw_config.llm_gemini_safety_settings
except Exception:
pass # Config may not be initialized in test environments
# Create provider implementation using factory
self._provider_impl = create_llm_provider(
provider=self.provider,
@@ -335,6 +353,7 @@ class LLMProvider:
vertexai_project_id=vertexai_project_id,
vertexai_region=vertexai_region,
vertexai_credentials=vertexai_credentials,
gemini_safety_settings=self.gemini_safety_settings,
)
# Backward compatibility: Keep mock provider properties
@@ -503,6 +522,14 @@ class LLMProvider:
return result
def set_response_callback(self, fn: Any) -> None:
"""Set a callback invoked on each call() instead of the fixed mock response."""
if self.provider == "mock":
from .providers.mock_llm import MockLLM
if isinstance(self._provider_impl, MockLLM):
self._provider_impl.set_response_callback(fn)
def set_mock_response(self, response: Any) -> None:
"""Set the response to return from mock calls."""
# Backward compatibility: Store in both wrapper and provider implementation
@@ -595,6 +622,23 @@ class LLMProvider:
# SDK will automatically check for authentication when first used
# No need to verify here - let it fail gracefully on first call with helpful error
def with_config(self, config: Any) -> "ConfiguredLLMProvider":
"""
Return a configured wrapper for a specific bank operation.
The wrapper applies per-bank overrides (e.g. Gemini safety settings)
to every ``call()`` / ``call_with_tools()`` invocation without
changing the underlying provider or its long-lived client connection.
Args:
config: Resolved ``HindsightConfig`` for the current bank/request.
Returns:
A ``ConfiguredLLMProvider`` that delegates to this provider with
the supplied config applied.
"""
return ConfiguredLLMProvider(self, config.llm_gemini_safety_settings)
async def cleanup(self) -> None:
"""Clean up resources."""
pass
@@ -656,5 +700,58 @@ class LLMProvider:
return cls(provider=provider, api_key=api_key, base_url=base_url, model=model, reasoning_effort="high")
class ConfiguredLLMProvider:
"""
Thin wrapper around LLMProvider that applies bank-specific config to every call.
Obtained via ``LLMProvider.with_config(resolved_config)``. The wrapper
sets any provider-specific overrides (currently Gemini safety settings)
immediately before each call using a ContextVar token, then resets it
afterwards — so nesting is safe and the configuration cannot leak across
operations.
All attribute access falls through to the underlying provider so callers
that read ``llm.provider``, ``llm.model``, etc. continue to work without
any changes.
"""
def __init__(self, provider: "LLMProvider", gemini_safety_settings: list | None) -> None:
# Use object.__setattr__ to avoid triggering __getattr__
object.__setattr__(self, "_provider", provider)
object.__setattr__(self, "_gemini_safety_settings", gemini_safety_settings)
# ── attribute passthrough ──────────────────────────────────────────────────
def __getattr__(self, name: str) -> Any:
return getattr(object.__getattribute__(self, "_provider"), name)
# ── overridden call methods ────────────────────────────────────────────────
async def call(self, messages: list[dict[str, Any]], **kwargs: Any) -> Any:
from .providers.gemini_llm import _safety_settings_ctx
token = _safety_settings_ctx.set(object.__getattribute__(self, "_gemini_safety_settings"))
try:
return await object.__getattribute__(self, "_provider").call(messages=messages, **kwargs)
finally:
_safety_settings_ctx.reset(token)
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
**kwargs: Any,
) -> "LLMToolCallResult":
from .providers.gemini_llm import _safety_settings_ctx
token = _safety_settings_ctx.set(object.__getattribute__(self, "_gemini_safety_settings"))
try:
return await object.__getattribute__(self, "_provider").call_with_tools(
messages=messages, tools=tools, **kwargs
)
finally:
_safety_settings_ctx.reset(token)
# Backwards compatibility alias
LLMConfig = LLMProvider
@@ -15,15 +15,19 @@ import json
import logging
import time
import uuid
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any
import asyncpg
import httpx
import tiktoken
from ..config import get_config
from ..metrics import get_metrics_collector
from ..tracing import create_operation_span
from ..utils import mask_network_location
from ..worker.exceptions import RetryTaskAt
from .db_budget import budgeted_operation
from .operation_metadata import (
BatchRetainChildMetadata,
@@ -183,8 +187,8 @@ from .response_models import RecallResult as RecallResultModel
from .retain import bank_utils, embedding_utils
from .retain.types import RetainContentDict
from .search import think_utils
from .search.reranking import CrossEncoderReranker
from .search.tags import TagsMatch
from .search.reranking import CrossEncoderReranker, apply_combined_scoring
from .search.tags import TagsMatch, build_tags_where_clause
from .task_backend import BrokerTaskBackend, SyncTaskBackend, TaskBackend
@@ -357,6 +361,11 @@ class MemoryEngine(MemoryEngineInterface):
self._db_command_timeout = db_command_timeout if db_command_timeout is not None else config.db_command_timeout
self._db_acquire_timeout = db_acquire_timeout if db_acquire_timeout is not None else config.db_acquire_timeout
self._run_migrations = run_migrations
self._retain_entity_lookup = config.retain_entity_lookup
# Webhook manager (will be created in initialize() after pool is ready)
self._webhook_manager = None
self._http_client: httpx.AsyncClient | None = None
# Initialize entity resolver (will be created in initialize())
self.entity_resolver = None
@@ -581,6 +590,12 @@ class MemoryEngine(MemoryEngineInterface):
document_tags=document_tags,
request_context=context,
operation_id=operation_id,
outbox_callback=self._build_retain_outbox_callback(
bank_id=bank_id,
contents=contents,
operation_id=operation_id,
schema=_current_schema.get(),
),
)
# If this retain was triggered by file conversion, update document with file metadata
@@ -777,6 +792,7 @@ class MemoryEngine(MemoryEngineInterface):
)
logger.info(f"[CONSOLIDATION] bank={bank_id} completed: {result.get('memories_processed', 0)} processed")
return result
async def _handle_refresh_mental_model(self, task_dict: dict[str, Any]):
"""
@@ -948,15 +964,18 @@ class MemoryEngine(MemoryEngineInterface):
logger.error(f"Failed to check operation status {operation_id}: {e}")
# Continue with processing if we can't check status
consolidation_result: dict | None = None
try:
if task_type == "batch_retain":
await self._handle_batch_retain(task_dict)
elif task_type == "file_convert_retain":
await self._handle_file_convert_retain(task_dict)
elif task_type == "consolidation":
await self._handle_consolidation(task_dict)
consolidation_result = await self._handle_consolidation(task_dict)
elif task_type == "refresh_mental_model":
await self._handle_refresh_mental_model(task_dict)
elif task_type == "webhook_delivery":
await self._handle_webhook_delivery(task_dict)
else:
logger.error(f"Unknown task type: {task_type}")
# Don't retry unknown task types
@@ -966,9 +985,22 @@ class MemoryEngine(MemoryEngineInterface):
# Task succeeded - mark operation as completed
# file_convert_retain marks itself as completed in a transaction, skip double-marking
if operation_id and task_type != "file_convert_retain":
await self._mark_operation_completed(operation_id)
if operation_id and task_type not in ("file_convert_retain",):
if task_type == "consolidation":
# Atomically mark completed AND queue webhook delivery in one transaction
await self._mark_operation_completed_and_fire_webhook(
operation_id=operation_id,
bank_id=task_dict.get("bank_id", ""),
status="completed",
result=consolidation_result,
schema=schema,
)
else:
await self._mark_operation_completed(operation_id)
except RetryTaskAt:
# Task-owned retry: let the poller handle scheduling
raise
except Exception as e:
logger.error(f"Task execution failed: {task_type}, error: {e}")
import traceback
@@ -983,10 +1015,193 @@ class MemoryEngine(MemoryEngineInterface):
if operation_id:
await self._mark_operation_failed(operation_id, str(e), error_traceback)
else:
# Retryable: re-raise so the worker poller handles retry/fail via _retry_or_fail,
# which correctly resets status='pending' and increments the DB retry_count.
if task_type == "consolidation" and operation_id:
# Fire failure webhook (non-transactional — operation not yet marked failed;
# poller will mark it failed after this raise)
await self._fire_consolidation_webhook(
bank_id=task_dict.get("bank_id", ""),
operation_id=operation_id,
status="failed",
result=None,
error_message=str(e),
schema=schema,
)
# Retryable: use RetryTaskAt if under the retry limit, else re-raise (poller marks failed)
retry_count = task_dict.get("_retry_count", 0)
if retry_count < 3:
raise RetryTaskAt(retry_at=datetime.now(UTC) + timedelta(seconds=60), message=str(e))
raise
async def _fire_consolidation_webhook(
self,
bank_id: str,
operation_id: str,
status: str,
result: dict | None,
error_message: str | None = None,
schema: str | None = None,
) -> None:
"""Fire a consolidation webhook event. Non-fatal - logs errors but does not raise."""
if not self._webhook_manager:
return
try:
from datetime import datetime, timezone
from ..webhooks.models import ConsolidationEventData, WebhookEvent, WebhookEventType
data = ConsolidationEventData(
observations_created=result.get("observations_created") if result else None,
observations_updated=result.get("observations_updated") if result else None,
observations_deleted=result.get("observations_deleted") if result else None,
error_message=error_message,
)
event = WebhookEvent(
event=WebhookEventType.CONSOLIDATION_COMPLETED,
bank_id=bank_id,
operation_id=operation_id,
status=status,
timestamp=datetime.now(timezone.utc),
data=data,
)
await self._webhook_manager.fire_event(event, schema=schema)
except Exception as e:
logger.error(f"Failed to fire consolidation webhook for operation {operation_id}: {e}")
def _build_retain_outbox_callback(
self,
bank_id: str,
contents: list[dict],
operation_id: str | None,
schema: str | None = None,
) -> "Callable[[asyncpg.Connection], Awaitable[None]] | None":
"""Build a transactional outbox callback for retain.completed webhook events.
Returns a coroutine function that queues one webhook delivery row per content
item using the provided connection (inside the retain transaction). Returns None
if no webhook manager is configured.
"""
webhook_manager = getattr(self, "_webhook_manager", None)
if not webhook_manager:
return None
from ..webhooks.models import RetainEventData, WebhookEvent, WebhookEventType
now = datetime.now(UTC)
op_id = operation_id or uuid.uuid4().hex
events = []
for content in contents:
doc_id = content.get("document_id")
tags = content.get("tags")
data = RetainEventData(
document_id=doc_id,
tags=tags if isinstance(tags, list) else None,
)
events.append(
WebhookEvent(
event=WebhookEventType.RETAIN_COMPLETED,
bank_id=bank_id,
operation_id=op_id,
status="completed",
timestamp=now,
data=data,
)
)
async def _callback(conn: asyncpg.Connection) -> None:
for event in events:
await webhook_manager.fire_event_with_conn(event, conn, schema=schema)
return _callback
async def _update_webhook_delivery_metadata(
self, operation_id: str, status_code: int | None, response_body: str | None
) -> None:
"""Persist last HTTP attempt info into async_operations.result_metadata."""
try:
pool = await self._get_pool()
meta = json.dumps(
{
"last_status_code": status_code,
"last_response_body": (response_body or "")[:2048],
"last_attempt_at": datetime.now(UTC).isoformat(),
}
)
async with acquire_with_retry(pool) as conn:
await conn.execute(
f"UPDATE {fq_table('async_operations')} SET result_metadata = $2::jsonb, updated_at = now() WHERE operation_id = $1",
uuid.UUID(operation_id),
meta,
)
except Exception as meta_err:
logger.debug(f"Failed to update webhook delivery metadata: {meta_err}")
async def _handle_webhook_delivery(self, task_dict: dict[str, Any]) -> None:
"""Deliver a webhook event via HTTP.
Raises RetryTaskAt to schedule a retry on failure (up to MAX_ATTEMPTS).
Raises the original exception when retries are exhausted (poller marks failed).
Response status code and body are stored in result_metadata for debugging.
"""
from ..webhooks.manager import MAX_ATTEMPTS, RETRY_DELAYS
from ..webhooks.models import WebhookHttpConfig
url = task_dict["url"]
secret = task_dict.get("secret")
event_type = task_dict["event_type"]
raw_payload = task_dict["payload"]
retry_count = task_dict.get("_retry_count", 0)
operation_id: str | None = task_dict.get("_operation_id")
http_config = WebhookHttpConfig.model_validate(task_dict.get("http_config") or {})
if isinstance(raw_payload, dict):
payload_bytes = json.dumps(raw_payload).encode()
else:
payload_bytes = str(raw_payload).encode()
headers: dict[str, str] = {
"Content-Type": "application/json",
"X-Hindsight-Event": event_type,
**http_config.headers,
}
if secret and self._webhook_manager:
headers["X-Hindsight-Signature"] = self._webhook_manager._sign_payload(secret, payload_bytes)
if self._http_client is None:
raise RuntimeError("HTTP client not initialized")
response = None
try:
request_kwargs: dict[str, Any] = {
"headers": headers,
"params": http_config.params if http_config.params else None,
"timeout": http_config.timeout_seconds,
}
if http_config.method.upper() == "GET":
response = await self._http_client.get(url, **request_kwargs)
else:
response = await self._http_client.post(url, content=payload_bytes, **request_kwargs)
response.raise_for_status()
if operation_id:
await self._update_webhook_delivery_metadata(operation_id, response.status_code, response.text)
except Exception as e:
status_code = response.status_code if response is not None else None
response_body = response.text if response is not None else None
if operation_id:
await self._update_webhook_delivery_metadata(operation_id, status_code, response_body)
if retry_count >= MAX_ATTEMPTS - 1:
logger.error(
f"webhook_delivery permanently_failed url={url} attempts={retry_count + 1} "
f"status_code={status_code} error={e}"
)
raise
delay = RETRY_DELAYS[retry_count] if retry_count < len(RETRY_DELAYS) else RETRY_DELAYS[-1]
retry_at = datetime.now(UTC) + timedelta(seconds=delay)
logger.warning(
f"webhook_delivery failed url={url} attempt={retry_count + 1}/{MAX_ATTEMPTS} "
f"status_code={status_code} retry_in={delay}s error={e}"
)
raise RetryTaskAt(retry_at=retry_at, message=str(e))
async def _delete_operation_record(self, operation_id: str):
"""Helper to delete an operation record from the database."""
try:
@@ -1057,6 +1272,58 @@ class MemoryEngine(MemoryEngineInterface):
except Exception as e:
logger.error(f"Failed to mark operation as completed {operation_id}: {e}")
async def _mark_operation_completed_and_fire_webhook(
self,
operation_id: str,
bank_id: str,
status: str,
result: dict | None,
schema: str | None = None,
error_message: str | None = None,
) -> None:
"""Mark an operation as completed and queue webhook deliveries in a single transaction.
Uses the transactional outbox pattern: the webhook delivery row is inserted in the
same database transaction as the status update. This guarantees at-least-once delivery
even if the process crashes immediately after committing.
"""
from ..webhooks.models import ConsolidationEventData, WebhookEvent, WebhookEventType
try:
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
await conn.execute(
f"""
UPDATE {fq_table("async_operations")}
SET status = 'completed', updated_at = NOW(), completed_at = NOW()
WHERE operation_id = $1
""",
uuid.UUID(operation_id),
)
logger.info(f"Marked async operation as completed: {operation_id}")
await self._maybe_update_parent_operation(operation_id, conn)
# Queue webhook deliveries inside the same transaction
if self._webhook_manager:
data = ConsolidationEventData(
observations_created=result.get("observations_created") if result else None,
observations_updated=result.get("observations_updated") if result else None,
observations_deleted=result.get("observations_deleted") if result else None,
error_message=error_message,
)
event = WebhookEvent(
event=WebhookEventType.CONSOLIDATION_COMPLETED,
bank_id=bank_id,
operation_id=operation_id,
status=status,
timestamp=datetime.now(UTC),
data=data,
)
await self._webhook_manager.fire_event_with_conn(event, conn, schema=schema)
except Exception as e:
logger.error(f"Failed to mark operation completed and fire webhook {operation_id}: {e}")
async def _maybe_update_parent_operation(self, child_operation_id: str, conn):
"""Check if this is a child operation and update parent status if all siblings are done.
@@ -1340,8 +1607,11 @@ class MemoryEngine(MemoryEngineInterface):
timeout=self._db_acquire_timeout, # Connection acquisition timeout (seconds)
)
# Initialize entity resolver with pool
self.entity_resolver = EntityResolver(self._pool)
# Initialize entity resolver with pool and configured lookup strategy
self.entity_resolver = EntityResolver(
self._pool,
entity_lookup=self._retain_entity_lookup,
)
# Initialize config resolver for hierarchical configuration
from ..config_resolver import ConfigResolver
@@ -1377,6 +1647,32 @@ class MemoryEngine(MemoryEngineInterface):
else:
logger.debug("Iris parser not registered (VECTORIZE_TOKEN or VECTORIZE_ORG_ID not set)")
# Initialize webhook manager
from ..webhooks import WebhookManager
from ..webhooks.models import WebhookConfig
webhook_global: list[WebhookConfig] = []
if config.webhook_url:
webhook_global = [
WebhookConfig(
id="", # No DB row for env-configured global webhook
bank_id=None,
url=config.webhook_url,
secret=config.webhook_secret,
event_types=config.webhook_event_types,
enabled=True,
)
]
self._webhook_manager = WebhookManager(
pool=self._pool,
global_webhooks=webhook_global,
tenant_extension=self._tenant_extension,
)
logger.debug("Webhook manager initialized")
# Long-lived HTTP client for webhook delivery tasks
self._http_client = httpx.AsyncClient(timeout=30.0)
# Set executor for task backend and initialize
self._task_backend.set_executor(self.execute_task)
await self._task_backend.initialize()
@@ -1436,6 +1732,11 @@ class MemoryEngine(MemoryEngineInterface):
# Shutdown task backend
await self._task_backend.shutdown()
# Close HTTP client used for webhook delivery
if self._http_client is not None:
await self._http_client.aclose()
self._http_client = None
# Close pool
if self._pool is not None:
self._pool.terminate()
@@ -1485,110 +1786,6 @@ class MemoryEngine(MemoryEngineInterface):
# Could check if day is significant (not 1st or 15th) and include it
return f"{month_name} {year}"
async def _find_duplicate_facts_batch(
self,
conn,
bank_id: str,
texts: list[str],
embeddings: list[list[float]],
event_date: datetime,
time_window_hours: int = 24,
similarity_threshold: float = 0.95,
) -> list[bool]:
"""
Check which facts are duplicates using semantic similarity + temporal window.
For each new fact, checks if a semantically similar fact already exists
within the time window. Uses pgvector cosine similarity for efficiency.
Args:
conn: Database connection
bank_id: bank IDentifier
texts: List of fact texts to check
embeddings: Corresponding embeddings
event_date: Event date for temporal filtering
time_window_hours: Hours before/after event_date to search (default: 24)
similarity_threshold: Minimum cosine similarity to consider duplicate (default: 0.95)
Returns:
List of booleans - True if fact is a duplicate (should skip), False if new
"""
if not texts:
return []
# Handle edge cases where event_date is at datetime boundaries
try:
time_lower = event_date - timedelta(hours=time_window_hours)
except OverflowError:
time_lower = datetime.min
try:
time_upper = event_date + timedelta(hours=time_window_hours)
except OverflowError:
time_upper = datetime.max
# Fetch ALL existing facts in time window ONCE (much faster than N queries)
import time as time_mod
fetch_start = time_mod.time()
existing_facts = await conn.fetch(
f"""
SELECT id, text, embedding
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND event_date BETWEEN $2 AND $3
""",
bank_id,
time_lower,
time_upper,
)
# If no existing facts, nothing is duplicate
if not existing_facts:
return [False] * len(texts)
# Compute similarities in Python (vectorized with numpy)
is_duplicate = []
# Convert existing embeddings to numpy for faster computation
embedding_arrays = []
for row in existing_facts:
raw_emb = row["embedding"]
# Handle different pgvector formats
if isinstance(raw_emb, str):
# Parse string format: "[1.0, 2.0, ...]"
import json
emb = np.array(json.loads(raw_emb), dtype=np.float32)
elif isinstance(raw_emb, (list, tuple)):
emb = np.array(raw_emb, dtype=np.float32)
else:
# Try direct conversion
emb = np.array(raw_emb, dtype=np.float32)
embedding_arrays.append(emb)
if not embedding_arrays:
existing_embeddings = np.array([])
elif len(embedding_arrays) == 1:
# Single embedding: reshape to (1, dim)
existing_embeddings = embedding_arrays[0].reshape(1, -1)
else:
# Multiple embeddings: vstack
existing_embeddings = np.vstack(embedding_arrays)
comp_start = time_mod.time()
for embedding in embeddings:
# Compute cosine similarity with all existing facts
emb_array = np.array(embedding)
# Cosine similarity = 1 - cosine distance
# For normalized vectors: cosine_sim = dot product
similarities = np.dot(existing_embeddings, emb_array)
# Check if any existing fact is too similar
max_similarity = np.max(similarities) if len(similarities) > 0 else 0
is_duplicate.append(max_similarity > similarity_threshold)
return is_duplicate
def retain(
self,
bank_id: str,
@@ -1680,6 +1877,7 @@ class MemoryEngine(MemoryEngineInterface):
document_tags: list[str] | None = None,
return_usage: bool = False,
operation_id: str | None = None,
outbox_callback: "Callable[[asyncpg.Connection], Awaitable[None]] | None" = None,
):
"""
Store multiple content items as memory units in ONE batch operation.
@@ -1828,6 +2026,9 @@ class MemoryEngine(MemoryEngineInterface):
confidence_score=confidence_score,
document_tags=document_tags,
operation_id=operation_id,
# Outbox callback runs inside the last sub-batch's transaction so the
# webhook delivery row is committed atomically with the final retain data.
outbox_callback=outbox_callback if i == len(sub_batches) else None,
)
all_results.extend(sub_results)
total_usage = total_usage + sub_usage
@@ -1849,6 +2050,7 @@ class MemoryEngine(MemoryEngineInterface):
confidence_score=confidence_score,
document_tags=document_tags,
operation_id=operation_id,
outbox_callback=outbox_callback,
)
# Call post-operation hook if validator is configured
@@ -1899,6 +2101,7 @@ class MemoryEngine(MemoryEngineInterface):
confidence_score: float | None = None,
document_tags: list[str] | None = None,
operation_id: str | None = None,
outbox_callback: "Callable[[asyncpg.Connection], Awaitable[None]] | None" = None,
) -> tuple[list[list[str]], "TokenUsage"]:
"""
Internal method for batch processing without chunking logic.
@@ -1936,10 +2139,9 @@ class MemoryEngine(MemoryEngineInterface):
return await orchestrator.retain_batch(
pool=pool,
embeddings_model=self.embeddings,
llm_config=self._retain_llm_config,
llm_config=self._retain_llm_config.with_config(resolved_config),
entity_resolver=self.entity_resolver,
format_date_fn=self._format_readable_date,
duplicate_checker_fn=self._find_duplicate_facts_batch,
bank_id=bank_id,
contents_dicts=contents,
document_id=document_id,
@@ -1950,6 +2152,7 @@ class MemoryEngine(MemoryEngineInterface):
config=resolved_config,
operation_id=operation_id,
schema=request_context.tenant_id if request_context else None,
outbox_callback=outbox_callback,
)
def recall(
@@ -2575,6 +2778,11 @@ class MemoryEngine(MemoryEngineInterface):
"temporal_count": len(temporal_results) if temporal_results else 0,
},
)
# Also expose each retrieval method as its own phase so
# benchmarks can pinpoint which sub-query drives latency.
for _method, _dur in aggregated_timings.items():
if _dur > 0:
tracer.add_phase_metric(f"retrieval_{_method}", _dur)
# Step 3: Merge with RRF
step_start = time.time()
@@ -2650,57 +2858,12 @@ class MemoryEngine(MemoryEngineInterface):
rerank_span.set_attribute("hindsight.pre_filtered_count", pre_filtered_count)
rerank_span.end()
# Step 4.5: Combine cross-encoder score with retrieval signals
# This preserves retrieval work (RRF, temporal, recency) instead of pure cross-encoder ranking
# Step 4.5: Combine cross-encoder score with retrieval signals via multiplicative boosts.
# See apply_combined_scoring for the full rationale and formula.
if scored_results:
# Normalize RRF scores to [0, 1] range using min-max normalization
rrf_scores = [sr.candidate.rrf_score for sr in scored_results]
max_rrf = max(rrf_scores) if rrf_scores else 0.0
min_rrf = min(rrf_scores) if rrf_scores else 0.0
rrf_range = max_rrf - min_rrf # Don't force to 1.0, let fallback handle it
# Calculate recency based on occurred_start (more recent = higher score)
now = utcnow()
for sr in scored_results:
# Normalize RRF score (0-1 range, 0.5 if all same)
if rrf_range > 0:
sr.rrf_normalized = (sr.candidate.rrf_score - min_rrf) / rrf_range
else:
# All RRF scores are the same, use neutral value
sr.rrf_normalized = 0.5
# Calculate recency (decay over 365 days, minimum 0.1)
sr.recency = 0.5 # default for missing dates
if sr.retrieval.occurred_start:
occurred = sr.retrieval.occurred_start
if hasattr(occurred, "tzinfo") and occurred.tzinfo is None:
occurred = occurred.replace(tzinfo=UTC)
days_ago = (now - occurred).total_seconds() / 86400
sr.recency = max(0.1, 1.0 - (days_ago / 365)) # Linear decay over 1 year
# Get temporal proximity if available (already 0-1)
sr.temporal = (
sr.retrieval.temporal_proximity if sr.retrieval.temporal_proximity is not None else 0.5
)
# Weighted combination
# Cross-encoder: 60% (semantic relevance)
# RRF: 20% (retrieval consensus)
# Temporal proximity: 10% (time relevance for temporal queries)
# Recency: 10% (prefer recent facts)
sr.combined_score = (
0.6 * sr.cross_encoder_score_normalized
+ 0.2 * sr.rrf_normalized
+ 0.1 * sr.temporal
+ 0.1 * sr.recency
)
sr.weight = sr.combined_score # Update weight for final ranking
# Re-sort by combined score
apply_combined_scoring(scored_results, now=utcnow())
scored_results.sort(key=lambda x: x.weight, reverse=True)
log_buffer.append(
" [4.6] Combined scoring: cross_encoder(0.6) + rrf(0.2) + temporal(0.1) + recency(0.1)"
)
log_buffer.append(" [4.6] Combined scoring: ce * recency_boost(0.2) * temporal_boost(0.2)")
# Add reranked results to tracer AFTER combined scoring (so normalized values are included)
if tracer:
@@ -2728,77 +2891,96 @@ class MemoryEngine(MemoryEngineInterface):
if include_chunks and top_scored:
from .response_models import ChunkInfo
# Collect chunk_ids in order of fact relevance (preserving order from top_scored)
# Use a list to maintain order, but track seen chunks to avoid duplicates
chunk_ids_ordered = []
seen_chunk_ids = set()
# Collect chunk_ids in order of fact relevance (preserving order from top_scored).
# Observations have no direct chunk_id — use a placeholder so their source
# chunks end up at the observation's rank position, not appended at the end.
# ordered_items: list of ('chunk', chunk_id) | ('obs', sr.id)
ordered_items: list[tuple[str, str]] = []
seen_chunk_ids: set[str] = set()
observation_ids_ordered: list[uuid.UUID] = []
for sr in top_scored:
chunk_id = sr.retrieval.chunk_id
if chunk_id and chunk_id not in seen_chunk_ids:
chunk_ids_ordered.append(chunk_id)
ordered_items.append(("chunk", chunk_id))
seen_chunk_ids.add(chunk_id)
elif not chunk_id and sr.retrieval.fact_type == "observation":
ordered_items.append(("obs", sr.id))
observation_ids_ordered.append(uuid.UUID(sr.id))
# Resolve source chunk_ids for all observations in a single query,
# ordered by observation rank so per-observation results stay grouped correctly.
obs_chunk_ids: dict[str, list[str]] = {}
if observation_ids_ordered:
async with acquire_with_retry(pool) as obs_conn:
obs_source_rows = await obs_conn.fetch(
f"""
SELECT obs.id AS obs_id, mu.chunk_id
FROM {fq_table("memory_units")} obs
JOIN {fq_table("memory_units")} mu
ON mu.id = ANY(obs.source_memory_ids)
WHERE obs.id = ANY($1::uuid[])
AND mu.chunk_id IS NOT NULL
ORDER BY array_position($1::uuid[], obs.id)
""",
observation_ids_ordered,
)
for row in obs_source_rows:
obs_id = str(row["obs_id"])
cid = row["chunk_id"]
if cid not in seen_chunk_ids:
obs_chunk_ids.setdefault(obs_id, []).append(cid)
seen_chunk_ids.add(cid)
# Flatten ordered_items into chunk_ids_ordered, expanding obs placeholders
chunk_ids_ordered = []
for item_type, item_id in ordered_items:
if item_type == "chunk":
chunk_ids_ordered.append(item_id)
else:
chunk_ids_ordered.extend(obs_chunk_ids.get(item_id, []))
if chunk_ids_ordered:
# Estimate batch size based on retain_chunk_size * 2 (rough estimate)
# Chunk sizes vary per document, so we fetch in batches until budget is exhausted
bank_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
estimated_batch_size = max(1, (max_chunk_tokens // bank_config.retain_chunk_size) * 2)
chunks_dict = {}
encoding = _get_tiktoken_encoding()
chunk_offset = 0
# Fetch chunks in batches until we run out of budget or chunks
while chunk_offset < len(chunk_ids_ordered) and total_chunk_tokens < max_chunk_tokens:
# Get next batch of chunk IDs
batch_chunk_ids = chunk_ids_ordered[chunk_offset : chunk_offset + estimated_batch_size]
chunk_offset += estimated_batch_size
# Fetch all candidate chunks in a single query. Token-budget accounting
# happens in Python after the fetch — one round-trip is always faster
# than multiple batched round-trips when the candidate set is large.
async with acquire_with_retry(pool) as conn:
chunks_rows = await conn.fetch(
f"""
SELECT chunk_id, chunk_text, chunk_index
FROM {fq_table("chunks")}
WHERE chunk_id = ANY($1::text[])
""",
chunk_ids_ordered,
)
# Fetch chunk data from database
async with acquire_with_retry(pool) as conn:
chunks_rows = await conn.fetch(
f"""
SELECT chunk_id, chunk_text, chunk_index
FROM {fq_table("chunks")}
WHERE chunk_id = ANY($1::text[])
""",
batch_chunk_ids,
)
chunks_lookup = {row["chunk_id"]: row for row in chunks_rows}
# Create a lookup dict for fast access (preserves order from batch_chunk_ids)
chunks_lookup = {row["chunk_id"]: row for row in chunks_rows}
# Process chunks in relevance order, respecting token budget
for chunk_id in chunk_ids_ordered:
if chunk_id not in chunks_lookup:
continue
# Process chunks in order, respecting token budget
for chunk_id in batch_chunk_ids:
if chunk_id not in chunks_lookup:
continue
row = chunks_lookup[chunk_id]
chunk_text = row["chunk_text"]
chunk_tokens = len(encoding.encode(chunk_text))
row = chunks_lookup[chunk_id]
chunk_text = row["chunk_text"]
chunk_tokens = len(encoding.encode(chunk_text))
# Check if adding this chunk would exceed the limit
if total_chunk_tokens + chunk_tokens > max_chunk_tokens:
# Truncate the chunk to fit within the remaining budget
remaining_tokens = max_chunk_tokens - total_chunk_tokens
if remaining_tokens > 0:
# Truncate to remaining tokens
truncated_text = encoding.decode(encoding.encode(chunk_text)[:remaining_tokens])
chunks_dict[chunk_id] = ChunkInfo(
chunk_text=truncated_text, chunk_index=row["chunk_index"], truncated=True
)
total_chunk_tokens = max_chunk_tokens
# Budget exhausted - stop fetching more batches
break
else:
if total_chunk_tokens + chunk_tokens > max_chunk_tokens:
remaining_tokens = max_chunk_tokens - total_chunk_tokens
if remaining_tokens > 0:
truncated_text = encoding.decode(encoding.encode(chunk_text)[:remaining_tokens])
chunks_dict[chunk_id] = ChunkInfo(
chunk_text=chunk_text, chunk_index=row["chunk_index"], truncated=False
chunk_text=truncated_text, chunk_index=row["chunk_index"], truncated=True
)
total_chunk_tokens += chunk_tokens
# If we hit the budget limit in this batch, stop fetching more batches
if total_chunk_tokens >= max_chunk_tokens:
total_chunk_tokens = max_chunk_tokens
break
else:
chunks_dict[chunk_id] = ChunkInfo(
chunk_text=chunk_text, chunk_index=row["chunk_index"], truncated=False
)
total_chunk_tokens += chunk_tokens
# Step 6: Token budget filtering
step_start = time.time()
@@ -4142,6 +4324,8 @@ class MemoryEngine(MemoryEngineInterface):
bank_id: str,
*,
search_query: str | None = None,
tags: list[str] | None = None,
tags_match: "TagsMatch" = "any_strict",
limit: int = 100,
offset: int = 0,
request_context: "RequestContext",
@@ -4152,6 +4336,8 @@ class MemoryEngine(MemoryEngineInterface):
Args:
bank_id: bank ID (required)
search_query: Search in document ID
tags: Filter by tags
tags_match: How to match tags (any, all, any_strict, all_strict)
limit: Maximum number of results
offset: Offset for pagination
request_context: Request context for authentication.
@@ -4182,7 +4368,16 @@ class MemoryEngine(MemoryEngineInterface):
query_conditions.append(f"id ILIKE ${param_count}")
query_params.append(f"%{search_query}%")
tags_clause, tags_params, next_param = build_tags_where_clause(
tags, param_offset=param_count + 1, match=tags_match
)
query_params.extend(tags_params)
param_count = next_param - 1 # next_param is next available; convert to last used
where_clause = "WHERE " + " AND ".join(query_conditions) if query_conditions else ""
if tags_clause:
# tags_clause starts with "AND", append after WHERE conditions
where_clause = where_clause + " " + tags_clause if where_clause else "WHERE " + tags_clause[4:].lstrip()
# Get total count
count_query = f"""
@@ -4565,6 +4760,8 @@ class MemoryEngine(MemoryEngineInterface):
# The agent can call lookup() to list available models if needed.
# This is critical for banks with many mental models to avoid huge prompts.
resolved_reflect_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
# Compute max iterations based on budget
config = get_config()
base_max_iterations = config.reflect_max_iterations
@@ -4667,7 +4864,7 @@ class MemoryEngine(MemoryEngineInterface):
try:
agent_result = await run_reflect_agent(
llm_config=self._reflect_llm_config,
llm_config=self._reflect_llm_config.with_config(resolved_reflect_config),
bank_id=bank_id,
query=query,
bank_profile=profile,
@@ -5104,31 +5301,8 @@ class MemoryEngine(MemoryEngineInterface):
bank_id,
)
# Get link counts by link_type
link_stats = await conn.fetch(
f"""
SELECT ml.link_type, COUNT(*) as count
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
WHERE mu.bank_id = $1
GROUP BY ml.link_type
""",
bank_id,
)
# Get link counts by fact_type (from nodes)
link_fact_type_stats = await conn.fetch(
f"""
SELECT mu.fact_type, COUNT(*) as count
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
WHERE mu.bank_id = $1
GROUP BY mu.fact_type
""",
bank_id,
)
# Get link counts by fact_type AND link_type
# Single query for all link stats — avoids triple join on memory_links (can be 21M+ rows).
# link_counts and link_counts_by_fact_type are derived in Python from the breakdown.
link_breakdown_stats = await conn.fetch(
f"""
SELECT mu.fact_type, ml.link_type, COUNT(*) as count
@@ -5140,7 +5314,14 @@ class MemoryEngine(MemoryEngineInterface):
bank_id,
)
# Get pending and failed operations counts
link_counts: dict[str, int] = {}
link_counts_by_fact_type: dict[str, int] = {}
for row in link_breakdown_stats:
link_counts[row["link_type"]] = link_counts.get(row["link_type"], 0) + row["count"]
link_counts_by_fact_type[row["fact_type"]] = (
link_counts_by_fact_type.get(row["fact_type"], 0) + row["count"]
)
ops_stats = await conn.fetch(
f"""
SELECT status, COUNT(*) as count
@@ -5150,17 +5331,39 @@ class MemoryEngine(MemoryEngineInterface):
""",
bank_id,
)
doc_count_row = await conn.fetchrow(
f"SELECT COUNT(*) as count FROM {fq_table('documents')} WHERE bank_id = $1",
bank_id,
)
consolidation_row = await conn.fetchrow(
f"""
SELECT
MAX(consolidated_at) as last_consolidated_at,
COUNT(*) FILTER (WHERE consolidated_at IS NULL AND fact_type IN ('experience', 'world')) as pending
FROM {fq_table("memory_units")}
WHERE bank_id = $1
""",
bank_id,
)
node_counts = {row["fact_type"]: row["count"] for row in node_stats}
ops_by_status = {row["status"]: row["count"] for row in ops_stats}
last_consolidated_at = consolidation_row["last_consolidated_at"] if consolidation_row else None
return {
"bank_id": bank_id,
"node_counts": {row["fact_type"]: row["count"] for row in node_stats},
"link_counts": {row["link_type"]: row["count"] for row in link_stats},
"link_counts_by_fact_type": {row["fact_type"]: row["count"] for row in link_fact_type_stats},
"node_counts": node_counts,
"link_counts": link_counts,
"link_counts_by_fact_type": link_counts_by_fact_type,
"link_breakdown": [
{"fact_type": row["fact_type"], "link_type": row["link_type"], "count": row["count"]}
for row in link_breakdown_stats
],
"operations": {row["status"]: row["count"] for row in ops_stats},
"operations": ops_by_status,
"total_documents": doc_count_row["count"] if doc_count_row else 0,
"last_consolidated_at": last_consolidated_at.isoformat() if last_consolidated_at else None,
"pending_consolidation": consolidation_row["pending"] if consolidation_row else 0,
"total_observations": node_counts.get("observation", 0),
}
async def get_entity(
@@ -6038,8 +6241,6 @@ class MemoryEngine(MemoryEngineInterface):
async with acquire_with_retry(pool) as conn:
# Build filters
from .search.tags import build_tags_where_clause
filters = ["bank_id = $1"]
params: list[Any] = [bank_id]
param_idx = 2
@@ -11,6 +11,7 @@ import json
import logging
import os
import time
from contextvars import ContextVar
from typing import Any
from google import genai
@@ -24,6 +25,12 @@ from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
# Per-request Gemini safety settings override.
# Set exclusively by ConfiguredLLMProvider.call() / call_with_tools() via token-based
# set/reset, so it is properly scoped to each individual LLM call and never leaks.
_safety_settings_ctx: ContextVar[list | None] = ContextVar("gemini_safety_settings", default=None)
# Vertex AI imports (optional)
try:
import google.auth
@@ -58,6 +65,9 @@ class GeminiLLM(LLMInterface):
self._client = None
self._is_vertexai = self.provider == "vertexai"
# Safety settings: None means use Gemini's defaults
self._safety_settings: list | None = kwargs.get("gemini_safety_settings")
if self._is_vertexai:
self._init_vertexai(**kwargs)
else:
@@ -216,6 +226,16 @@ class GeminiLLM(LLMInterface):
if temperature is not None:
config_kwargs["temperature"] = temperature
# Apply safety settings: context var (per-request bank override) takes precedence over instance default
effective_safety_settings = _safety_settings_ctx.get()
if effective_safety_settings is None:
effective_safety_settings = self._safety_settings
if effective_safety_settings is not None:
config_kwargs["safety_settings"] = [
genai_types.SafetySetting(category=s["category"], threshold=s["threshold"])
for s in effective_safety_settings
]
generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
last_exception = None
@@ -489,6 +509,16 @@ class GeminiLLM(LLMInterface):
)
# "auto" is the default (no tool_config needed)
# Apply safety settings: context var (per-request bank override) takes precedence over instance default
effective_safety_settings = _safety_settings_ctx.get()
if effective_safety_settings is None:
effective_safety_settings = self._safety_settings
if effective_safety_settings is not None:
config_kwargs["safety_settings"] = [
genai_types.SafetySetting(category=s["category"], threshold=s["threshold"])
for s in effective_safety_settings
]
config = genai_types.GenerateContentConfig(**config_kwargs)
last_exception = None
@@ -6,6 +6,7 @@ without making actual API calls to external LLM services.
"""
import logging
from collections.abc import Callable
from typing import Any
from ..llm_interface import LLMInterface
@@ -66,6 +67,7 @@ class MockLLM(LLMInterface):
self._mock_calls: list[dict] = []
self._mock_response: Any = None
self._mock_exception: Exception | None = None
self._response_callback: Callable[[list[dict], str], Any] | None = None
async def verify_connection(self) -> None:
"""
@@ -147,7 +149,9 @@ class MockLLM(LLMInterface):
)
# Return mock response
if self._mock_response is not None:
if self._response_callback is not None:
result = self._response_callback(messages, scope)
elif self._mock_response is not None:
result = self._mock_response
elif response_format is not None:
# Try to create a minimal valid instance of the response format
@@ -214,7 +218,15 @@ class MockLLM(LLMInterface):
span_recorder = get_span_recorder()
if self._mock_response is not None:
if self._response_callback is not None:
cb_result = self._response_callback(messages, scope)
if isinstance(cb_result, LLMToolCallResult):
result = cb_result
else:
result = LLMToolCallResult(
content=str(cb_result) if cb_result is not None else "mock response", finish_reason="stop"
)
elif self._mock_response is not None:
if isinstance(self._mock_response, LLMToolCallResult):
result = self._mock_response
elif isinstance(self._mock_response, list):
@@ -258,6 +270,16 @@ class MockLLM(LLMInterface):
"""Clean up resources (no-op for mock provider)."""
pass
def set_response_callback(self, fn: Callable[[list[dict], str], Any]) -> None:
"""
Set a callback invoked on each call() instead of _mock_response.
The callback receives (messages, scope) and returns the response.
Useful for returning different responses per call (e.g., cycling
through a corpus in a benchmark).
"""
self._response_callback = fn
def set_mock_response(self, response: Any) -> None:
"""
Set the response to return from mock calls.
@@ -92,11 +92,17 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
self._search_dates = None
def load(self) -> None:
"""Load dateparser (lazy import)."""
"""Load dateparser and warm up internal data structures.
Triggers the real initialization cost (regex tables, timezone data) at
load time so the first actual recall doesn't pay the cold-start penalty.
"""
if self._search_dates is None:
from dateparser.search import search_dates
self._search_dates = search_dates
# Warm up: fire a dummy call to trigger lazy-loaded internal tables.
self._search_dates("today")
def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAnalysis:
"""
@@ -431,7 +431,9 @@ async def run_reflect_agent(
if is_last:
# Force text response on last iteration - no tools
prompt = build_final_prompt(query, context_history, bank_profile, context, max_context_tokens=max_context_tokens)
prompt = build_final_prompt(
query, context_history, bank_profile, context, max_context_tokens=max_context_tokens
)
llm_start = time.time()
response, usage = await llm_config.call(
messages=[
@@ -486,7 +488,9 @@ async def run_reflect_agent(
f"[REFLECT {reflect_id}] Context budget exceeded on iteration {iteration + 1}: "
f"~{estimated_tokens} tokens >= {max_context_tokens} limit. Forcing final synthesis."
)
prompt = build_final_prompt(query, context_history, bank_profile, context, max_context_tokens=max_context_tokens)
prompt = build_final_prompt(
query, context_history, bank_profile, context, max_context_tokens=max_context_tokens
)
llm_start = time.time()
response, usage = await llm_config.call(
messages=[
@@ -588,7 +592,9 @@ async def run_reflect_agent(
# For other errors: retry if no evidence yet (but cap consecutive errors to avoid long hangs)
elif not has_gathered_evidence and iteration < max_iterations - 1 and consecutive_errors < 2:
continue
prompt = build_final_prompt(query, context_history, bank_profile, context, max_context_tokens=max_context_tokens)
prompt = build_final_prompt(
query, context_history, bank_profile, context, max_context_tokens=max_context_tokens
)
llm_start = time.time()
response, usage = await llm_config.call(
messages=[
@@ -659,7 +665,9 @@ async def run_reflect_agent(
directives_applied=directives_applied,
)
# Empty response, force final
prompt = build_final_prompt(query, context_history, bank_profile, context, max_context_tokens=max_context_tokens)
prompt = build_final_prompt(
query, context_history, bank_profile, context, max_context_tokens=max_context_tokens
)
llm_start = time.time()
response, usage = await llm_config.call(
messages=[
@@ -5,7 +5,6 @@ This package contains modular components for the retain operation:
- types: Type definitions for retain pipeline
- fact_extraction: Extract facts from content
- embedding_processing: Augment texts and generate embeddings
- deduplication: Check for duplicate facts
- entity_processing: Process and resolve entities
- link_creation: Create temporal, semantic, entity, and causal links
- chunk_storage: Handle chunk storage
@@ -14,7 +13,6 @@ This package contains modular components for the retain operation:
from . import (
chunk_storage,
deduplication,
embedding_processing,
entity_processing,
fact_extraction,
@@ -35,7 +33,6 @@ __all__ = [
# Modules
"fact_extraction",
"embedding_processing",
"deduplication",
"entity_processing",
"link_creation",
"chunk_storage",
@@ -1,85 +0,0 @@
"""
Deduplication logic for retain pipeline.
Checks for duplicate facts using semantic similarity and temporal proximity.
"""
import logging
from collections import defaultdict
from datetime import UTC
from .types import ProcessedFact
logger = logging.getLogger(__name__)
async def check_duplicates_batch(conn, bank_id: str, facts: list[ProcessedFact], duplicate_checker_fn) -> list[bool]:
"""
Check which facts are duplicates using batched time-window queries.
Groups facts by 12-hour time buckets to efficiently check for duplicates
within a 24-hour window.
Args:
conn: Database connection
bank_id: Bank identifier
facts: List of ProcessedFact objects to check
duplicate_checker_fn: Async function(conn, bank_id, texts, embeddings, date, time_window_hours)
that returns List[bool] indicating duplicates
Returns:
List of boolean flags (same length as facts) indicating if each fact is a duplicate
"""
if not facts:
return []
# Group facts by event_date (rounded to 12-hour buckets) for efficient batching
time_buckets = defaultdict(list)
for idx, fact in enumerate(facts):
# Use occurred_start if available, otherwise use mentioned_at
# For deduplication purposes, we need a time reference
fact_date = fact.occurred_start if fact.occurred_start is not None else fact.mentioned_at
# Defensive: if both are None (shouldn't happen), use now()
if fact_date is None:
from datetime import datetime
fact_date = datetime.now(UTC)
# Round to 12-hour bucket to group similar times
bucket_key = fact_date.replace(hour=(fact_date.hour // 12) * 12, minute=0, second=0, microsecond=0)
time_buckets[bucket_key].append((idx, fact))
# Process each bucket in batch
all_is_duplicate = [False] * len(facts)
for bucket_date, bucket_items in time_buckets.items():
indices = [item[0] for item in bucket_items]
texts = [item[1].fact_text for item in bucket_items]
embeddings = [item[1].embedding for item in bucket_items]
# Check duplicates for this time bucket
dup_flags = await duplicate_checker_fn(conn, bank_id, texts, embeddings, bucket_date, time_window_hours=24)
# Map results back to original indices
for idx, is_dup in zip(indices, dup_flags):
all_is_duplicate[idx] = is_dup
return all_is_duplicate
def filter_duplicates(facts: list[ProcessedFact], is_duplicate_flags: list[bool]) -> list[ProcessedFact]:
"""
Filter out duplicate facts based on duplicate flags.
Args:
facts: List of ProcessedFact objects
is_duplicate_flags: Boolean flags indicating which facts are duplicates
Returns:
List of non-duplicate facts
"""
if len(facts) != len(is_duplicate_flags):
raise ValueError(f"Mismatch between facts ({len(facts)}) and flags ({len(is_duplicate_flags)})")
return [fact for fact, is_dup in zip(facts, is_duplicate_flags) if not is_dup]
@@ -27,14 +27,21 @@ def augment_texts_with_dates(facts: list[ExtractedFact], format_date_fn) -> list
"""
augmented_texts = []
for fact in facts:
# Use occurred_start as the representative date
# Use occurred_start as the representative date, fall back to mentioned_at
fact_date = fact.occurred_start or fact.mentioned_at
# Augment text with date and entity names for embedding (but store original text in DB)
# Entity names (including key:value labels) improve retrieval without polluting stored content
if fact_date is not None:
readable_date = format_date_fn(fact_date)
# Augment text with date for embedding (but store original text in DB)
augmented_text = f"{fact.fact_text} (happened in {readable_date})"
if fact.occurred_end and fact.occurred_end != fact.occurred_start:
readable_end = format_date_fn(fact.occurred_end)
augmented_text = f"{fact.fact_text} (happened from {readable_date} to {readable_end})"
else:
augmented_text = f"{fact.fact_text} (happened in {readable_date})"
else:
augmented_text = fact.fact_text
if fact.entities:
augmented_text = f"{augmented_text} [{', '.join(fact.entities)}]"
augmented_texts.append(augmented_text)
return augmented_texts
@@ -41,10 +41,9 @@ async def generate_embeddings_batch(embeddings_backend, texts: list[str]) -> lis
List of embeddings in same order as input texts
"""
try:
# Run embeddings in thread pool to avoid blocking event loop
loop = asyncio.get_event_loop()
embeddings = await loop.run_in_executor(
None, # Use default thread pool
None,
embeddings_backend.encode,
texts,
)
@@ -0,0 +1,194 @@
"""
Entity labels models and helpers for retain pipeline.
Defines a controlled vocabulary of key:value classification labels
(e.g., 'pedagogy:scaffolding', 'interest:active') that are extracted
at retain time and stored as entities.
"""
from typing import Literal
from pydantic import BaseModel, Field, create_model
class LabelValue(BaseModel):
"""A single allowed value for a label group."""
value: str
description: str = ""
class LabelGroup(BaseModel):
"""A label group (dimension) with its type and allowed values."""
key: str
description: str = ""
type: Literal["value", "multi-values", "text"] = "value"
optional: bool = True
tag: bool = False
values: list[LabelValue] = []
class EntityLabelsConfig(BaseModel):
"""Entity labels configuration for a bank (controlled vocabulary)."""
attributes: list[LabelGroup] = []
def parse_entity_labels(raw: dict | list | None) -> EntityLabelsConfig | None:
"""
Parse raw entity labels config into EntityLabelsConfig.
Accepts:
- None → returns None
- list → list of attribute dicts (each may use legacy free_values/multi_value or new type field)
- dict → {attributes: [...]}
Legacy migration (backward-compat):
- free_values=True → type="text"
- multi_value=True → type="multi-values"
- neither / free_values=False → type="value"
Args:
raw: Raw entity labels config from bank config
Returns:
EntityLabelsConfig or None if raw is None/empty
"""
if raw is None:
return None
if isinstance(raw, list):
if not raw:
return None
attributes = [LabelGroup.model_validate(_migrate_label_group(a)) for a in raw]
return EntityLabelsConfig(attributes=attributes)
if isinstance(raw, dict):
attrs_raw = raw.get("attributes", [])
if not attrs_raw:
return None
attributes = [LabelGroup.model_validate(_migrate_label_group(a)) for a in attrs_raw]
return EntityLabelsConfig(attributes=attributes)
return None
def _migrate_label_group(raw: dict) -> dict:
"""Migrate legacy free_values/multi_value fields to the new type field."""
if not isinstance(raw, dict) or "type" in raw:
return raw
patched = dict(raw)
if patched.get("free_values"):
patched["type"] = "text"
elif patched.get("multi_value"):
patched["type"] = "multi-values"
else:
patched["type"] = "value"
# Remove legacy keys so Pydantic doesn't error on unknown fields
patched.pop("free_values", None)
patched.pop("multi_value", None)
return patched
def build_labels_model(labels_cfg: EntityLabelsConfig) -> type[BaseModel] | None:
"""
Build a dynamic Pydantic model for structured label extraction.
Each LabelGroup becomes a typed field based on its type:
- type="text" → str | None (always optional)
- type="value", optional=True → Literal["v1","v2"] | None
- type="value", optional=False → Literal["v1","v2"] (required)
- type="multi-values" → list[Literal["v1","v2"]]
Args:
labels_cfg: Parsed EntityLabelsConfig
Returns:
Dynamic Pydantic model class, or None if no groups defined
"""
fields: dict = {}
for group in labels_cfg.attributes:
if not group.key:
continue
description = group.description or group.key
if group.type == "text":
# Free-form: any string value accepted, always optional
fields[group.key] = (str | None, Field(default=None, description=description))
else:
# Enum-constrained: must have defined values
if not group.values:
continue
values = tuple(v.value for v in group.values if v.value)
if not values:
continue
# Literal[("v1", "v2")] is equivalent to Literal["v1", "v2"] in Python 3.11+
literal_type = Literal[values] # type: ignore[valid-type]
if group.type == "multi-values":
fields[group.key] = (
list[literal_type], # type: ignore[valid-type]
Field(default_factory=list, description=description),
)
elif group.optional:
fields[group.key] = (
literal_type | None, # type: ignore[valid-type]
Field(default=None, description=description),
)
else:
fields[group.key] = (
literal_type, # type: ignore[valid-type]
Field(description=description),
)
if not fields:
return None
return create_model("Labels", **fields)
def is_label_entity(text: str, labels_cfg: EntityLabelsConfig, labels_lookup: set[str]) -> bool:
"""
Return True if entity text belongs to any configured label group.
For enum groups: checks the pre-built lookup set.
For text groups: checks that the text starts with a known key prefix.
"""
if text.lower() in labels_lookup:
return True
for group in labels_cfg.attributes:
if group.type == "text" and group.key and text.lower().startswith(f"{group.key.lower()}:"):
return True
return False
def build_labels_lookup(labels_cfg: EntityLabelsConfig | list | None) -> set[str]:
"""
Build a set of valid 'key:value' label strings (lowercase) for fast lookup.
Accepts either EntityLabelsConfig or raw list/None for backwards compatibility.
Args:
labels_cfg: EntityLabelsConfig, raw list of attribute dicts, or None
Returns:
Set of lowercase 'key:value' strings
"""
if labels_cfg is None:
return set()
# Accept raw list/dict for backwards compatibility
if not isinstance(labels_cfg, EntityLabelsConfig):
parsed = parse_entity_labels(labels_cfg)
if parsed is None:
return set()
labels_cfg = parsed
valid = set()
for group in labels_cfg.attributes:
if group.type == "text":
continue # No fixed vocabulary — all values accepted in post-processing
for v in group.values:
if group.key and v.value:
valid.add(f"{group.key}:{v.value}".lower())
return valid
@@ -20,6 +20,7 @@ async def process_entities_batch(
facts: list[ProcessedFact],
log_buffer: list[str] = None,
user_entities_per_content: dict[int, list[dict]] = None,
entity_labels: list | None = None,
) -> list[EntityLink]:
"""
Process entities for all facts and create entity links.
@@ -90,6 +91,7 @@ async def process_entities_batch(
fact_dates,
entities_per_fact,
log_buffer, # Pass log_buffer for detailed logging
entity_labels=entity_labels,
)
return entity_links
@@ -10,13 +10,20 @@ import json
import logging
import re
from datetime import datetime, timedelta
from typing import Literal
from typing import Literal, cast
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, create_model, field_validator
from ...config import get_config
from ..llm_wrapper import LLMConfig, OutputTooLongError
from ..response_models import TokenUsage
from .entity_labels import (
EntityLabelsConfig,
build_labels_lookup,
build_labels_model,
is_label_entity,
parse_entity_labels,
)
def _infer_temporal_date(fact_text: str, event_date: datetime | None) -> str | None:
@@ -692,10 +699,62 @@ Example: "Lost job → couldn't pay rent → moved apartment"
- Fact 2: Moved apartment, causal_relations: [{target_index: 1, relation_type: "caused_by"}]"""
def _build_labels_prompt_section(labels_cfg: EntityLabelsConfig | list | None, free_form_entities: bool = True) -> str:
"""Build the entity labels classification section for the extraction prompt."""
if labels_cfg is None:
return ""
# Accept raw list for backwards compatibility
if isinstance(labels_cfg, list):
if not labels_cfg:
return ""
labels_cfg = parse_entity_labels(labels_cfg)
if labels_cfg is None:
return ""
if not labels_cfg.attributes:
return ""
if free_form_entities:
entities_instruction = "Classify each fact using the structured 'labels' field below. Continue extracting regular named entities in the 'entities' field."
else:
entities_instruction = "Classify each fact using the structured 'labels' field below. Do NOT add regular named entities — labels-only mode."
lines = [
"\n\n══════════════════════════════════════════════════════════════════════════",
"ENTITY LABELS - CLASSIFICATION ATTRIBUTES",
"══════════════════════════════════════════════════════════════════════════",
"",
entities_instruction,
"",
"For each fact, fill the 'labels' object. Each field is a label group:",
"",
]
for attr in labels_cfg.attributes:
if attr.type == "text":
# Free-text: no predefined values — LLM writes any relevant string or null
lines.append(f"- {attr.key} (free text or null): {attr.description}")
else:
mode = "multi-value (list)" if attr.type == "multi-values" else "single value or null"
lines.append(f"- {attr.key} ({mode}): {attr.description}")
for v in attr.values:
desc = f"{v.description}" if v.description else ""
lines.append(f'"{v.value}"{desc}')
lines.append("")
lines.append("Only assign labels when clearly applicable. Leave null/empty if the fact does not match.")
return "\n".join(lines)
def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
"""
Build extraction prompt and response schema based on config.
When a taxonomy is configured, dynamically builds a Pydantic model with a
typed `taxonomy_entities` field using an Enum built from valid taxonomy values.
This enables JSON schema enforcement for structured outputs.
Returns:
Tuple of (prompt, response_schema)
"""
@@ -738,9 +797,53 @@ def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
# Add causal relationships section if enabled
if extract_causal_links:
prompt = prompt + CAUSAL_RELATIONSHIPS_SECTION
response_schema = FactExtractionResponseVerbose if extraction_mode == "verbose" else FactExtractionResponse
base_fact_class = ExtractedFactVerbose if extraction_mode == "verbose" else ExtractedFact
base_response_class = FactExtractionResponseVerbose if extraction_mode == "verbose" else FactExtractionResponse
else:
response_schema = FactExtractionResponseNoCausal
base_fact_class = ExtractedFactNoCausal
base_response_class = FactExtractionResponseNoCausal
# Add entity labels section if configured and build dynamic schema
entity_labels_raw = getattr(config, "entity_labels", None)
labels_cfg = parse_entity_labels(entity_labels_raw)
free_form_entities = getattr(config, "entities_allow_free_form", True)
labels_section = _build_labels_prompt_section(labels_cfg, free_form_entities)
if labels_section:
prompt = prompt + labels_section
response_schema = base_response_class
if labels_cfg and labels_cfg.attributes:
LabelsModel = build_labels_model(labels_cfg)
if LabelsModel is not None:
dynamic_fields: dict = {
"labels": (
LabelsModel,
Field(
description="Classification labels for this fact. Fill each applicable field; leave others null/empty."
),
)
}
if not free_form_entities:
dynamic_fields["entities"] = (
list[Entity] | None,
Field(default=None, description="Leave empty — labels-only mode"),
)
# Inherit parent's required fields and add 'labels' so it appears in the JSON schema
# required array (the base class json_schema_extra overrides required entirely)
base_extra = base_fact_class.model_config.get("json_schema_extra")
base_required = cast(dict, base_extra).get("required", []) if isinstance(base_extra, dict) else []
DynamicFact = create_model(
"LabelsFact",
__base__=base_fact_class,
__config__=ConfigDict(
json_schema_mode="validation",
json_schema_extra={"required": [*base_required, "labels"]},
),
**dynamic_fields,
)
DynamicResponse = create_model("LabelsResponse", facts=(list[DynamicFact], ...)) # type: ignore[valid-type]
response_schema = DynamicResponse
return prompt, response_schema
@@ -841,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
@@ -866,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
@@ -880,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
@@ -997,9 +1099,9 @@ async def _extract_facts_from_chunk(
# Add entities if present (validate as Entity objects)
# LLM sometimes returns strings instead of {"text": "..."} format
entities = get_value("entities")
validated_entities = []
if entities:
# Validate and normalize each entity
validated_entities = []
for ent in entities:
if isinstance(ent, str):
# Normalize string to Entity object
@@ -1009,8 +1111,48 @@ async def _extract_facts_from_chunk(
validated_entities.append(Entity.model_validate(ent))
except Exception as e:
logger.warning(f"Invalid entity {ent}: {e}")
if validated_entities:
fact_data["entities"] = validated_entities
# Post-process label entities from structured labels object
entity_labels_raw = getattr(config, "entity_labels", None)
labels_cfg = parse_entity_labels(entity_labels_raw)
free_form_entities = getattr(config, "entities_allow_free_form", True)
if labels_cfg and labels_cfg.attributes:
labels_lookup = build_labels_lookup(labels_cfg)
labels_data = llm_fact.get("labels") or {}
if isinstance(labels_data, dict):
existing_texts_lower = {e.text.lower() for e in validated_entities}
for group in labels_cfg.attributes:
value = labels_data.get(group.key)
if not value:
continue
values_list = value if isinstance(value, list) else [value]
for v in values_list:
if not isinstance(v, str) or not v.strip() or v.lower() in ("none", "null", "n/a"):
continue
label_str = f"{group.key}:{v.strip()}"
if group.type == "text":
if label_str.lower() not in existing_texts_lower:
validated_entities.append(Entity(text=label_str))
existing_texts_lower.add(label_str.lower())
elif (
label_str.lower() in labels_lookup and label_str.lower() not in existing_texts_lower
):
validated_entities.append(Entity(text=label_str))
existing_texts_lower.add(label_str.lower())
else:
logger.warning(f"Label '{label_str}' not in valid label values, skipping")
# In labels-only mode, keep only label entities
if not free_form_entities:
validated_entities = [
e for e in validated_entities if is_label_entity(e.text, labels_cfg, labels_lookup)
]
elif not free_form_entities:
# No labels but free_form disabled: clear all entities
validated_entities = []
if validated_entities:
fact_data["entities"] = validated_entities
# Add per-fact causal relations (only if enabled in config)
if extract_causal_links:
@@ -1063,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
@@ -1098,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(
@@ -1606,8 +1750,8 @@ async def extract_facts_from_contents_batch_api(
# Entities
entities = get_value("entities")
validated_entities = []
if entities:
validated_entities = []
for ent in entities:
if isinstance(ent, str):
validated_entities.append(Entity(text=ent))
@@ -1616,8 +1760,45 @@ async def extract_facts_from_contents_batch_api(
validated_entities.append(Entity.model_validate(ent))
except Exception:
pass
if validated_entities:
fact_data["entities"] = validated_entities
# Post-process label entities from structured labels object
entity_labels_raw = getattr(config, "entity_labels", None)
labels_cfg_batch = parse_entity_labels(entity_labels_raw)
free_form_entities_batch = getattr(config, "entities_allow_free_form", True)
if labels_cfg_batch and labels_cfg_batch.attributes:
labels_lookup_batch = build_labels_lookup(labels_cfg_batch)
labels_data = llm_fact.get("labels") or {}
if isinstance(labels_data, dict):
existing_texts_lower = {e.text.lower() for e in validated_entities}
for group in labels_cfg_batch.attributes:
value = labels_data.get(group.key)
if not value:
continue
values_list = value if isinstance(value, list) else [value]
for v in values_list:
if not isinstance(v, str) or not v.strip() or v.lower() in ("none", "null", "n/a"):
continue
label_str = f"{group.key}:{v.strip()}"
if group.type == "text":
if label_str.lower() not in existing_texts_lower:
validated_entities.append(Entity(text=label_str))
existing_texts_lower.add(label_str.lower())
elif (
label_str.lower() in labels_lookup_batch
and label_str.lower() not in existing_texts_lower
):
validated_entities.append(Entity(text=label_str))
existing_texts_lower.add(label_str.lower())
if not free_form_entities_batch:
validated_entities = [
e for e in validated_entities if is_label_entity(e.text, labels_cfg_batch, labels_lookup_batch)
]
elif not free_form_entities_batch:
validated_entities = []
if validated_entities:
fact_data["entities"] = validated_entities
# Causal relations
if extract_causal_links:
@@ -1718,6 +1899,9 @@ async def extract_facts_from_contents_batch_api(
# Step 7: Add temporal offsets
_add_temporal_offsets(extracted_facts, contents)
# Step 8: Auto-tag facts from label groups with tag=True
_inject_label_tags(extracted_facts, config)
logger.info(f"Batch API extracted {len(extracted_facts)} facts from {len(all_chunks_info)} chunks")
return extracted_facts, chunks_metadata, total_usage
@@ -1850,6 +2034,9 @@ async def extract_facts_from_contents(
# Step 4: Add time offsets to preserve ordering within each content
_add_temporal_offsets(extracted_facts, contents)
# Step 5: Auto-tag facts from label groups with tag=True
_inject_label_tags(extracted_facts, config)
return extracted_facts, chunks_metadata, total_usage
@@ -1905,3 +2092,24 @@ def _add_temporal_offsets(facts: list[ExtractedFactType], contents: list[RetainC
fact.occurred_end = parse_datetime_flexible(fact.occurred_end) + offset
if fact.mentioned_at:
fact.mentioned_at = parse_datetime_flexible(fact.mentioned_at) + offset
def _inject_label_tags(facts: list[ExtractedFactType], config) -> None:
"""
For label groups with tag=True, add extracted key:value label entities
to each fact's tags list. Modifies facts in place.
This lets entity labels double as tags, enabling filtering via the
existing tags API without any extra query infrastructure.
"""
labels_cfg = parse_entity_labels(getattr(config, "entity_labels", None))
if not labels_cfg:
return
tag_group_keys = {g.key.lower() for g in labels_cfg.attributes if g.tag}
if not tag_group_keys:
return
for fact in facts:
label_tags = [e for e in fact.entities if ":" in e and e.split(":", 1)[0].lower() in tag_group_keys]
if label_tags:
existing = set(fact.tags)
fact.tags = fact.tags + [t for t in label_tags if t not in existing]
@@ -48,6 +48,7 @@ async def insert_facts_batch(
document_ids = []
tags_list = []
observation_scopes_list = []
text_signals_list = []
for fact in facts:
fact_texts.append(_sanitize_text(fact.fact_text))
@@ -73,6 +74,15 @@ async def insert_facts_batch(
observation_scopes_list.append(
json.dumps(fact.observation_scopes) if fact.observation_scopes is not None else None
)
# Build text_signals: entity names + date tokens for enriched BM25 indexing
signal_parts = []
if fact.entities:
signal_parts.extend(e.name for e in fact.entities)
if fact.occurred_start:
signal_parts.append(fact.occurred_start.strftime("%B %-d %Y"))
if fact.occurred_end and fact.occurred_end != fact.occurred_start:
signal_parts.append(fact.occurred_end.strftime("%B %-d %Y"))
text_signals_list.append(" ".join(signal_parts) if signal_parts else None)
# Batch insert all facts
# Note: tags are passed as JSON strings and converted back to varchar[] via jsonb_array_elements_text + array_agg
@@ -80,18 +90,19 @@ async def insert_facts_batch(
config = get_config()
if config.text_search_extension == "vchord":
# VectorChord: manually tokenize and insert search_vector
# text_signals (entity names etc.) are included in the tokenize input for enriched BM25
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[], $15::jsonb[]
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[], $15::jsonb[], $16::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags_json,
observation_scopes_json)
observation_scopes_json, text_signals)
)
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags,
observation_scopes, search_vector)
observation_scopes, text_signals, search_vector)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
@@ -101,25 +112,29 @@ async def insert_facts_batch(
'{{}}'::varchar[]
),
observation_scopes_json,
tokenize(COALESCE(text, '') || ' ' || COALESCE(context, ''), 'llmlingua2')::bm25_catalog.bm25vector
text_signals,
tokenize(
COALESCE(text, '') || ' ' || COALESCE(context, '') || ' ' || COALESCE(text_signals, ''),
'llmlingua2'
)::bm25_catalog.bm25vector
FROM input_data
RETURNING id
"""
else: # native or pg_textsearch
# Native PostgreSQL: search_vector is GENERATED ALWAYS, don't include it
# Native PostgreSQL: search_vector is GENERATED ALWAYS (expression includes text_signals), don't include it
# pg_textsearch: indexes operate on base columns directly, don't populate search_vector
query = f"""
WITH input_data AS (
SELECT * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[], $15::jsonb[]
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[], $15::jsonb[], $16::text[]
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags_json,
observation_scopes_json)
observation_scopes_json, text_signals)
)
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags,
observation_scopes)
observation_scopes, text_signals)
SELECT
$1,
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
@@ -128,7 +143,8 @@ async def insert_facts_batch(
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
'{{}}'::varchar[]
),
observation_scopes_json
observation_scopes_json,
text_signals
FROM input_data
RETURNING id
"""
@@ -150,6 +166,7 @@ async def insert_facts_batch(
document_ids,
tags_list,
observation_scopes_list,
text_signals_list,
)
unit_ids = [str(row["id"]) for row in results]
@@ -150,6 +150,7 @@ async def extract_entities_batch_optimized(
fact_dates: list,
llm_entities: list[list[dict]],
log_buffer: list[str] = None,
entity_labels: list | None = None,
) -> list[tuple]:
"""
Process LLM-extracted entities for ALL facts in batch.
@@ -239,6 +240,7 @@ async def extract_entities_batch_optimized(
context=context,
unit_event_date=None, # Not used when per-entity dates provided
conn=conn, # Use main transaction connection
entity_labels=entity_labels,
)
_log(
@@ -496,14 +498,13 @@ async def create_temporal_links_batch_per_fact(
# Batch inserts to avoid timeout on large batches
BATCH_SIZE = 1000
for batch_start in range(0, len(links), BATCH_SIZE):
batch = links[batch_start : batch_start + BATCH_SIZE]
await conn.executemany(
f"""
INSERT INTO {fq_table("memory_links")} (from_unit_id, to_unit_id, link_type, weight, entity_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
""",
batch,
links[batch_start : batch_start + BATCH_SIZE],
)
_log(log_buffer, f" [7.4] Insert {len(links)} temporal links: {time_mod.time() - insert_start:.3f}s")
@@ -551,81 +552,45 @@ async def create_semantic_links_batch(
import numpy as np
# Fetch ALL existing units with embeddings in ONE query
fetch_start = time_mod.time()
all_existing = await conn.fetch(
f"""
SELECT id, embedding
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND embedding IS NOT NULL
AND id::text != ALL($2)
""",
bank_id,
unit_ids,
)
_log(
log_buffer,
f" [8.1] Fetch {len(all_existing)} existing embeddings (1 query): {time_mod.time() - fetch_start:.3f}s",
)
# Convert to numpy for vectorized similarity computation
compute_start = time_mod.time()
# Use pgvector ANN search (HNSW index) for each new unit instead of fetching
# all existing embeddings into Python. At large scale (100K+ units) the old
# approach would transfer 100K × 384 floats (~150 MB) per retain call; the
# ANN query completes in <5 ms and transfers only top_k rows.
ann_start = time_mod.time()
all_links = []
if all_existing:
# Convert existing embeddings to numpy array
existing_ids = [str(row["id"]) for row in all_existing]
# Stack embeddings as 2D array: (num_embeddings, embedding_dim)
embedding_arrays = []
for row in all_existing:
raw_emb = row["embedding"]
# Handle different pgvector formats
if isinstance(raw_emb, str):
# Parse string format: "[1.0, 2.0, ...]"
import json
# Build UUID exclude list once for all ANN queries
import uuid as uuid_mod
emb = np.array(json.loads(raw_emb), dtype=np.float32)
elif isinstance(raw_emb, (list, tuple)):
emb = np.array(raw_emb, dtype=np.float32)
else:
# Try direct conversion (works for numpy arrays, pgvector objects, etc.)
emb = np.array(raw_emb, dtype=np.float32)
exclude_uuids = [uuid_mod.UUID(uid) if isinstance(uid, str) else uid for uid in unit_ids]
# Ensure it's 1D
if emb.ndim != 1:
raise ValueError(f"Expected 1D embedding, got shape {emb.shape}")
embedding_arrays.append(emb)
for unit_id, new_embedding in zip(unit_ids, embeddings):
emb_str = str(list(new_embedding) if not isinstance(new_embedding, list) else new_embedding)
rows = await conn.fetch(
f"""
SELECT id::text,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND embedding IS NOT NULL
AND id != ALL($3::uuid[])
ORDER BY embedding <=> $1::vector
LIMIT $4
""",
emb_str,
bank_id,
exclude_uuids,
top_k,
)
for row in rows:
sim = float(min(1.0, max(0.0, row["similarity"])))
if sim >= threshold:
all_links.append((unit_id, str(row["id"]), "semantic", sim, None))
if not embedding_arrays:
existing_embeddings = np.array([])
elif len(embedding_arrays) == 1:
# Single embedding: reshape to (1, dim)
existing_embeddings = embedding_arrays[0].reshape(1, -1)
else:
# Multiple embeddings: vstack
existing_embeddings = np.vstack(embedding_arrays)
# For each new unit, compute similarities with ALL existing units
for unit_id, new_embedding in zip(unit_ids, embeddings):
new_emb_array = np.array(new_embedding)
# Compute cosine similarities (dot product for normalized vectors)
similarities = np.dot(existing_embeddings, new_emb_array)
# Find top-k above threshold
# Get indices of similarities above threshold
above_threshold = np.where(similarities >= threshold)[0]
if len(above_threshold) > 0:
# Sort by similarity (descending) and take top-k
sorted_indices = above_threshold[np.argsort(-similarities[above_threshold])][:top_k]
for idx in sorted_indices:
similar_id = existing_ids[idx]
# Clamp to [0, 1] to handle floating point precision issues
similarity = float(min(1.0, max(0.0, similarities[idx])))
all_links.append((unit_id, similar_id, "semantic", similarity, None))
_log(
log_buffer,
f" [8.1] ANN search for {len(unit_ids)} new units → {len(all_links)} candidate links: {time_mod.time() - ann_start:.3f}s",
)
# Also compute similarities WITHIN the new batch (new units to each other)
# Apply the same top_k limit per unit as we do for existing units
@@ -657,7 +622,7 @@ async def create_semantic_links_batch(
_log(
log_buffer,
f" [8.2] Compute similarities & generate {len(all_links)} semantic links: {time_mod.time() - compute_start:.3f}s",
f" [8.2] Within-batch similarities added {len(all_links)} total semantic links",
)
if all_links:
@@ -665,14 +630,13 @@ async def create_semantic_links_batch(
# Batch inserts to avoid timeout on large batches
BATCH_SIZE = 1000
for batch_start in range(0, len(all_links), BATCH_SIZE):
batch = all_links[batch_start : batch_start + BATCH_SIZE]
await conn.executemany(
f"""
INSERT INTO {fq_table("memory_links")} (from_unit_id, to_unit_id, link_type, weight, entity_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
""",
batch,
all_links[batch_start : batch_start + BATCH_SIZE],
)
_log(
log_buffer, f" [8.3] Insert {len(all_links)} semantic links: {time_mod.time() - insert_start:.3f}s"
@@ -688,18 +652,18 @@ async def create_semantic_links_batch(
raise
async def insert_entity_links_batch(conn, links: list[EntityLink], chunk_size: int = 50000):
async def insert_entity_links_batch(conn, links: list[EntityLink], chunk_size: int = 5000):
"""
Insert all entity links using COPY to temp table + INSERT for maximum speed.
Insert all entity links using COPY to temp table + chunked INSERT for reliability.
Uses PostgreSQL COPY (via copy_records_to_table) for bulk loading,
then INSERT ... ON CONFLICT from temp table. This is the fastest
method for bulk inserts with conflict handling.
Uses PostgreSQL COPY (via copy_records_to_table) for bulk loading into a
temp table, then INSERT ... ON CONFLICT in chunks of chunk_size. Chunking
prevents single-query timeouts on very large tables (100M+ rows).
Args:
conn: Database connection
links: List of EntityLink objects
chunk_size: Number of rows per batch (default 50000)
chunk_size: Number of rows per INSERT chunk (default 5000)
"""
if not links:
return
@@ -708,10 +672,11 @@ async def insert_entity_links_batch(conn, links: list[EntityLink], chunk_size: i
total_start = time_mod.time()
# Create temp table for bulk loading
# Create temp table with serial for stable chunked access
create_start = time_mod.time()
await conn.execute("""
CREATE TEMP TABLE IF NOT EXISTS _temp_entity_links (
_row_num SERIAL,
from_unit_id uuid,
to_unit_id uuid,
link_type text,
@@ -728,9 +693,7 @@ async def insert_entity_links_batch(conn, links: list[EntityLink], chunk_size: i
# Convert EntityLink objects to tuples for COPY
convert_start = time_mod.time()
records = []
for link in links:
records.append((link.from_unit_id, link.to_unit_id, link.link_type, link.weight, link.entity_id))
records = [(link.from_unit_id, link.to_unit_id, link.link_type, link.weight, link.entity_id) for link in links]
logger.debug(f" [9.3] Convert {len(records)} records: {time_mod.time() - convert_start:.3f}s")
# Bulk load using COPY (fastest method)
@@ -742,15 +705,25 @@ async def insert_entity_links_batch(conn, links: list[EntityLink], chunk_size: i
)
logger.debug(f" [9.4] COPY {len(records)} records to temp table: {time_mod.time() - copy_start:.3f}s")
# Insert from temp table with ON CONFLICT (single query for all rows)
# Insert from temp table in chunks to avoid single-query timeouts on large tables
insert_start = time_mod.time()
await conn.execute(f"""
INSERT INTO {fq_table("memory_links")} (from_unit_id, to_unit_id, link_type, weight, entity_id)
SELECT from_unit_id, to_unit_id, link_type, weight, entity_id
FROM _temp_entity_links
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
""")
logger.debug(f" [9.5] INSERT from temp table: {time_mod.time() - insert_start:.3f}s")
total_rows = len(records)
chunks = 0
for chunk_start in range(0, total_rows, chunk_size):
chunk_end = chunk_start + chunk_size
await conn.execute(
f"""
INSERT INTO {fq_table("memory_links")} (from_unit_id, to_unit_id, link_type, weight, entity_id)
SELECT from_unit_id, to_unit_id, link_type, weight, entity_id
FROM _temp_entity_links
WHERE _row_num > $1 AND _row_num <= $2
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
""",
chunk_start,
chunk_end,
)
chunks += 1
logger.debug(f" [9.5] INSERT {total_rows} rows in {chunks} chunks: {time_mod.time() - insert_start:.3f}s")
logger.debug(f" [9.TOTAL] Entity links batch insert: {time_mod.time() - total_start:.3f}s")
@@ -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,10 +53,11 @@ 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,
deduplication,
embedding_processing,
entity_processing,
fact_extraction,
@@ -73,7 +75,6 @@ async def retain_batch(
llm_config,
entity_resolver,
format_date_fn,
duplicate_checker_fn,
bank_id: str,
contents_dicts: list[RetainContentDict],
config,
@@ -84,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.
@@ -94,7 +96,6 @@ async def retain_batch(
llm_config: LLM configuration for fact extraction
entity_resolver: Entity resolver for entity processing
format_date_fn: Function to format datetime to readable string
duplicate_checker_fn: Function to check for duplicate facts
bank_id: Bank identifier
contents_dicts: List of content dictionaries
config: Resolved HindsightConfig for this bank
@@ -165,8 +166,6 @@ async def retain_batch(
docs_tracked = 0
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
await fact_storage.ensure_bank_exists(conn, bank_id)
# Group contents by document_id (consistent with normal path)
contents_by_doc_early = defaultdict(list)
for idx, content_dict in enumerate(contents_dicts):
@@ -284,9 +283,6 @@ async def retain_batch(
# Step 4: Database transaction
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
# Ensure bank exists
await fact_storage.ensure_bank_exists(conn, bank_id)
# Handle document tracking for all documents
step_start = time.time()
# Map None document_id to generated UUIDs
@@ -438,20 +434,7 @@ async def retain_batch(
actual_doc_id = document_id
processed_fact.document_id = actual_doc_id
# Deduplication
step_start = time.time()
is_duplicate_flags = await deduplication.check_duplicates_batch(
conn, bank_id, processed_facts, duplicate_checker_fn
)
log_buffer.append(
f"[4] Deduplication: {sum(is_duplicate_flags)} duplicates in {time.time() - step_start:.3f}s"
)
# Filter out duplicates
non_duplicate_facts = deduplication.filter_duplicates(processed_facts, is_duplicate_flags)
if not non_duplicate_facts:
return [[] for _ in contents], usage
non_duplicate_facts = processed_facts
# Insert facts (document_id is now stored per-fact)
step_start = time.time()
@@ -472,6 +455,7 @@ async def retain_batch(
non_duplicate_facts,
log_buffer,
user_entities_per_content=user_entities_per_content,
entity_labels=getattr(config, "entity_labels", None),
)
log_buffer.append(f"[6] Process entities: {len(entity_links)} links in {time.time() - step_start:.3f}s")
@@ -502,7 +486,16 @@ async def retain_batch(
log_buffer.append(f"[10] Causal links: {causal_link_count} links in {time.time() - step_start:.3f}s")
# Map results back to original content items
result_unit_ids = _map_results_to_contents(contents, extracted_facts, is_duplicate_flags, unit_ids)
result_unit_ids = _map_results_to_contents(contents, extracted_facts, unit_ids)
# Transactional outbox: queue any side-effect tasks (e.g. webhook deliveries)
# inside the same transaction so they are atomically committed with the retain data.
if outbox_callback:
await outbox_callback(conn)
# Flush entity stats (mention_count / last_seen) now that the transaction
# has committed. Uses a fresh pool connection — no locks held.
await entity_resolver.flush_pending_stats()
# Log final summary
total_time = time.time() - start_time
@@ -520,28 +513,20 @@ async def retain_batch(
def _map_results_to_contents(
contents: list[RetainContent],
extracted_facts: list[ExtractedFact],
is_duplicate_flags: list[bool],
unit_ids: list[str],
) -> list[list[str]]:
"""
Map created unit IDs back to original content items.
Accounts for duplicates when mapping back.
"""
result_unit_ids = []
filtered_idx = 0
# Group facts by content_index
facts_by_content = {i: [] for i in range(len(contents))}
"""Map created unit IDs back to original content items."""
facts_by_content: dict[int, list[int]] = {i: [] for i in range(len(contents))}
for i, fact in enumerate(extracted_facts):
facts_by_content[fact.content_index].append(i)
result_unit_ids = []
unit_idx = 0
for content_index in range(len(contents)):
content_unit_ids = []
for fact_idx in facts_by_content[content_index]:
if not is_duplicate_flags[fact_idx]:
content_unit_ids.append(unit_ids[filtered_idx])
filtered_idx += 1
for _ in facts_by_content[content_index]:
content_unit_ids.append(unit_ids[unit_idx])
unit_idx += 1
result_unit_ids.append(content_unit_ids)
return result_unit_ids
@@ -1,18 +1,28 @@
"""
Link Expansion graph retrieval.
A simple, fast graph retrieval that expands from seeds via:
1. Entity links: Find facts sharing entities with seeds (filtered by entity frequency)
2. Causal links: Find facts causally linked to seeds (top-k by weight)
Expands from semantic/temporal seeds through three parallel, first-class signals
stored in memory_links:
Characteristics:
- 2-3 DB queries (seed finding + parallel entity/causal expansion)
- Sublinear: only touches connected facts via indexes
- No iteration, no propagation, no normalization
- Target: <100ms
1. Entity links — precomputed co-occurrence graph (created at retain time, bounded to
MAX_LINKS_PER_ENTITY per entity). Score = number of distinct shared
entities between the seed set and each candidate.
2. Semantic links — precomputed kNN graph (each new fact linked to its top-5 most
similar existing facts at insert time, similarity >= 0.7). Checked
in both directions since the graph is not symmetric. Score = weight.
3. Causal links — explicit causal chains (causes/caused_by/enables/prevents).
Score = weight + 1.0 (boosted as highest-quality signal).
All three signals are bounded at retain time, so no LATERAL fan-out caps are needed
at query time. Each expansion is a simple aggregation over a small result set.
For non-observation fact types the three expansions are issued as a single CTE query
(one roundtrip, one connection) with a `source` discriminator column so the Python
merge step can apply per-signal score transformations.
"""
import logging
import math
import time
from ..db_utils import acquire_with_retry
@@ -65,27 +75,23 @@ class LinkExpansionRetriever(GraphRetriever):
"""
Graph retrieval via direct link expansion from seeds.
Expands through entity co-occurrence and causal links in a single query.
Fast and simple alternative to MPFP.
Runs three expansions through precomputed memory_links: entity co-occurrence,
semantic kNN, and causal chains, all bounded at retain time.
For non-observation fact types the three expansions are issued as a single CTE
query (one roundtrip, one connection slot) with a `source` discriminator column.
The Python merge step applies per-signal score transformations.
"""
def __init__(
self,
max_entity_frequency: int = 500,
causal_weight_threshold: float = 0.3,
causal_limit_per_seed: int = 10,
):
"""
Initialize link expansion retriever.
Args:
max_entity_frequency: Skip entities appearing in more than this many facts
causal_weight_threshold: Minimum weight for causal links
causal_limit_per_seed: Max causal links to follow per seed
causal_weight_threshold: Minimum weight for causal links to follow.
"""
self.max_entity_frequency = max_entity_frequency
self.causal_weight_threshold = causal_weight_threshold
self.causal_limit_per_seed = causal_limit_per_seed
@property
def name(self) -> str:
@@ -110,7 +116,7 @@ class LinkExpansionRetriever(GraphRetriever):
Args:
pool: Database connection pool
query_embedding_str: Query embedding (unused, kept for interface)
query_embedding_str: Query embedding as string
bank_id: Memory bank ID
fact_type: Fact type to filter
budget: Maximum results to return
@@ -118,7 +124,7 @@ class LinkExpansionRetriever(GraphRetriever):
semantic_seeds: Pre-computed semantic entry points
temporal_seeds: Pre-computed temporal entry points
adjacency: Unused, kept for interface compatibility
tags: Optional list of tags for visibility filtering (OR matching)
tags: Optional list of tags for visibility filtering
Returns:
Tuple of (results, timings)
@@ -126,8 +132,6 @@ class LinkExpansionRetriever(GraphRetriever):
start_time = time.time()
timings = MPFPTimings(fact_type=fact_type)
# Use single connection for all queries to reduce pool pressure
# (queries are fast ~50ms each, connection acquisition is the bottleneck)
async with acquire_with_retry(pool) as conn:
# Find seeds if not provided
if semantic_seeds:
@@ -150,7 +154,6 @@ class LinkExpansionRetriever(GraphRetriever):
f"(tags={tags}, tags_match={tags_match})"
)
# Add temporal seeds if provided
if temporal_seeds:
all_seeds.extend(temporal_seeds)
@@ -160,223 +163,61 @@ class LinkExpansionRetriever(GraphRetriever):
seed_ids = list({s.id for s in all_seeds})
timings.pattern_count = len(seed_ids)
# Run entity and causal expansion sequentially on same connection
query_start = time.time()
# For observations, traverse through source_memory_ids to find entity connections.
# Observations don't have direct unit_entities - they inherit entities via their
# source world/experience facts.
#
# Path: observation → source_memory_ids → world fact → entities →
# ALL world facts with those entities → their observations (excluding seeds)
if fact_type == "observation":
# Debug: Check what source_memory_ids exist on seed observations
debug_sources = await conn.fetch(
f"""
SELECT id, source_memory_ids
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
""",
seed_ids,
)
source_ids_found = []
for row in debug_sources:
if row["source_memory_ids"]:
source_ids_found.extend(row["source_memory_ids"])
logger.debug(
f"[LinkExpansion] observation graph: {len(seed_ids)} seeds, "
f"{len(source_ids_found)} source_memory_ids found"
)
entity_rows = await conn.fetch(
f"""
WITH seed_sources AS (
-- Get source memory IDs from seed observations
SELECT DISTINCT unnest(source_memory_ids) AS source_id
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
AND source_memory_ids IS NOT NULL
),
source_entities AS (
-- Get entities from those source memories (filtered by frequency)
SELECT DISTINCT ue.entity_id
FROM seed_sources ss
JOIN {fq_table("unit_entities")} ue ON ss.source_id = ue.unit_id
JOIN {fq_table("entities")} e ON ue.entity_id = e.id
WHERE e.mention_count < $2
),
all_connected_sources AS (
-- Find ALL world facts sharing those entities (don't exclude seed sources)
-- The exclusion happens at the observation level, not the source level
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
)
-- Find observations derived from connected source memories
-- Only exclude the actual seed observations
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]
WHERE mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
GROUP BY mu.id
ORDER BY score DESC
LIMIT $3
""",
seed_ids,
self.max_entity_frequency,
budget,
)
logger.debug(f"[LinkExpansion] observation graph: found {len(entity_rows)} connected observations")
entity_rows, semantic_rows, causal_rows = await self._expand_observations(conn, seed_ids, budget)
else:
# For world/experience facts, use direct entity lookup
entity_rows = await conn.fetch(
f"""
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(*)::float AS score
FROM {fq_table("unit_entities")} seed_ue
JOIN {fq_table("entities")} e ON seed_ue.entity_id = e.id
JOIN {fq_table("unit_entities")} other_ue ON seed_ue.entity_id = other_ue.entity_id
JOIN {fq_table("memory_units")} mu ON other_ue.unit_id = mu.id
WHERE seed_ue.unit_id = ANY($1::uuid[])
AND e.mention_count < $2
AND mu.id != ALL($1::uuid[])
AND mu.fact_type = $3
GROUP BY mu.id
ORDER BY score DESC
LIMIT $4
""",
seed_ids,
self.max_entity_frequency,
fact_type,
budget,
)
causal_rows = await conn.fetch(
f"""
SELECT DISTINCT ON (mu.id)
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,
ml.weight + 1.0 AS score
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('causes', 'caused_by', 'enables', 'prevents')
AND ml.weight >= $2
AND mu.fact_type = $3
ORDER BY mu.id, ml.weight DESC
LIMIT $4
""",
seed_ids,
self.causal_weight_threshold,
fact_type,
budget,
)
# Fallback: semantic/temporal/entity links from memory_links table
# These are secondary to entity links (via unit_entities) and causal links
# Weight is halved (0.5x) to prioritize primary link types
# Check both directions: seeds -> others AND others -> seeds
fallback_rows = await conn.fetch(
f"""
WITH outgoing AS (
-- Links FROM seeds TO other facts
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,
ml.weight
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('semantic', 'temporal', 'entity')
AND ml.weight >= $2
AND mu.fact_type = $3
AND mu.id != ALL($1::uuid[])
),
incoming AS (
-- Links FROM other facts TO seeds (reverse direction)
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,
ml.weight
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
WHERE ml.to_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('semantic', 'temporal', 'entity')
AND ml.weight >= $2
AND mu.fact_type = $3
AND mu.id != ALL($1::uuid[])
),
combined AS (
SELECT * FROM outgoing
UNION ALL
SELECT * FROM incoming
)
SELECT DISTINCT ON (id)
id, text, context, event_date, occurred_start,
occurred_end, mentioned_at,
fact_type, document_id, chunk_id, tags,
(MAX(weight) * 0.5) AS score
FROM combined
GROUP BY id, text, context, event_date, occurred_start,
occurred_end, mentioned_at,
fact_type, document_id, chunk_id, tags
ORDER BY id, score DESC
LIMIT $4
""",
seed_ids,
self.causal_weight_threshold,
fact_type,
budget,
)
entity_rows, semantic_rows, causal_rows = await self._expand_combined(conn, seed_ids, fact_type, budget)
timings.edge_load_time = time.time() - query_start
timings.db_queries = 3
timings.edge_count = len(entity_rows) + len(causal_rows) + len(fallback_rows)
timings.db_queries = 1
timings.edge_count = len(entity_rows) + len(semantic_rows) + len(causal_rows)
# Merge results, taking max score per fact
# Priority: entity links (unit_entities) > causal links > fallback links
score_map: dict[str, float] = {}
# Merge results with additive intra-score: entity + semantic + causal ∈ [0, 3].
#
# Entity score: tanh(count × 0.5) maps shared-entity count to [0, 1]:
# 1 entity → 0.46, 2 → 0.76, 3 → 0.91, 4 → 0.96 (saturates naturally)
# Semantic score: similarity weight, already ∈ [0.7, 1.0].
# Causal score: link weight, already ∈ [0, 1].
#
# Facts appearing in multiple signals accumulate higher scores, rewarding
# convergent evidence. The outer RRF uses rank position from this sorted list.
entity_scores: dict[str, float] = {}
semantic_scores: dict[str, float] = {}
causal_scores: dict[str, float] = {}
row_map: dict[str, dict] = {}
for row in entity_rows:
fact_id = str(row["id"])
score_map[fact_id] = max(score_map.get(fact_id, 0), row["score"])
entity_scores[fact_id] = math.tanh(row["score"] * 0.5)
row_map[fact_id] = dict(row)
for row in semantic_rows:
fact_id = str(row["id"])
semantic_scores[fact_id] = max(semantic_scores.get(fact_id, 0.0), row["score"])
row_map.setdefault(fact_id, dict(row))
for row in causal_rows:
fact_id = str(row["id"])
score_map[fact_id] = max(score_map.get(fact_id, 0), row["score"])
if fact_id not in row_map:
row_map[fact_id] = dict(row)
causal_scores[fact_id] = max(causal_scores.get(fact_id, 0.0), row["score"])
row_map.setdefault(fact_id, dict(row))
for row in fallback_rows:
fact_id = str(row["id"])
score_map[fact_id] = max(score_map.get(fact_id, 0), row["score"])
if fact_id not in row_map:
row_map[fact_id] = dict(row)
all_ids = set(entity_scores) | set(semantic_scores) | set(causal_scores)
score_map = {
fid: entity_scores.get(fid, 0.0) + semantic_scores.get(fid, 0.0) + causal_scores.get(fid, 0.0)
for fid in all_ids
}
# Sort by score and limit
sorted_ids = sorted(score_map.keys(), key=lambda x: score_map[x], reverse=True)[:budget]
rows = [row_map[fact_id] for fact_id in sorted_ids]
# Convert to results
results = []
for row in rows:
result = RetrievalResult.from_db_row(dict(row))
result.activation = row["score"]
results.append(result)
# Apply tags filtering (graph expansion may reach untagged memories)
if tags:
results = filter_results_by_tags(results, tags, match=tags_match)
@@ -389,3 +230,254 @@ class LinkExpansionRetriever(GraphRetriever):
)
return results, timings
async def _expand_combined(
self,
conn,
seed_ids: list,
fact_type: str,
budget: int,
) -> tuple[list, list, list]:
"""
Single-roundtrip CTE query combining entity, semantic, and causal expansions.
Uses a `source` discriminator column so the caller can apply per-signal
score transformations. The three CTEs share one connection slot — important
for asyncpg which does not allow concurrent queries on the same connection.
Index coverage (requires migration d2e3f4a5b6c7):
entity: idx_memory_links_entity_covering (from_unit_id) INCLUDE (to_unit_id, entity_id)
WHERE link_type = 'entity' → index-only scan, no heap reads
semantic incoming:
idx_memory_links_to_type_weight (to_unit_id, link_type, weight DESC)
→ replaces costly BitmapAnd of two separate scans
"""
ml = fq_table("memory_links")
mu = fq_table("memory_units")
all_rows = await conn.fetch(
f"""
WITH entity_expanded AS (
-- Entity co-occurrence: seeds → their precomputed entity-link neighbors.
-- Score = distinct shared entities (bounded at retain time to
-- MAX_LINKS_PER_ENTITY=50). GROUP BY mu.id is sufficient because mu.id
-- is the primary key and functionally determines all other mu columns.
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 ml.entity_id)::float AS score,
'entity'::text AS source
FROM {ml} ml
JOIN {mu} mu ON mu.id = ml.to_unit_id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type = 'entity'
AND mu.fact_type = $2
AND mu.id != ALL($1::uuid[])
GROUP BY mu.id
ORDER BY score DESC
LIMIT $3
),
semantic_expanded AS (
-- Semantic kNN: both outgoing (seeds → their kNN at insert time) and
-- incoming (facts inserted after seeds that found seeds as kNN).
-- Score = max similarity weight across both directions.
SELECT
id, text, context, event_date, occurred_start,
occurred_end, mentioned_at,
fact_type, document_id, chunk_id, tags,
MAX(weight) AS score,
'semantic'::text AS source
FROM (
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,
ml.weight
FROM {ml} ml
JOIN {mu} mu ON mu.id = ml.to_unit_id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type = 'semantic'
AND mu.fact_type = $2
AND mu.id != ALL($1::uuid[])
UNION ALL
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,
ml.weight
FROM {ml} ml
JOIN {mu} mu ON mu.id = ml.from_unit_id
WHERE ml.to_unit_id = ANY($1::uuid[])
AND ml.link_type = 'semantic'
AND mu.fact_type = $2
AND mu.id != ALL($1::uuid[])
) sem_raw
GROUP BY id, text, context, event_date, occurred_start,
occurred_end, mentioned_at,
fact_type, document_id, chunk_id, tags
ORDER BY score DESC
LIMIT $3
),
causal_expanded AS (
-- Causal chains: explicit causes/enables/prevents links from seeds.
-- DISTINCT ON handles the case where a seed has multiple causal links
-- to the same target; best weight wins.
SELECT DISTINCT ON (mu.id)
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,
ml.weight AS score,
'causal'::text AS source
FROM {ml} ml
JOIN {mu} mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('causes', 'caused_by', 'enables', 'prevents')
AND ml.weight >= $4
AND mu.fact_type = $2
ORDER BY mu.id, ml.weight DESC
LIMIT $3
)
SELECT * FROM entity_expanded
UNION ALL
SELECT * FROM semantic_expanded
UNION ALL
SELECT * FROM causal_expanded
""",
seed_ids,
fact_type,
budget,
self.causal_weight_threshold,
)
entity_rows = [r for r in all_rows if r["source"] == "entity"]
semantic_rows = [r for r in all_rows if r["source"] == "semantic"]
causal_rows = [r for r in all_rows if r["source"] == "causal"]
return entity_rows, semantic_rows, causal_rows
async def _expand_observations(
self,
conn,
seed_ids: list,
budget: int,
) -> tuple[list, list, list]:
"""
Observation-specific expansion.
Observations don't have direct entity links in memory_links (they're created
by consolidation, not retain). Instead, traverse source_memory_ids → world
facts → entities → other world facts → their observations.
Semantic and causal expansions run as a second combined CTE query.
"""
source_ids_found: list = []
if logger.isEnabledFor(logging.DEBUG):
debug_rows = await conn.fetch(
f"""
SELECT id, source_memory_ids
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
""",
seed_ids,
)
for row in debug_rows:
if row["source_memory_ids"]:
source_ids_found.extend(row["source_memory_ids"])
logger.debug(
f"[LinkExpansion] observation graph: {len(seed_ids)} seeds, "
f"{len(source_ids_found)} source_memory_ids found"
)
entity_rows = await conn.fetch(
f"""
WITH seed_sources AS (
SELECT DISTINCT unnest(source_memory_ids) AS source_id
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
AND source_memory_ids IS NOT NULL
),
connected_sources AS (
-- Mirror the non-observation entity expansion: follow pre-bounded entity
-- links in memory_links (capped to MAX_LINKS_PER_ENTITY=50 at retain time).
-- Score = number of distinct shared entities, same as the non-obs path.
SELECT DISTINCT ml.to_unit_id AS source_id
FROM seed_sources ss
JOIN {fq_table("memory_links")} ml ON ml.from_unit_id = ss.source_id
WHERE ml.link_type = 'entity'
),
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,
(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[])
AND ca.source_ids IS NOT NULL
AND mu.source_memory_ids && ca.source_ids
ORDER BY score DESC
LIMIT $2
""",
seed_ids,
budget,
)
logger.debug(f"[LinkExpansion] observation graph: found {len(entity_rows)} connected observations")
# Semantic + causal for observations in one query
ml = fq_table("memory_links")
mu = fq_table("memory_units")
sem_causal_rows = await conn.fetch(
f"""
WITH semantic_expanded AS (
SELECT
id, text, context, event_date, occurred_start,
occurred_end, mentioned_at,
fact_type, document_id, chunk_id, tags,
MAX(weight) AS score,
'semantic'::text AS source
FROM (
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, ml.weight
FROM {ml} ml JOIN {mu} mu ON mu.id = ml.to_unit_id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type = 'semantic' AND mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
UNION ALL
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, ml.weight
FROM {ml} ml JOIN {mu} mu ON mu.id = ml.from_unit_id
WHERE ml.to_unit_id = ANY($1::uuid[])
AND ml.link_type = 'semantic' AND mu.fact_type = 'observation'
AND mu.id != ALL($1::uuid[])
) sem_raw
GROUP BY id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, fact_type, document_id, chunk_id, tags
ORDER BY score DESC LIMIT $2
),
causal_expanded AS (
SELECT DISTINCT ON (mu.id)
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, ml.weight AS score, 'causal'::text AS source
FROM {ml} ml JOIN {mu} mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.link_type IN ('causes', 'caused_by', 'enables', 'prevents')
AND ml.weight >= $3 AND mu.fact_type = 'observation'
ORDER BY mu.id, ml.weight DESC LIMIT $2
)
SELECT * FROM semantic_expanded
UNION ALL
SELECT * FROM causal_expanded
""",
seed_ids,
budget,
self.causal_weight_threshold,
)
semantic_rows = [r for r in sem_causal_rows if r["source"] == "semantic"]
causal_rows = [r for r in sem_causal_rows if r["source"] == "causal"]
return entity_rows, semantic_rows, causal_rows
@@ -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:
"""
@@ -297,13 +297,20 @@ async def retrieve_temporal_combined(
if tags:
params.append(tags)
# Batch query: Get entry points for ALL fact types at once with window function
# Two-phase entry point query:
# Phase 1 (date_ranked): rank by date only — no embedding computation — for all units in
# the temporal window. This lets the planner use date indexes for filtering.
# Phase 2 (sim_ranked): join back to memory_units for only the top-50-per-type candidates
# and compute embedding similarity for that small set (≤ 50 × len(fact_types) rows).
# This avoids computing embedding distances for potentially thousands of date-range rows.
entry_points = await conn.fetch(
f"""
WITH ranked_entries AS (
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags,
1 - (embedding <=> $1::vector) AS similarity,
ROW_NUMBER() OVER (PARTITION BY fact_type ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC, embedding <=> $1::vector) AS rn
WITH date_ranked AS MATERIALIZED (
SELECT id, fact_type,
ROW_NUMBER() OVER (
PARTITION BY fact_type
ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC NULLS LAST
) AS rn
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND fact_type = ANY($3)
@@ -318,12 +325,20 @@ async def retrieve_temporal_combined(
OR
(occurred_end IS NOT NULL AND occurred_end BETWEEN $4 AND $5)
)
AND (1 - (embedding <=> $1::vector)) >= $6
{tags_clause}
),
sim_ranked AS (
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,
1 - (mu.embedding <=> $1::vector) AS similarity,
ROW_NUMBER() OVER (PARTITION BY mu.fact_type ORDER BY mu.embedding <=> $1::vector) AS sim_rn
FROM date_ranked dr
JOIN {fq_table("memory_units")} mu ON mu.id = dr.id
WHERE dr.rn <= 50
AND (1 - (mu.embedding <=> $1::vector)) >= $6
)
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags, similarity
FROM ranked_entries
WHERE rn <= 10
FROM sim_ranked
WHERE sim_rn <= 10
""",
*params,
)
@@ -387,34 +402,52 @@ async def retrieve_temporal_combined(
frontier = list(node_scores.keys())
budget_remaining = budget - len(ft_entry_points)
batch_size = 20
# Per-source neighbor limit: lets the planner use the composite index
# (from_unit_id, link_type, weight DESC) with early termination, avoiding
# a full scan of all links from all source nodes before sorting.
per_source_limit = 10
# Safety cap on BFS iterations to prevent runaway spreading in dense graphs.
max_iterations = 5
iteration = 0
# Build tags clause for spreading (use param 6 since 1-5 are used)
spreading_tags_clause = build_tags_where_clause_simple(tags, 6, table_alias="mu.", match=tags_match)
# Build tags clause for spreading (use param 7 since 1-6 are used)
spreading_tags_clause = build_tags_where_clause_simple(tags, 7, table_alias="mu.", match=tags_match)
while frontier and budget_remaining > 0:
while frontier and budget_remaining > 0 and iteration < max_iterations:
iteration += 1
batch_ids = frontier[:batch_size]
frontier = frontier[batch_size:]
spreading_params = [query_emb_str, batch_ids, ft, semantic_threshold, batch_size * 10]
# $1=query_emb, $2=batch_ids, $3=fact_type, $4=threshold, $5=per_source_limit, $6=bank_id, $7=tags
spreading_params = [query_emb_str, batch_ids, ft, semantic_threshold, per_source_limit, bank_id]
if tags:
spreading_params.append(tags)
# LATERAL join: for each source node, fetch top-K neighbors by weight using
# the existing idx_memory_links_from_type_weight index with early-exit semantics.
# This avoids scanning all temporal links from all source nodes before sorting.
# bank_id on memory_units lets the planner use idx_memory_units_bank_fact_type.
neighbors = await conn.fetch(
f"""
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,
ml.weight, ml.link_type, ml.from_unit_id,
SELECT src.from_unit_id, 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,
l.weight, l.link_type,
1 - (mu.embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id = ANY($2::uuid[])
AND ml.link_type IN ('temporal', 'causes', 'caused_by', 'enables', 'prevents')
AND ml.weight >= 0.1
FROM unnest($2::uuid[]) AS src(from_unit_id)
CROSS JOIN LATERAL (
SELECT ml.to_unit_id, ml.weight, ml.link_type
FROM {fq_table("memory_links")} ml
WHERE ml.from_unit_id = src.from_unit_id
AND ml.link_type IN ('temporal', 'causes', 'caused_by', 'enables', 'prevents')
AND ml.weight >= 0.1
ORDER BY ml.weight DESC
LIMIT $5
) l
JOIN {fq_table("memory_units")} mu ON mu.id = l.to_unit_id
WHERE mu.bank_id = $6
AND mu.fact_type = $3
AND mu.embedding IS NOT NULL
AND (1 - (mu.embedding <=> $1::vector)) >= $4
{spreading_tags_clause}
ORDER BY ml.weight DESC
LIMIT $5
""",
*spreading_params,
)
@@ -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."""
@@ -87,3 +87,15 @@ class HttpExtension(Extension, ABC):
```
"""
pass
def get_root_router(self, memory: "MemoryEngine") -> APIRouter | None:
"""
Return a FastAPI router with endpoints mounted at the app root.
Unlike get_router() which is mounted at /ext/, this router is mounted
directly on the application root. Use for well-known endpoints or other
paths that must be at specific locations.
Returns None by default (no root routes). Override to provide root-level routes.
"""
return None
@@ -11,8 +11,9 @@ from hindsight_api.models import RequestContext
class AuthenticationError(Exception):
"""Raised when authentication fails."""
def __init__(self, reason: str):
def __init__(self, reason: str, headers: dict[str, str] | None = None):
self.reason = reason
self.headers = headers or {}
super().__init__(f"Authentication failed: {reason}")
+9
View File
@@ -171,6 +171,7 @@ def main():
llm_vertexai_project_id=config.llm_vertexai_project_id,
llm_vertexai_region=config.llm_vertexai_region,
llm_vertexai_service_account_key=config.llm_vertexai_service_account_key,
llm_gemini_safety_settings=config.llm_gemini_safety_settings,
retain_llm_provider=config.retain_llm_provider,
retain_llm_api_key=config.retain_llm_api_key,
retain_llm_model=config.retain_llm_model,
@@ -252,6 +253,7 @@ def main():
retain_mission=config.retain_mission,
retain_custom_instructions=config.retain_custom_instructions,
retain_batch_tokens=config.retain_batch_tokens,
retain_entity_lookup=config.retain_entity_lookup,
retain_batch_enabled=config.retain_batch_enabled,
retain_batch_poll_interval_seconds=config.retain_batch_poll_interval_seconds,
file_storage_type=config.file_storage_type,
@@ -277,6 +279,8 @@ def main():
consolidation_llm_batch_size=config.consolidation_llm_batch_size,
consolidation_max_tokens=config.consolidation_max_tokens,
observations_mission=config.observations_mission,
entity_labels=config.entity_labels,
entities_allow_free_form=config.entities_allow_free_form,
skip_llm_verification=config.skip_llm_verification,
lazy_reranker=config.lazy_reranker,
run_migrations_on_startup=config.run_migrations_on_startup,
@@ -303,6 +307,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:
@@ -381,6 +389,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
+59 -36
View File
@@ -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:
+39 -4
View File
@@ -18,6 +18,7 @@ No alembic.ini required - all configuration is done programmatically.
import hashlib
import logging
import os
import time
from pathlib import Path
from alembic import command
@@ -220,13 +221,40 @@ def run_migrations(
lock_id = _get_schema_lock_id(schema) if schema else MIGRATION_LOCK_ID
schema_name = schema or "public"
# Use PostgreSQL advisory lock to coordinate between distributed workers
# Use PostgreSQL advisory lock to coordinate between distributed workers.
#
# IMPORTANT: We must avoid holding an open transaction on the advisory-lock
# connection while CREATE INDEX CONCURRENTLY runs inside a migration.
# CONCURRENTLY waits for ALL active transactions to finish before the index
# becomes valid. If the advisory-lock connection (or any waiting worker's
# connection) holds an open transaction, CONCURRENTLY deadlocks:
# - migration worker waits for other workers' transactions to close
# - other workers wait for the advisory lock to be released
#
# Fix:
# 1. Use pg_try_advisory_lock (non-blocking) in a poll loop instead of
# blocking pg_advisory_lock, so we can COMMIT the transaction between
# retries. Between retries the connection holds no open transaction.
# 2. After acquiring the lock, COMMIT the transaction on the advisory-lock
# connection itself before running migrations. pg_advisory_lock is
# session-level, so the lock survives the COMMIT.
engine = create_engine(database_url)
with engine.connect() as conn:
# pg_advisory_lock blocks until the lock is acquired
# The lock is automatically released when the connection closes
logger.debug(f"Acquiring migration advisory lock for schema '{schema_name}' (id={lock_id})...")
conn.execute(text(f"SELECT pg_advisory_lock({lock_id})"))
while True:
acquired = conn.execute(text(f"SELECT pg_try_advisory_lock({lock_id})")).scalar()
if acquired:
break
# Commit the transaction so this connection holds no open snapshot
# while waiting. This prevents blocking CREATE INDEX CONCURRENTLY
# that may be running in the migration worker.
conn.commit()
time.sleep(0.5)
# Commit AFTER acquiring the lock too. pg_advisory_lock is session-level
# and survives the COMMIT, but the open transaction on this connection
# would otherwise block any CREATE INDEX CONCURRENTLY in the migration.
conn.commit()
logger.debug("Migration advisory lock acquired")
try:
@@ -347,6 +375,13 @@ def run_migrations(
"Please install it with: CREATE EXTENSION vectorscale CASCADE;"
) from e
# Commit any pending transaction on the advisory-lock connection
# before running migrations. Some code paths above (e.g., the
# pgvector extension check) may have started a transaction via
# SQLAlchemy's autobegin. If we leave it open, CREATE INDEX
# CONCURRENTLY inside a migration will deadlock waiting for it.
conn.commit()
# Run migrations while holding the lock
_run_migrations_internal(database_url, script_location, schema=schema)
finally:
@@ -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,
+38 -49
View File
@@ -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:
"""
+9 -4
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api"
version = "0.4.14"
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]
-1
View File
@@ -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,
+142 -305
View File
@@ -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
+102 -1
View File
@@ -5,11 +5,15 @@ Note: Consolidation runs automatically after retain via SyncTaskBackend in tests
"""
import uuid
from datetime import datetime, timezone
from unittest.mock import patch
import pytest
from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
from hindsight_api.engine.consolidation.consolidator import (
_aggregate_source_fields,
run_consolidation_job,
)
from hindsight_api.engine.memory_engine import MemoryEngine
from hindsight_api.engine.reflect.tools import (
tool_recall,
@@ -2317,3 +2321,100 @@ 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"]
File diff suppressed because it is too large Load Diff
@@ -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
@@ -0,0 +1,340 @@
"""
Tests for Gemini safety settings feature.
Verifies that:
- Safety settings are read from env var and stored on GeminiLLM instances
- Settings are applied to GenerateContentConfig in call() and call_with_tools()
- The context variable override allows per-bank settings at request time
- None (unset) means Gemini's default safety settings are used (no override)
"""
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
pytest.importorskip("google.genai")
SAMPLE_SAFETY_SETTINGS = [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
]
# ─── Config / env var parsing ─────────────────────────────────────────────────
def test_gemini_safety_settings_parsed_from_env():
"""Safety settings JSON from env var is parsed into HindsightConfig."""
import json
from hindsight_api.config import ENV_LLM_GEMINI_SAFETY_SETTINGS, HindsightConfig, clear_config_cache
settings_json = json.dumps(SAMPLE_SAFETY_SETTINGS)
with patch.dict(os.environ, {ENV_LLM_GEMINI_SAFETY_SETTINGS: settings_json}, clear=False):
clear_config_cache()
config = HindsightConfig.from_env()
assert config.llm_gemini_safety_settings == SAMPLE_SAFETY_SETTINGS
clear_config_cache()
def test_gemini_safety_settings_default_is_none():
"""When env var is not set, llm_gemini_safety_settings defaults to None."""
from hindsight_api.config import ENV_LLM_GEMINI_SAFETY_SETTINGS, HindsightConfig, clear_config_cache
env = {k: v for k, v in os.environ.items() if k != ENV_LLM_GEMINI_SAFETY_SETTINGS}
with patch.dict(os.environ, env, clear=True):
clear_config_cache()
config = HindsightConfig.from_env()
assert config.llm_gemini_safety_settings is None
clear_config_cache()
def test_gemini_safety_settings_is_configurable_field():
"""llm_gemini_safety_settings appears in configurable (per-bank) fields."""
from hindsight_api.config import HindsightConfig
assert "llm_gemini_safety_settings" in HindsightConfig.get_configurable_fields()
def test_gemini_safety_settings_not_in_credential_fields():
"""llm_gemini_safety_settings is NOT a credential — it is safe to expose via API."""
from hindsight_api.config import HindsightConfig
assert "llm_gemini_safety_settings" not in HindsightConfig.get_credential_fields()
# ─── GeminiLLM instance ───────────────────────────────────────────────────────
def _make_gemini_provider(safety_settings=None):
"""Return a GeminiLLM instance with a mocked genai.Client."""
with patch("google.genai.Client") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
from hindsight_api.engine.providers.gemini_llm import GeminiLLM
provider = GeminiLLM(
provider="gemini",
api_key="fake-api-key",
base_url="",
model="gemini-2.5-flash",
gemini_safety_settings=safety_settings,
)
# Replace client with a fresh mock so we can inspect calls
provider._client = MagicMock()
return provider
def test_gemini_llm_stores_safety_settings():
"""GeminiLLM stores safety settings passed at construction."""
provider = _make_gemini_provider(safety_settings=SAMPLE_SAFETY_SETTINGS)
assert provider._safety_settings == SAMPLE_SAFETY_SETTINGS
def test_gemini_llm_no_safety_settings_is_none():
"""GeminiLLM._safety_settings is None when not provided."""
provider = _make_gemini_provider(safety_settings=None)
assert provider._safety_settings is None
# ─── call() applies safety settings ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_call_applies_safety_settings():
"""call() includes safety_settings in GenerateContentConfig when configured."""
from google.genai import types as genai_types
provider = _make_gemini_provider(safety_settings=SAMPLE_SAFETY_SETTINGS)
# Build a fake successful response
fake_response = MagicMock()
fake_response.text = "hello"
fake_response.candidates = [MagicMock(finish_reason="STOP")]
fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=2)
provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response)
await provider.call(
messages=[{"role": "user", "content": "hi"}],
scope="test",
)
# Inspect the config passed to generate_content
call_args = provider._client.aio.models.generate_content.call_args
config_arg = call_args.kwargs.get("config") or call_args.args[0] if call_args.args else None
# config may be in kwargs or positional; grab from kwargs
config_arg = call_args.kwargs.get("config")
assert config_arg is not None, "GenerateContentConfig should have been passed"
assert hasattr(config_arg, "safety_settings"), "Config should have safety_settings"
assert config_arg.safety_settings is not None
categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings]
assert "HARM_CATEGORY_HARASSMENT" in categories
assert "HARM_CATEGORY_HATE_SPEECH" in categories
assert "HARM_CATEGORY_SEXUALLY_EXPLICIT" in categories
assert "HARM_CATEGORY_DANGEROUS_CONTENT" in categories
thresholds = [s.threshold.value if hasattr(s.threshold, "value") else str(s.threshold) for s in config_arg.safety_settings]
assert all(t == "BLOCK_NONE" for t in thresholds)
@pytest.mark.asyncio
async def test_call_no_safety_settings_omits_key():
"""call() does NOT add safety_settings to GenerateContentConfig when none configured."""
provider = _make_gemini_provider(safety_settings=None)
fake_response = MagicMock()
fake_response.text = "hello"
fake_response.candidates = [MagicMock(finish_reason="STOP")]
fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=2)
provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response)
await provider.call(
messages=[{"role": "user", "content": "hi"}],
scope="test",
)
call_args = provider._client.aio.models.generate_content.call_args
config_arg = call_args.kwargs.get("config")
# When no safety settings, config is either None or lacks safety_settings
if config_arg is not None:
assert not hasattr(config_arg, "safety_settings") or config_arg.safety_settings is None
# ─── call_with_tools() applies safety settings ────────────────────────────────
@pytest.mark.asyncio
async def test_call_with_tools_applies_safety_settings():
"""call_with_tools() includes safety_settings in GenerateContentConfig."""
provider = _make_gemini_provider(safety_settings=SAMPLE_SAFETY_SETTINGS)
# Build a fake tool-use response (no tool calls, just text)
fake_part = MagicMock()
fake_part.text = "answer"
fake_part.function_call = None
fake_candidate = MagicMock()
fake_candidate.content = MagicMock(parts=[fake_part])
fake_response = MagicMock()
fake_response.candidates = [fake_candidate]
fake_response.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=3)
provider._client.aio.models.generate_content = AsyncMock(return_value=fake_response)
tools = [
{
"type": "function",
"function": {
"name": "test_tool",
"description": "A test tool",
"parameters": {"type": "object", "properties": {}, "required": []},
},
}
]
await provider.call_with_tools(
messages=[{"role": "user", "content": "hi"}],
tools=tools,
scope="test",
)
call_args = provider._client.aio.models.generate_content.call_args
config_arg = call_args.kwargs.get("config")
assert config_arg is not None
assert config_arg.safety_settings is not None
categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings]
assert "HARM_CATEGORY_HARASSMENT" in categories
# ─── with_config() override ───────────────────────────────────────────────────
def _make_llm_provider(safety_settings=None):
"""Return an LLMProvider (wrapping GeminiLLM) with a mocked genai.Client."""
with patch("google.genai.Client") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
from hindsight_api.engine.llm_wrapper import LLMProvider
provider = LLMProvider(
provider="gemini",
api_key="fake-api-key",
base_url="",
model="gemini-2.5-flash",
gemini_safety_settings=safety_settings,
)
# Replace the underlying Gemini client with a fresh mock
provider._provider_impl._client = MagicMock()
return provider
def _fake_response():
r = MagicMock()
r.text = "hello"
r.candidates = [MagicMock(finish_reason="STOP")]
r.usage_metadata = MagicMock(prompt_token_count=5, candidates_token_count=2)
return r
def _make_config(safety_settings):
"""Return a minimal config-like object with llm_gemini_safety_settings."""
cfg = MagicMock()
cfg.llm_gemini_safety_settings = safety_settings
return cfg
@pytest.mark.asyncio
async def test_with_config_overrides_instance_settings():
"""with_config() settings take precedence over the provider instance defaults."""
instance_settings = [{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_ONLY_HIGH"}]
override_settings = [{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}]
provider = _make_llm_provider(safety_settings=instance_settings)
provider._provider_impl._client.aio.models.generate_content = AsyncMock(return_value=_fake_response())
configured = provider.with_config(_make_config(override_settings))
await configured.call(messages=[{"role": "user", "content": "hi"}], scope="test")
config_arg = provider._provider_impl._client.aio.models.generate_content.call_args.kwargs.get("config")
assert config_arg is not None
categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings]
# Should use override_settings (HATE_SPEECH), not instance_settings (HARASSMENT)
assert "HARM_CATEGORY_HATE_SPEECH" in categories
assert "HARM_CATEGORY_HARASSMENT" not in categories
@pytest.mark.asyncio
async def test_with_config_none_falls_back_to_instance():
"""When with_config() supplies None, the instance default is used."""
instance_settings = [{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}]
provider = _make_llm_provider(safety_settings=instance_settings)
provider._provider_impl._client.aio.models.generate_content = AsyncMock(return_value=_fake_response())
configured = provider.with_config(_make_config(None))
await configured.call(messages=[{"role": "user", "content": "hi"}], scope="test")
config_arg = provider._provider_impl._client.aio.models.generate_content.call_args.kwargs.get("config")
assert config_arg is not None
categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings]
assert "HARM_CATEGORY_HARASSMENT" in categories
@pytest.mark.asyncio
async def test_with_config_resets_after_call():
"""The ContextVar is properly reset after a with_config() call (no leakage)."""
from hindsight_api.engine.providers.gemini_llm import _safety_settings_ctx
settings = [{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}]
provider = _make_llm_provider(safety_settings=None)
provider._provider_impl._client.aio.models.generate_content = AsyncMock(return_value=_fake_response())
before = _safety_settings_ctx.get()
configured = provider.with_config(_make_config(settings))
await configured.call(messages=[{"role": "user", "content": "hi"}], scope="test")
after = _safety_settings_ctx.get()
assert after == before # ContextVar restored to its original value
# ─── LLMProvider reads safety settings from config ────────────────────────────
def test_llm_provider_reads_safety_settings_from_config():
"""LLMProvider reads llm_gemini_safety_settings from global config for Gemini provider."""
import json
from hindsight_api.config import ENV_LLM_GEMINI_SAFETY_SETTINGS, clear_config_cache
settings_json = json.dumps(SAMPLE_SAFETY_SETTINGS)
env_overrides = {
"HINDSIGHT_API_LLM_PROVIDER": "gemini",
"HINDSIGHT_API_LLM_API_KEY": "fake-key",
ENV_LLM_GEMINI_SAFETY_SETTINGS: settings_json,
}
with patch.dict(os.environ, env_overrides, clear=False):
clear_config_cache()
with patch("google.genai.Client") as mock_client_cls:
mock_client_cls.return_value = MagicMock()
from hindsight_api.engine.llm_wrapper import LLMProvider
provider = LLMProvider(
provider="gemini",
api_key="fake-key",
base_url="",
model="gemini-2.5-flash",
)
assert provider.gemini_safety_settings == SAMPLE_SAFETY_SETTINGS
clear_config_cache()
@@ -81,8 +81,12 @@ async def test_hierarchical_fields_categorization():
assert "disposition_literalism" in configurable
assert "disposition_empathy" in configurable
# Verify entity labels fields are included
assert "entities_allow_free_form" in configurable
assert "entity_labels" in configurable
# Verify count is correct
assert len(configurable) == 11
assert len(configurable) == 14
# Verify credential fields (NEVER exposed)
assert "llm_api_key" in credentials
+174
View File
@@ -0,0 +1,174 @@
"""
Tests for list_documents pagination and tags filtering.
"""
from datetime import datetime, timezone
import pytest
async def _retain_doc(memory, bank_id, document_id, tags, request_context):
"""Helper to retain a document with given tags. Uses gibberish content to avoid LLM
fact extraction (documents are persisted even with zero facts)."""
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": f"xyzabc123 !@# $$$ {document_id}"}],
document_id=document_id,
document_tags=tags or None,
request_context=request_context,
)
@pytest.mark.asyncio
async def test_list_documents_offset_pagination(memory, request_context):
"""offset parameter returns the correct slice of documents."""
bank_id = f"test_list_docs_offset_{datetime.now(timezone.utc).timestamp()}"
try:
for i in range(4):
await _retain_doc(memory, bank_id, f"doc-{i:02d}", [], request_context)
# All documents, ordered by created_at DESC → doc-03, doc-02, doc-01, doc-00
all_docs = await memory.list_documents(
bank_id=bank_id, limit=10, offset=0, request_context=request_context
)
assert all_docs["total"] == 4
assert len(all_docs["items"]) == 4
all_ids = [d["id"] for d in all_docs["items"]]
# offset=2 should skip the first two and return the remaining two
page2 = await memory.list_documents(
bank_id=bank_id, limit=10, offset=2, request_context=request_context
)
assert page2["total"] == 4 # total is always the full count
assert len(page2["items"]) == 2
assert [d["id"] for d in page2["items"]] == all_ids[2:]
# offset beyond total returns empty items but correct total
beyond = await memory.list_documents(
bank_id=bank_id, limit=10, offset=10, request_context=request_context
)
assert beyond["total"] == 4
assert beyond["items"] == []
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_list_documents_tags_filter_any_strict(memory, request_context):
"""tags filter with any_strict returns only tagged documents that match."""
bank_id = f"test_list_docs_tags_{datetime.now(timezone.utc).timestamp()}"
try:
await _retain_doc(memory, bank_id, "doc-alpha", ["team-a"], request_context)
await _retain_doc(memory, bank_id, "doc-beta", ["team-b"], request_context)
await _retain_doc(memory, bank_id, "doc-both", ["team-a", "team-b"], request_context)
await _retain_doc(memory, bank_id, "doc-untagged", [], request_context)
# any_strict: only docs with at least one of the given tags, untagged excluded
result = await memory.list_documents(
bank_id=bank_id,
tags=["team-a"],
tags_match="any_strict",
request_context=request_context,
)
ids = {d["id"] for d in result["items"]}
assert ids == {"doc-alpha", "doc-both"}
assert result["total"] == 2
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_list_documents_tags_filter_any_includes_untagged(memory, request_context):
"""tags filter with 'any' mode includes untagged documents."""
bank_id = f"test_list_docs_tags_any_{datetime.now(timezone.utc).timestamp()}"
try:
await _retain_doc(memory, bank_id, "doc-tagged", ["team-a"], request_context)
await _retain_doc(memory, bank_id, "doc-other", ["team-b"], request_context)
await _retain_doc(memory, bank_id, "doc-untagged", [], request_context)
result = await memory.list_documents(
bank_id=bank_id,
tags=["team-a"],
tags_match="any",
request_context=request_context,
)
ids = {d["id"] for d in result["items"]}
# "any" includes untagged + matching tagged
assert "doc-tagged" in ids
assert "doc-untagged" in ids
assert "doc-other" not in ids
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_list_documents_tags_filter_all_strict(memory, request_context):
"""tags filter with all_strict returns only docs that have ALL the specified tags."""
bank_id = f"test_list_docs_tags_all_{datetime.now(timezone.utc).timestamp()}"
try:
await _retain_doc(memory, bank_id, "doc-a-only", ["team-a"], request_context)
await _retain_doc(memory, bank_id, "doc-a-and-b", ["team-a", "team-b"], request_context)
await _retain_doc(memory, bank_id, "doc-untagged", [], request_context)
result = await memory.list_documents(
bank_id=bank_id,
tags=["team-a", "team-b"],
tags_match="all_strict",
request_context=request_context,
)
ids = {d["id"] for d in result["items"]}
assert ids == {"doc-a-and-b"}
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_list_documents_no_tags_filter_returns_all(memory, request_context):
"""When no tags filter is specified, all documents are returned."""
bank_id = f"test_list_docs_no_tags_{datetime.now(timezone.utc).timestamp()}"
try:
await _retain_doc(memory, bank_id, "doc-tagged", ["team-a"], request_context)
await _retain_doc(memory, bank_id, "doc-untagged", [], request_context)
result = await memory.list_documents(
bank_id=bank_id,
tags=None,
request_context=request_context,
)
ids = {d["id"] for d in result["items"]}
assert ids == {"doc-tagged", "doc-untagged"}
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_list_documents_tags_and_search_query_combined(memory, request_context):
"""tags filter and q (search_query) can be combined."""
bank_id = f"test_list_docs_tags_q_{datetime.now(timezone.utc).timestamp()}"
try:
await _retain_doc(memory, bank_id, "report-2024", ["team-a"], request_context)
await _retain_doc(memory, bank_id, "report-2025", ["team-b"], request_context)
await _retain_doc(memory, bank_id, "summary-2024", ["team-a"], request_context)
result = await memory.list_documents(
bank_id=bank_id,
search_query="report",
tags=["team-a"],
tags_match="any_strict",
request_context=request_context,
)
ids = {d["id"] for d in result["items"]}
assert ids == {"report-2024"}
finally:
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):
"""
+780
View File
@@ -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)
+16 -18
View File
@@ -48,11 +48,11 @@ async def pool(pg0_db_url):
@pytest_asyncio.fixture
async def clean_operations(pool):
"""Clean up async_operations table before and after tests."""
# Clean before test
await pool.execute("DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%'")
# Clean before test - covers both 'test-worker-' and 'test_worker_recovery' patterns
await pool.execute("DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'")
yield
# Clean after test
await pool.execute("DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%'")
await pool.execute("DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'")
class TestBrokerTaskBackend:
@@ -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 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.4.14"
version = "0.4.16"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
+2
View File
@@ -300,6 +300,8 @@ impl ApiClient {
offset.map(|o| o as i64),
q,
None,
None,
None,
).await?;
Ok(response.into_inner())
})
+582 -2
View File
@@ -7,7 +7,7 @@ info:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
title: Hindsight HTTP API
version: 0.4.14
version: 0.4.16
servers:
- url: /
paths:
@@ -1173,7 +1173,9 @@ paths:
title: Bank Id
type: string
style: simple
- explode: true
- description: Case-insensitive substring filter on document ID (e.g. 'report'
matches 'report-2024')
explode: true
in: query
name: q
required: false
@@ -1181,6 +1183,28 @@ paths:
nullable: true
type: string
style: form
- description: Filter documents by tags
explode: true
in: query
name: tags
required: false
schema:
items:
type: string
nullable: true
type: array
style: form
- description: "How to match tags: 'any', 'all', 'any_strict', 'all_strict'"
explode: true
in: query
name: tags_match
required: false
schema:
default: any_strict
description: "How to match tags: 'any', 'all', 'any_strict', 'all_strict'"
title: Tags Match
type: string
style: form
- explode: true
in: query
name: limit
@@ -2087,6 +2111,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\
@@ -2855,6 +3121,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:
@@ -4413,6 +4720,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
@@ -4457,6 +4799,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
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.14
API version: 0.4.16
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.14
API version: 0.4.16
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+34 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.14
API version: 0.4.16
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
@@ -17,6 +17,7 @@ import (
"net/http"
"net/url"
"strings"
"reflect"
)
@@ -409,16 +410,31 @@ type ApiListDocumentsRequest struct {
ApiService *DocumentsAPIService
bankId string
q *string
tags *[]string
tagsMatch *string
limit *int32
offset *int32
authorization *string
}
// Case-insensitive substring filter on document ID (e.g. &#39;report&#39; matches &#39;report-2024&#39;)
func (r ApiListDocumentsRequest) Q(q string) ApiListDocumentsRequest {
r.q = &q
return r
}
// Filter documents by tags
func (r ApiListDocumentsRequest) Tags(tags []string) ApiListDocumentsRequest {
r.tags = &tags
return r
}
// How to match tags: &#39;any&#39;, &#39;all&#39;, &#39;any_strict&#39;, &#39;all_strict&#39;
func (r ApiListDocumentsRequest) TagsMatch(tagsMatch string) ApiListDocumentsRequest {
r.tagsMatch = &tagsMatch
return r
}
func (r ApiListDocumentsRequest) Limit(limit int32) ApiListDocumentsRequest {
r.limit = &limit
return r
@@ -480,6 +496,23 @@ func (a *DocumentsAPIService) ListDocumentsExecute(r ApiListDocumentsRequest) (*
if r.q != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "q", r.q, "form", "")
}
if r.tags != nil {
t := *r.tags
if reflect.TypeOf(t).Kind() == reflect.Slice {
s := reflect.ValueOf(t)
for i := 0; i < s.Len(); i++ {
parameterAddToHeaderOrQuery(localVarQueryParams, "tags", s.Index(i).Interface(), "form", "multi")
}
} else {
parameterAddToHeaderOrQuery(localVarQueryParams, "tags", t, "form", "multi")
}
}
if r.tagsMatch != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "tags_match", r.tagsMatch, "form", "")
} else {
var defaultValue string = "any_strict"
r.tagsMatch = &defaultValue
}
if r.limit != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "limit", r.limit, "form", "")
} else {
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.14
API version: 0.4.16
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.14
API version: 0.4.16
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.14
API version: 0.4.16
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.14
API version: 0.4.16
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.14
API version: 0.4.16
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.14
API version: 0.4.16
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+691
View File
@@ -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
}
+5 -2
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.14
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.14
// 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
}
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.14
API version: 0.4.16
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -67,7 +67,7 @@ func TestRetainWithContext(t *testing.T) {
Items: []MemoryItem{
{
Content: "Bob went hiking in the mountains",
Timestamp: *NewNullableTime(PtrTime(timestamp)),
Timestamp: *NewNullableTimestamp(&Timestamp{TimeTime: &timestamp}),
Context: *NewNullableString(PtrString("outdoor activities")),
},
},
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.14
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.14
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.14
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.14
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.14
API version: 0.4.16
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.14
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.14
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.14
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.14
API version: 0.4.16
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.14
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.14
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.14
API version: 0.4.16
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.14
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.14
API version: 0.4.16
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
+1 -1
View File
@@ -3,7 +3,7 @@ Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.14
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.14
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.14
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.14
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.14
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.14
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.14
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