Compare commits

..
Author SHA1 Message Date
Nicolò Boschi 9049904ee8 fix: ui shows only 1000 memories 2026-01-08 11:12:06 +01:00
Nicolò Boschi de6b253ead fix: ui shows only 1000 memories 2026-01-08 11:03:02 +01:00
Alexander Pinsker 29a542dc23 feat: Add per-request LLM token usage metrics (#117)
* feat: Record LLM token metrics via Prometheus

Wire up the existing token metrics infrastructure to actually record
token usage from LLM calls. The MetricsCollector already had
record_tokens() method and Prometheus counters (hindsight.tokens.input,
hindsight.tokens.output), but they were never being populated.

Changes:
- Import get_metrics_collector in llm_wrapper.py
- Call record_tokens() after successful LLM calls for:
  - OpenAI/Groq (using response.usage.prompt_tokens, completion_tokens)
  - Anthropic (using response.usage.input_tokens, output_tokens)
  - Gemini (using response.usage_metadata.prompt_token_count, candidates_token_count)
- Add test file to verify token metrics are recorded

Note: Ollama's native API doesn't return token usage, so metrics
are not recorded for that provider.

The token metrics will now be available via /metrics endpoint:
- hindsight_tokens_input_total
- hindsight_tokens_output_total

* feat: add per-request token usage tracking to retain and reflect endpoints

- Add TokenUsage model with input_tokens, output_tokens, total_tokens
- Return usage metrics in retain response (sync operations only)
- Return usage metrics in reflect response
- Update Python, TypeScript, and Rust clients
- Add API documentation for usage fields
- Add changelog entry
2026-01-08 10:36:58 +01:00
Anatolii LapytskyiandAnatolii Lapytskyi ecc1f31996 feat(helm): add existingSecret support (#119)
* feat(helm): add existingSecret support

Allow users to reference a pre-existing Kubernetes Secret instead of
having the chart create one. This enables better secret management
through tools like External Secrets Operator or sealed-secrets.

Usage:
```yaml
existingSecret: "my-pre-created-secret"
```

When existingSecret is set:
- The chart skips creating its own Secret resource
- Deployments reference the provided secret name
- Secret checksum annotation is omitted (no auto-rollout on changes)

The existing secret should contain all required keys:
- API secrets (e.g., HINDSIGHT_API_LLM_API_KEY)
- Control plane secrets
- postgres-password (if using external PostgreSQL)

* fix(helm): use envFrom for existingSecret and fix env var ordering

- Add envFrom to inject all keys from existingSecret as env vars automatically
- Fix POSTGRES_PASSWORD ordering (must be before DATABASE_URL for $(VAR) interpolation)
- Only use api.secrets/controlPlane.secrets when existingSecret is not set
- Update values.yaml documentation for existingSecret usage

---------

Co-authored-by: Anatolii Lapytskyi <[email protected]>
2026-01-08 10:36:03 +01:00
Nicolò Boschi 233bd2e5d4 feat: run db migrations offline (optionally) (#114)
* feat: run db migrations offline (optionally)

* fix
2026-01-07 15:49:51 +01:00
Nicolò Boschi b3becb6e9a fix(security): fix qs - CVE-2025-15284 (#113)
* fix(security): fix qs - CVE-2025-15284

* fix
2026-01-07 15:33:07 +01:00
Nicolò Boschi 67b273de69 feat: backup/restore (#110)
* feat: backup/restore

* feat: backup/restore

* fix
2026-01-07 11:29:50 +01:00
Nicolò Boschi 5a3090b5e5 ci: pin rust lock version (#112) 2026-01-07 11:29:41 +01:00
Nicolò Boschi 2a00df0bc0 fix: improve causal links detection (#111)
* fix: improve causal links detection

* fix: improve causal links detection
2026-01-07 11:16:24 +01:00
Nicolò Boschi 7715a5110e fix: make retain max completion tokens configurable (#109)
* fix: make retain max completion tokens configurable

* fix: make retain max completion tokens configurable
2026-01-07 10:26:42 +01:00
Chris Bartholomew c06d9b4e4f Load .env file automatically on startup (#104)
Add automatic .env file loading using python-dotenv. This searches
the current working directory and parent directories for a .env file
and loads environment variables from it.

Uses override=True so .env file values take precedence over existing
shell environment variables, which is the expected behavior when
running from a project directory.
2026-01-07 09:49:13 +01:00
Chris Bartholomew 39e3f7c528 Fix Python SDK not sending Authorization header (#106)
* Fix Python SDK not sending Authorization header

The Python SDK accepts an api_key parameter but never sends it as a
Bearer token in requests. The OpenAPI-generated Configuration class
stores the key in access_token, but auth_settings() returns an empty
dict because the OpenAPI spec doesn't define a security scheme.

This fix manually sets the Authorization header on the ApiClient,
bypassing the broken auth_settings() mechanism.

Tested against api.dev.hindsight.vectorize.io:
- Before: 401 "Authentication failed: API key required"
- After: Success

* chore: update Rust client Cargo.lock for CI verification

Run generate-clients.sh to sync Cargo.lock with current dependencies.
2026-01-07 09:46:50 +01:00
Nicolò Boschi d899d1890d fix: groq llm with free tier doesn't work (#102)
* fix: groq with free tier doens't work

* fix: groq with free tier doens't work
2026-01-05 15:10:35 +01:00
Nicolò Boschi 70de23ed85 feat: configurable embedding dimensions + OpenAI Embeddings (#101)
* feat: configurable embedding dimensions + OpenAI Embeddings

* fix tests
2026-01-05 14:43:05 +01:00
Nicolò Boschi 1984936150 Release v0.2.1
- Update version to 0.2.1 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2026-01-05 12:36:49 +01:00
Nicolò Boschi 4f21886a0e doc: changelog for 0.2.0 (and regenerate clients) (#99)
* doc: changelog for 0.2.0 (and regenerate clients)

* doc: changelog for 0.2.0 (and regenerate clients)

* doc: changelog for 0.2.0 (and regenerate clients)

* doc: changelog for 0.2.0 (and regenerate clients)

* doc: changelog for 0.2.0 (and regenerate clients)

* doc: changelog for 0.2.0 (and regenerate clients)

* doc: changelog for 0.2.0 (and regenerate clients)

* doc: changelog for 0.2.0 (and regenerate clients)
2026-01-05 12:36:29 +01:00
Nicolò Boschi 5e65691743 Release v0.2.0
- Update version to 0.2.0 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2026-01-05 11:34:52 +01:00
Nicolò Boschi 76fd052b3a misc: add mcp integration tests and increase test coverage (#98)
* misc: add mcp integration tests and increase test coverage

* misc: add mcp integration tests and increase test coverage

* misc: add mcp integration tests and increase test coverage
2026-01-05 11:16:55 +01:00
Bjorn SchliebitzandClaude Opus 4.5 6b5f593dca feat(mcp): Add multi-bank access and new MCP tools (#82)
* feat(mcp): Add multi-bank access and new MCP tools

Enables orchestrator agents to access multiple memory banks from a
single MCP connection, with new tools for bank management.

## New MCP Tools
- `reflect` - Thoughtful analysis using bank's personality and memories
- `list_banks` - Discover all available memory banks
- `create_bank` - Create new banks programmatically

## Multi-Bank Access
- Added optional `bank_id` parameter to `retain`, `recall`, `reflect`
- Allows cross-bank operations from a single MCP session
- Defaults to session bank if not specified

## Claude Code Compatibility
- Enabled `stateless_http=True` for proper Claude Code integration
- Responses now include `bank_id` for transparency

## Documentation
- Added docker-compose.example.yml with env var substitution
- Added HINDSIGHT-DOCKER.md setup guide with volume persistence docs
- Updated .gitignore to exclude local docker-compose.yml

## Use Case
Orchestrator agents can now:
- Maintain a private meta-orchestration bank
- Access shared project knowledge banks
- Query across banks for cross-context insights

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Address PR review feedback: remove docker files, improve reflect description

- Remove HINDSIGHT-DOCKER.md and docker-compose.example.yml per reviewer request
- Improve reflect tool description with clearer guidance for AI agents:
  - Added "WHEN TO USE THIS TOOL" section
  - Added "EXAMPLES OF GOOD QUERIES" with concrete use cases
  - Added "HOW IT DIFFERS FROM RECALL" to clarify when to use each tool

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-01-05 10:06:52 +01:00
Phạm Gia Linh dd59bc8ef9 feat: Add user-provided entities support to retain endpoint (#91)
* feat: entities input for retain endpoint

* remove docker-compose.yml
2026-01-05 10:05:17 +01:00
csfet9andClaude Opus 4.5 eea0f27118 feat: Add local LLM improvements for reasoning models and Docker startup (#88)
* feat: Add local LLM improvements for reasoning models and Docker startup

## Reasoning Model Support
- Strip thinking tags from local LLM responses (<think>, <thinking>, <reasoning>, |startthink|/|endthink|)
- Enables Qwen3, DeepSeek, and other reasoning models to work with JSON extraction
- Non-breaking: only affects responses that contain thinking tags

## Docker Retry Start Script
- New retry-start.sh waits for dependencies before starting Hindsight
- Checks LLM Studio availability at /v1/models endpoint
- Checks database connectivity (skipped for embedded pg0)
- Configurable via HINDSIGHT_RETRY_MAX and HINDSIGHT_RETRY_INTERVAL env vars
- Prevents startup failures when LLM Studio isn't ready yet

Tested on Apple Silicon M4 Max with Qwen3 8B via LM Studio.

* refactor: make thinking token stripping opt-in via env var

* refactor: merge retry logic into start-all.sh (opt-in via HINDSIGHT_WAIT_FOR_DEPS)

* fix: resolve pg0 stale instance config in Docker build

- Remove stale pg0 instance data after pre-caching binaries to avoid
  port conflicts (was using hardcoded port 5555 from build time)
- Remove unused cache copy logic from start-all.sh
- Add database backup instructions to CLAUDE.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-01-05 10:04:58 +01:00
Nicolò Boschi 964537f885 chore: add pre-commit setup instructions 2026-01-05 10:03:15 +01:00
Chris Latimer 1a620697b1 Feature/graph viz (#85)
* Improve graph visualization on the UI

* Fix double animation when loading the graph visualization

* Fix typescript issues

* CI test changes for temporal scenarios

* Fix typescript errors

* Fix animation issue on opinions and experiences
2026-01-02 16:27:29 +01:00
Chris Bartholomew ce45d301ce Add operation validator extension support with proper HTTP error handling (#86)
* Load operation validator extension in main entry point

Enable the operation validator extension to be loaded from environment
configuration and passed to MemoryEngine, allowing pre/post operation
hooks for usage metering, rate limiting, and audit logging.

* Fix reflect background task authentication and add internal flag

- Pass API key to background opinion storage task for proper auth
- Add internal flag to RequestContext for tracking internal operations
- Background opinion storage now authenticates correctly with tenant

* Add api_key_id to RequestContext for usage tracking

- Add api_key_id field to RequestContext to track which API key was used
- Enables per-API-key usage analytics in the metering system

* Fix HTTP error handling for authentication and validation errors

- Add status_code parameter to ValidationResult and OperationValidationError
- Convert OperationValidationError to HTTPException with proper status codes
- Fix authentication errors to return 401 instead of raising internal errors
- Re-raise HTTPException in exception handlers to prevent swallowing errors

* Fix AuthenticationError handling in memory engine

- Raise AuthenticationError from memory_engine._authenticate_tenant instead
  of HTTPException so unit tests pass
- Add AuthenticationError handling in HTTP layer to convert to 401 responses
- Fixes failing TestMemoryEngineTenantAuth tests

* Add global exception handler for AuthenticationError

Returns proper 401 status code for all authentication failures
across all endpoints, not just the ones with explicit handlers.

* Simplify exception handling: use global AuthenticationError handler

- Remove redundant individual exception handlers
- Add 'except AuthenticationError: raise' before generic Exception handlers
  to let global handler process auth errors uniformly

* Refactor background tasks to use tenant_id instead of api_key

This makes the core more generic - it passes tenant_id (which is
extension-agnostic) rather than api_key (which is cloud-specific).

- Add tenant_id field to RequestContext
- Pass tenant_id instead of api_key to background tasks
- Extensions can check internal=True with tenant_id to bypass normal auth

* Fix exception propagation: include HTTPException in re-raise

After cleanup of redundant exception handlers, 404 errors were
returning 500 because HTTPException was caught by the generic
except Exception handler. Fixed by combining AuthenticationError
and HTTPException in the re-raise pattern.
2026-01-01 20:19:52 -05:00
Nicolò Boschi d49e8201b4 feat: add max_tokens and structured output to /reflect (#74)
* feat: add structured output to /reflect

* feat: add structured output to /reflect

* imrpove

* add max_toksn

* fix rust client

* fix rust client

* fix rust client

* try fix

* try fix

* no stricts
2026-01-01 17:09:39 +01:00
Nicolò Boschi c8c7603580 feat(doc): add new config options and supported providers (#84) 2026-01-01 17:09:05 +01:00
csfet9andClaude Opus 4.5 787ed60763 feat: Add Anthropic Claude and LM Studio provider support (#36)
* feat: Add Anthropic Claude and LM Studio provider support

- Add Anthropic as LLM provider with full async support
- Add LM Studio provider for local model inference
- Fix JSON response format compatibility for local models
- Update .env.example with configuration examples
- Update docstrings with all supported providers

Tested with:
- Claude Sonnet 4 (claude-sonnet-4-20250514)
- Claude Haiku 4.5 (claude-haiku-4-5-20251001)
- Qwen 30B via LM Studio

* feat: Add dynamic timeout for local LLM providers

Add configurable timeout support for LLM API calls:
- Environment variable override via HINDSIGHT_API_LLM_TIMEOUT
- Dynamic heuristic for lmstudio/ollama: 20 mins for large models
  (30b, 33b, 34b, 65b, 70b, 72b, 8x7b, 8x22b), 5 mins for others
- Pass timeout to Anthropic, OpenAI, and local model clients

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: Address PR review feedback

- Remove CLAUDE.md from .gitignore (should stay in repository)
- Pass max_completion_tokens to _call_anthropic instead of hardcoding 4096

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* chore: Remove deleted AI assistant files from .gitignore

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* docs: Add CLAUDE.md for Claude Code integration

Provides project context and development commands for AI-assisted coding.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* chore: Include local dev files and sync changes

- Add docker-compose.yml for local development
- Add test_internal.py for local testing
- Sync uv.lock and llm_wrapper.py changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: Address PR review feedback for LLM provider support

- Move LLM config to config.py with HINDSIGHT_API_ prefix
  - Add HINDSIGHT_API_LLM_MAX_CONCURRENT (default: 32)
  - Add HINDSIGHT_API_LLM_TIMEOUT (default: 120s)
- Remove fragile model-size timeout heuristic
- Apply markdown JSON extraction to all providers, not just local
- Fix Anthropic markdown extraction bug (missing split)
- Change LLM request/response logs from info to debug level

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* chore: Remove local dev docker-compose.yml

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* chore: Add local dev docker-compose.yml

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: Update LM Studio port to 2222 in docker-compose

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* chore: Remove obsolete version attribute from docker-compose

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: Remove test file and docker-compose per PR review

- Remove test_internal.py (debug file)
- Remove docker-compose.yml (to be moved to hindsight-cookbook repo)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-01-01 16:34:11 +01:00
Bjorn SchliebitzandClaude Opus 4.5 6b78f7d949 fix(mcp): Chain MCP lifespan with FastAPI app lifespan (#81)
The MCP server's lifespan was not being properly chained with the
FastAPI app's lifespan, causing the MCP server to not start/stop
correctly when mounted as a sub-application.

Changes:
- Create MCP app before FastAPI app to access its lifespan
- Chain MCP lifespan context with FastAPI's lifespan context
- Ensures MCP server lifecycle is properly managed

This fix is required for the MCP server to function correctly when
used with Claude Code and other MCP clients.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-01-01 16:33:58 +01:00
Bjorn SchliebitzandClaude Opus 4.5 54e2df0baf feat(config): Add configurable observation thresholds (#83)
Allows tuning of entity observation generation via environment variables.

## New Environment Variables
- `HINDSIGHT_API_OBSERVATION_MIN_FACTS` - Minimum facts required to
  generate entity observations (default: 5)
- `HINDSIGHT_API_OBSERVATION_TOP_ENTITIES` - Maximum entities to process
  per retain batch (default: 5)

## Changes
- Added threshold configuration to HindsightConfig
- Updated memory_engine.py to use config values
- Updated observation_regeneration.py to use config values

## Use Case
Lower thresholds generate more observations (better recall, higher cost).
Higher thresholds are more selective (lower cost, may miss patterns).

Example:
```bash
# Generate more observations
docker run -e HINDSIGHT_API_OBSERVATION_MIN_FACTS=3 \
           -e HINDSIGHT_API_OBSERVATION_TOP_ENTITIES=10 ...
```

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <[email protected]>
2026-01-01 16:22:25 +01:00
Chris Latimer 967e586e01 Add model providers on README 2025-12-24 10:46:53 -07:00
Chris Bartholomew dfa7cec05b Load operation validator extension in main entry point (#72)
Enable the operation validator extension to be loaded from environment
configuration and passed to MemoryEngine, allowing pre/post operation
hooks for usage metering, rate limiting, and audit logging.
2025-12-23 15:47:26 +01:00
Nicolò Boschi 36e48a7166 doc: add skills documentation (#73)
* doc: add skills documentation

* doc: add skills documentation
2025-12-23 15:42:27 +01:00
Nicolò Boschi 786b1ecbbd Release v0.1.16
- Update version to 0.1.16 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm, hindsight-embed
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-23 14:12:03 +01:00
Nicolò Boschi f14f277692 fix: hindsight-embed release version 2025-12-23 14:11:49 +01:00
Nicolò Boschi c9f3657de6 0.1.15 changelog 2025-12-23 13:54:41 +01:00
Nicolò Boschi 0ae0374dc8 Release v0.1.15
- Update version to 0.1.15 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-23 13:54:14 +01:00
Nicolò Boschi f7ff32d49d feat: delete document from ui (#71)
* feat: delete document from ui

* feat: delete document from ui
2025-12-23 13:54:06 +01:00
Nicolò Boschi e06a6120a3 feat(misc): update clients types, test coverage, improve /health endpoint and add changelog (#70)
* doc: changelog and delete doc info

* others

* others

* fixes

* fixes
2025-12-23 12:49:31 +01:00
Nicolò Boschi e599346e59 Release v0.1.14
- Update version to 0.1.14 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-23 10:37:23 +01:00
Nicolò Boschi 0b352d1bfa fix: embed get-skill installer (#69) 2025-12-23 10:36:36 +01:00
Nicolò Boschi c882511f10 Release v0.1.13
- Update version to 0.1.13 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-22 22:27:49 +01:00
Nicolò Boschi 234d426499 fix(ui): timestamp is not considered in retain (#68) 2025-12-22 22:27:31 +01:00
Nicolò Boschi e6511e7d77 feat: refactor hindsight-embed architecture (#66)
* feat: refactor hindsight-embed architecture

* feat: refactor hindsight-embed architecture

* refactor deamin

* refactor deamin

* refactor deamin

* refactor deamin
2025-12-22 22:02:40 +01:00
Chris Bartholomew 904ea4de24 fix: propagate exceptions from task handlers to enable retry logic (#65)
Task handlers were swallowing exceptions, causing operations to be
marked as completed even when they failed. This prevented the retry
logic in execute_task() from working and led to accumulation of
pending operations that never completed.

Fixed handlers:
- _handle_batch_retain: remove try/except wrapper
- _handle_access_count_update: remove try/except wrapper
- _handle_regenerate_observations: remove outer try/except, keep
  inner one for individual entity failures
2025-12-22 20:42:57 +01:00
Nicolò Boschi 6168a77846 Release v0.1.12
- Update version to 0.1.12 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-all, hindsight-litellm
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-22 16:44:05 +01:00
Nicolò Boschi da44a5e839 feat: add hindsight-embed and native agentic skill (#64) 2025-12-22 16:42:11 +01:00
Nicolò Boschi 32bca12c6f fix: ollama structured support (#63)
* fix: ollama structured support

* fix: ollama structured support

* fix: ollama structured support
2025-12-22 16:35:24 +01:00
Nicolò Boschi 26850a0156 doc: add documentation for extensions (#62)
* add doc for extensions

* add doc for extensions
2025-12-22 11:58:05 +01:00
Nicolò Boschi 2a0c490c9e feat: extensions (#54) 2025-12-22 11:05:23 +01:00
cesarandreslopezandCAL a831a7b77b Improve LLM JSON parsing error handling with retry logic and detailed logging (#61)
* Improve LLM JSON parsing error handling with retry logic and detailed logging

* npm changes (packaging)

---------

Co-authored-by: CAL <[email protected]>
2025-12-22 10:44:02 +01:00
DK09876 d405b4feed ci: finalize test for the documentation code (#57)
* Fix main-methods.py: entities is a dict, use .items() and .canonical_name

* Migrate docs to use CodeSnippet components

- Convert quickstart.md, retain.md, recall.md, reflect.md, memory-banks.md to .mdx
- Use CodeSnippet to pull code from validated example scripts
- Add missing 'name' parameter to create_bank calls
- Fix main-methods.py entities iteration (dict not list)
- Remove retain-new.mdx demo file

* Migrate existing docs to match testing pattern with code snippet and add CLI tests to the CI

* Fix doc-id issue + add main-method tests

* CLI fixes

* Update openAPI json

* Fix rust build issues

* increase sleep time for Hindsight to process the document

* Added a polling sleep instead of fixed

* Delete immediately fails, so create the doc a earlier in the test to get the doc ready

* Add debug logs

* Remove debug logs
2025-12-19 12:17:59 -07:00
Nicolò Boschi b94b5cf26e fix: set max_completion_tokens to 100 in llm validation (#59) 2025-12-19 09:32:43 +01:00
Nicolò Boschi 6d820ef91b doc: add openai api compatible note 2025-12-18 16:19:30 +01:00
Nicolò Boschi cf8882a867 changelog for 0.1.11 2025-12-18 14:40:30 +01:00
Nicolò Boschi 490fccdc6f Release v0.1.11
- Update version to 0.1.11 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-dev/benchmarks, hindsight-all, hindsight-litellm
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-18 14:09:44 +01:00
Nicolò Boschi 2948cb62d2 fix: docker image and control plane standalone build 2025-12-18 14:07:41 +01:00
Nicolò Boschi 9053a51a88 update changelog for 0.1.10 2025-12-18 13:36:48 +01:00
Nicolò Boschi f2c28cfd98 Release v0.1.10
- Update version to 0.1.10 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-dev/benchmarks, hindsight-all, hindsight-litellm
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-18 13:10:56 +01:00
Nicolò Boschi 67fc532c43 ci: make release faster and restartable 2025-12-18 13:10:46 +01:00
Nicolò Boschi 9474f950f2 fix release process 2025-12-18 12:10:01 +01:00
Nicolò Boschi 6a0c034f5d Release v0.1.9
- Update version to 0.1.9 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-dev/benchmarks, hindsight-all, hindsight-litellm
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-18 12:00:03 +01:00
Nicolò Boschi b52eb905ad fix: docker image build and startup (#46)
* ci: add docker smoke test to ci

* fix alpine version

* fix

* fix space

* fix space

* comment out

* docker fixes

* docker fixes

* docker fixes

* docker fixes

* docker fixes
2025-12-18 11:59:25 +01:00
Nicolò Boschi 1c6acc3ba0 feat: simplify mcp installation + ui standalone (#41) 2025-12-18 10:24:56 +01:00
DK09876 8ecb5d3a0c Add documentation code validation system (#43)
* Add documentation code validation system

- Create runnable example scripts in examples/api/ (19 files)
- Add CodeSnippet component for extracting marked sections
- Add raw-loader dependency for importing source files
- Create sample retain-new.mdx showing new approach
- Add README documenting coverage and gaps

* Fix wheel glob expansion in test-doc-examples CI job

* Fix CI issue

* Fix wheel path - uv build outputs to repo root dist/

* Fix: use explicit shell expansion for wheel install

* Fix: run cd in subshell so install runs from repo root

* Add documentation code validation CI job

- Use uv sync + uv run pattern (matches existing CI)
- Add requests to test dependencies for cleanup scripts

* Fix async API client usage in documents.py example

* Fix main-methods.py: RecallResult and ReflectFact don't have weight attribute

* Fix opinions.py: use actual API attributes instead of non-existent ones

* Fix example scripts: remove non-existent API attributes

- recall.py: remove .weight, fix entities iteration (dict not list)
- retain.mjs: remove result.async check
2025-12-18 10:21:38 +01:00
Chris Bartholomew ae80876671 fix: add procps to Docker image and smoke test to release workflow (#45)
* fix: add procps to Docker image and smoke test to release workflow

The Docker image was failing to start because pg0 uses `kill -0 <pid>`
to check if PostgreSQL is running, but the python:3.11-slim base image
doesn't include the `kill` command. Adding procps provides it.

This has been broken since release 0.1.6 when the fallback URI code was
removed to support dynamic ports. Without the kill command, pg0 couldn't
detect process status and returned None for the database URI.

Also adds smoke testing to the release workflow:
- Build image locally (single platform) and test before pushing
- Run container and wait for /health endpoint (up to 120s)
- Only push multi-platform release images if smoke test passes
- Each image (api-only, cp-only, standalone) tested independently

This prevents releasing broken Docker images to GHCR.

* refactor: extract smoke test into reusable script

Add scripts/docker-smoke-test.sh that can be run locally or in CI:
- Takes image name and optional target (cp-only vs api)
- Handles LLM credentials for API/standalone images
- Configurable timeout via SMOKE_TEST_TIMEOUT env var
- Colored output and clear error messages
- Proper cleanup on exit

Update release workflow to use the script instead of inline bash.
2025-12-17 22:01:53 +01:00
Chris Bartholomew 476a62da47 Add Hindsight Cloud links to README and docs (#42)
- Add Hindsight Cloud link to README header
- Add Hindsight Cloud navbar item in docs
- Add callout in installation docs for managed alternative
2025-12-17 11:09:36 -05:00
Nicolò Boschi 5aaa769ab9 Release v0.1.8
- Update version to 0.1.8 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-dev/benchmarks, hindsight-all, hindsight-litellm
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-17 13:21:35 +01:00
Nicolò Boschi 04f01ab9ab fix: bank list response with no name banks 2025-12-17 13:21:16 +01:00
Nicolò Boschi 63f51385c4 fix: retain async fails (#40)
* fix: retain async fails

* fix: retain async fails
2025-12-17 13:17:38 +01:00
William Simmonds e468a4e19f fix: bank selector race condition when switching banks (#38) (#39) 2025-12-17 12:56:24 +01:00
Nicolò Boschi c0a0f447b7 Update README.md 2025-12-17 10:20:02 +01:00
Nicolò Boschi 84927ccc99 add run benchmarks instructions 2025-12-16 17:24:45 +01:00
Chris Latimer a6e8944ff0 README updates 2025-12-16 07:09:30 -07:00
Nicolò Boschi f6d890f6ed Release v0.1.7
- Update version to 0.1.7 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-dev/benchmarks, hindsight-all, hindsight-litellm
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-16 14:29:07 +01:00
Nicolò Boschi 1fa8d9150c ci: check compatibility with python 3.11, 3.12 and 3.13 (#35) 2025-12-16 14:28:45 +01:00
Nicolò Boschi 656777c2be 0.1.6 changelog 2025-12-16 14:09:15 +01:00
Nicolò Boschi b36807ad3b Release v0.1.6
- Update version to 0.1.6 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-dev/benchmarks, hindsight-all, hindsight-litellm
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-16 13:49:57 +01:00
Nicolò Boschi 11ac9cd9a5 less verbose git hooks 2025-12-16 13:49:38 +01:00
Nicolò Boschi 9394cf92f2 fix: doc build and lint files (#34)
* fix doc build

* fix doc build
2025-12-16 13:49:09 +01:00
Nicolò Boschi 47be07f97f bump pg0 0.11.x and improve documentation (#33)
* bump pg0 0.11.x and improve documentation

* bump pg0 0.11.x and improve documentation

* bump pg0 0.11.x and improve documentation

* ci: test notebooks on ci

* ci: test notebooks on ci

* rm llms-full from repo

* formatting

* formatting
2025-12-16 13:33:01 +01:00
Nicolò Boschi bb1f9cb221 feat: support for gemini-3-pro and gpt-5.2 (#30)
* feat: support for gemini-3-pro and gpt-5.2

* feat: support for gemini-3-pro and gpt-5.2

* feat: support for gemini-3-pro and gpt-5.2

* feat: support for gemini-3-pro and gpt-5.2

* feat: add local mcp server

* docs

* docs
2025-12-16 11:00:27 +01:00
Nicolò Boschi 7dd68538bb feat: add local mcp server (#32) 2025-12-16 10:50:20 +01:00
470 changed files with 42095 additions and 35701 deletions
+12
View File
@@ -2,11 +2,23 @@
# Copy this file to .env and fill in your values
# LLM Configuration (Required)
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio
HINDSIGHT_API_LLM_PROVIDER=openai
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
HINDSIGHT_API_LLM_MODEL=o3-mini
HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# Example: Anthropic Claude configuration
# HINDSIGHT_API_LLM_PROVIDER=anthropic
# HINDSIGHT_API_LLM_API_KEY=your-anthropic-api-key
# HINDSIGHT_API_LLM_MODEL=claude-sonnet-4-20250514
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
# HINDSIGHT_API_LLM_API_KEY=lmstudio
# HINDSIGHT_API_LLM_BASE_URL=http://localhost:1234/v1
# HINDSIGHT_API_LLM_MODEL=qwen2.5-32b-instruct
# API Configuration (Optional)
HINDSIGHT_API_HOST=0.0.0.0
HINDSIGHT_API_PORT=8888
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# Pre-commit hook - runs all scripts in scripts/hooks/
set -e
REPO_ROOT="$(git rev-parse --show-toplevel)"
HOOKS_DIR="$REPO_ROOT/scripts/hooks"
if [ ! -d "$HOOKS_DIR" ]; then
exit 0
fi
echo ""
echo "=== Running pre-commit hooks ==="
echo ""
# Run all executable scripts in hooks directory
for hook in "$HOOKS_DIR"/*.sh; do
if [ -x "$hook" ]; then
echo "[hook] $(basename "$hook")"
(cd "$REPO_ROOT" && "$hook")
fi
done
echo ""
echo "=== Pre-commit hooks completed ==="
echo ""
+71
View File
@@ -0,0 +1,71 @@
name: Bug Report
description: Report a bug or unexpected behavior
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the sections below.
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of the bug
placeholder: What happened?
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to Reproduce
description: Steps to reproduce the behavior
placeholder: |
1. Configure '...'
2. Call '...'
3. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: What did you expect to happen?
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
description: What actually happened?
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: What version are you using?
placeholder: e.g., 0.1.0 or commit hash
validations:
required: false
- type: dropdown
id: llm-provider
attributes:
label: LLM Provider
description: Which LLM provider are you using?
options:
- OpenAI
- Anthropic
- Gemini
- Groq
- Ollama
- LM Studio
- Other
validations:
required: false
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Questions & Help
url: https://github.com/vectorize-io/hindsight/discussions/categories/q-a
about: Please ask questions and get help in Discussions instead of opening an issue.
- name: Ideas & Feedback
url: https://github.com/vectorize-io/hindsight/discussions/categories/ideas
about: Share ideas or give feedback in Discussions.
@@ -0,0 +1,82 @@
name: Feature Request
description: Suggest a new feature or enhancement
labels: ["enhancement", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a feature! Please describe what you'd like to see added.
- type: textarea
id: use-case
attributes:
label: Use Case
description: Describe your specific use case. What are you building? What's your goal?
placeholder: |
I'm building an AI agent that needs to...
My application handles...
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem Statement
description: What problem are you facing? What's missing or difficult today?
placeholder: Currently I have to... which causes...
validations:
required: true
- type: textarea
id: benefit
attributes:
label: How This Feature Would Help
description: Explain how this feature would improve your workflow or solve your problem
placeholder: With this feature, I would be able to...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: Describe your ideal solution (optional - we may have ideas too!)
placeholder: It would be great if Hindsight could...
validations:
required: false
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Have you considered any alternative solutions or workarounds?
validations:
required: false
- type: dropdown
id: priority
attributes:
label: Priority
description: How important is this feature to you?
options:
- Nice to have
- Important - affects my workflow
- Critical - blocking my use case
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional Context
description: Any other context, mockups, or examples?
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: Checklist
options:
- label: I would be willing to contribute this feature
required: false
+2
View File
@@ -27,7 +27,9 @@ jobs:
node-version: 20
cache: npm
cache-dependency-path: package-lock.json
- uses: astral-sh/setup-uv@v4
- run: npm ci --workspace=hindsight-docs
- run: uv run generate-llms-full
- run: npm run build --workspace=hindsight-docs
- uses: actions/upload-pages-artifact@v3
with:
+117 -5
View File
@@ -42,6 +42,10 @@ jobs:
working-directory: ./hindsight-integrations/litellm
run: uv build --out-dir dist
- name: Build hindsight-embed
working-directory: ./hindsight-embed
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
@@ -67,6 +71,12 @@ jobs:
packages-dir: ./hindsight-integrations/litellm/dist
skip-existing: true
- name: Publish hindsight-embed to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-embed/dist
skip-existing: true
# Upload artifacts for GitHub release
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -77,6 +87,7 @@ jobs:
hindsight-api/dist/*
hindsight/dist/*
hindsight-integrations/litellm/dist/*
hindsight-embed/dist/*
retention-days: 1
release-typescript-client:
@@ -102,7 +113,18 @@ jobs:
- name: Publish to npm
working-directory: ./hindsight-clients/typescript
run: npm publish --access public
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -117,6 +139,65 @@ jobs:
path: hindsight-clients/typescript/*.tgz
retention-days: 1
release-control-plane:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Build TypeScript client (dependency)
run: npm run build --workspace=hindsight-clients/typescript
- name: Fix platform-specific native modules
run: |
# npm ci installs from lockfile which may have wrong platform binaries
# Delete hoisted native modules and reinstall for current platform
rm -rf node_modules/lightningcss node_modules/@tailwindcss
npm install lightningcss @tailwindcss/postcss @tailwindcss/node
- name: Build
run: npm run build --workspace=hindsight-control-plane
- name: Publish to npm
working-directory: ./hindsight-control-plane
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-control-plane
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: control-plane
path: hindsight-control-plane/*.tgz
retention-days: 1
release-rust-cli:
runs-on: ${{ matrix.os }}
strategy:
@@ -181,7 +262,7 @@ jobs:
- name: Free Disk Space
uses: jlumbroso/free-disk-space@main
with:
tool-cache: false
tool-cache: true
android: true
dotnet: true
haskell: true
@@ -206,7 +287,7 @@ jobs:
id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Extract metadata
- name: Extract metadata for release tags
id: meta
uses: docker/metadata-action@v5
with:
@@ -217,7 +298,29 @@ jobs:
type=semver,pattern={{major}},value=${{ steps.get_version.outputs.VERSION }}
type=raw,value=latest
- name: Build and push
# TODO: Re-enable smoke test when disk space issue is resolved
# # Step 1: Build for local testing (single platform, no push)
# # This creates an identical image to what will be released, just for one platform
# - name: Build image for testing
# uses: docker/build-push-action@v6
# with:
# context: .
# file: docker/standalone/Dockerfile
# target: ${{ matrix.target }}
# push: false
# load: true
# tags: ${{ matrix.image_name }}:test
# cache-from: type=gha
# cache-to: type=gha,mode=max
# # Step 2: Test the image before pushing anything
# - name: Smoke test - verify container starts
# env:
# GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
# run: ./scripts/docker-smoke-test.sh "${{ matrix.image_name }}:test" "${{ matrix.target }}"
# Build multi-platform and push to release tags
- name: Build and push release images
uses: docker/build-push-action@v6
with:
context: .
@@ -263,7 +366,7 @@ jobs:
create-github-release:
runs-on: ubuntu-latest
needs: [release-python-packages, release-typescript-client, release-rust-cli, release-docker-images, release-helm-chart]
needs: [release-python-packages, release-typescript-client, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
permissions:
contents: write
@@ -286,6 +389,12 @@ jobs:
name: typescript-client
path: ./artifacts/typescript-client
- name: Download Control Plane
uses: actions/download-artifact@v4
with:
name: control-plane
path: ./artifacts/control-plane
- name: Download Rust CLI (Linux)
uses: actions/download-artifact@v4
with:
@@ -318,8 +427,11 @@ 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-embed/dist/* release-assets/ || true
# TypeScript client
cp artifacts/typescript-client/*.tgz release-assets/ || true
# Control Plane
cp artifacts/control-plane/*.tgz release-assets/ || true
# Rust CLI binaries
cp artifacts/rust-cli-linux/hindsight-linux-amd64 release-assets/ || true
cp artifacts/rust-cli-darwin-amd64/hindsight-darwin-amd64 release-assets/ || true
+416 -93
View File
@@ -20,6 +20,8 @@ jobs:
path: hindsight-api
- name: hindsight-client
path: hindsight-clients/python
- name: hindsight-embed
path: hindsight-embed
steps:
- uses: actions/checkout@v4
@@ -38,6 +40,29 @@ jobs:
working-directory: ./${{ matrix.path }}
run: uv build
build-api-python-versions:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Build hindsight-api
working-directory: ./hindsight-api
run: uv build
build-typescript-client:
runs-on: ubuntu-latest
@@ -57,6 +82,58 @@ jobs:
- name: Build TypeScript client
run: npm run build --workspace=hindsight-clients/typescript
build-control-plane:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install SDK dependencies
run: npm ci --workspace=hindsight-clients/typescript
- name: Build SDK
run: npm run build --workspace=hindsight-clients/typescript
# Install control plane deps and fix hoisted lightningcss binary
# lightningcss gets hoisted to root node_modules, so we need to reinstall it there
- name: Install Control Plane dependencies
run: |
npm install --workspace=hindsight-control-plane
rm -rf node_modules/lightningcss node_modules/@tailwindcss
npm install lightningcss @tailwindcss/postcss @tailwindcss/node
- name: Build Control Plane
run: npm run build --workspace=hindsight-control-plane
- name: Verify standalone build
run: |
test -f hindsight-control-plane/standalone/server.js || exit 1
test -d hindsight-control-plane/standalone/node_modules || exit 1
node hindsight-control-plane/bin/cli.js --help
- name: Smoke test - verify server starts
run: |
cd hindsight-control-plane
node bin/cli.js --port 9999 &
SERVER_PID=$!
sleep 5
if curl -sf http://localhost:9999 > /dev/null 2>&1; then
echo "Server started successfully"
kill $SERVER_PID 2>/dev/null || true
exit 0
else
echo "Server failed to respond"
kill $SERVER_PID 2>/dev/null || true
exit 1
fi
build-docs:
runs-on: ubuntu-latest
@@ -98,6 +175,90 @@ jobs:
working-directory: hindsight-cli
run: cargo build --release
- name: Upload CLI artifact
uses: actions/upload-artifact@v4
with:
name: hindsight-cli
path: hindsight-cli/target/release/hindsight
retention-days: 1
test-rust-cli:
runs-on: ubuntu-latest
needs: build-rust-cli
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
- name: Download CLI artifact
uses: actions/download-artifact@v4
with:
name: hindsight-cli
path: /tmp/cli
- name: Make CLI executable
run: chmod +x /tmp/cli/hindsight
- 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 API
working-directory: ./hindsight-api
run: uv build
- name: Install API dependencies
working-directory: ./hindsight-api
run: uv sync --no-install-project --index-strategy unsafe-best-match
- name: Create .env file
run: |
cat > .env << EOF
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }}
EOF
- name: Start API server
run: |
./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 &
echo "Waiting for API server to be ready..."
for i in {1..60}; do
if curl -sf http://localhost:8888/health > /dev/null 2>&1; then
echo "API server is ready after ${i}s"
break
fi
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
sleep 1
done
- name: Run CLI smoke test
run: |
HINDSIGHT_CLI=/tmp/cli/hindsight ./hindsight-cli/smoke-test.sh
- name: Show API server logs
if: always()
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
lint-helm-chart:
runs-on: ubuntu-latest
@@ -130,7 +291,7 @@ jobs:
- name: Free Disk Space
uses: jlumbroso/free-disk-space@main
with:
tool-cache: false
tool-cache: true
android: true
dotnet: true
haskell: true
@@ -148,6 +309,13 @@ jobs:
file: docker/standalone/Dockerfile
target: ${{ matrix.target }}
push: false
load: false
# TODO: Re-enable smoke test when disk space issue is resolved
# - name: Smoke test - verify container starts
# env:
# GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
# run: ./scripts/docker-smoke-test.sh "hindsight-${{ matrix.name }}:test" "${{ matrix.target }}"
test-api:
runs-on: ubuntu-latest
@@ -157,6 +325,7 @@ jobs:
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
@@ -445,6 +614,97 @@ jobs:
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-integration:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
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 API
working-directory: ./hindsight-api
run: uv build
- name: Install API dependencies
working-directory: ./hindsight-api
run: uv sync --no-install-project --index-strategy unsafe-best-match
- name: Install integration test dependencies
working-directory: ./hindsight-integration-tests
run: uv sync
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-
- name: Pre-download models
working-directory: ./hindsight-api
run: |
uv run python -c "
from sentence_transformers import SentenceTransformer, CrossEncoder
print('Downloading embedding model...')
SentenceTransformer('BAAI/bge-small-en-v1.5')
print('Downloading cross-encoder model...')
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
print('Models downloaded successfully')
"
- name: Create .env file
run: |
cat > .env << EOF
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }}
EOF
- name: Start API server
run: |
./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 &
echo "Waiting for API server to be ready..."
for i in {1..60}; do
if curl -sf http://localhost:8888/health > /dev/null 2>&1; then
echo "API server is ready after ${i}s"
break
fi
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
sleep 1
done
- name: Run integration tests
working-directory: ./hindsight-integration-tests
run: uv run pytest tests/ -v
- name: Show API server logs
if: always()
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-litellm-integration:
runs-on: ubuntu-latest
@@ -474,23 +734,69 @@ jobs:
working-directory: ./hindsight-integrations/litellm
run: uv run pytest tests -v
test-embed:
runs-on: ubuntu-latest
env:
HINDSIGHT_EMBED_LLM_PROVIDER: groq
HINDSIGHT_EMBED_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_EMBED_LLM_MODEL: openai/gpt-oss-20b
# Prefer CPU-only PyTorch in CI
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
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: Install dependencies
working-directory: ./hindsight-embed
run: uv sync --index-strategy unsafe-best-match
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-embed-${{ hashFiles('hindsight-embed/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-embed-
${{ runner.os }}-huggingface-
- name: Run smoke test
working-directory: ./hindsight-embed
run: ./test.sh
test-doc-examples:
runs-on: ubuntu-latest
needs: build-rust-cli
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Model for test generation and analysis (options: gpt-4o, o3-mini, o1, etc.)
DOC_TEST_MODEL: o3-mini
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
- name: Download CLI artifact
uses: actions/download-artifact@v4
with:
name: hindsight-cli
path: /usr/local/bin
- name: Make CLI executable
run: chmod +x /usr/local/bin/hindsight
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
@@ -509,87 +815,20 @@ jobs:
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Build API
- name: Build and install API
working-directory: ./hindsight-api
run: uv build
run: |
uv build
uv sync --no-install-project --index-strategy unsafe-best-match
- name: Build Python client
- name: Install Python client dependencies
working-directory: ./hindsight-clients/python
run: uv build
run: uv sync --extra test --index-strategy unsafe-best-match
- name: Install Python client
working-directory: ./hindsight-clients/python
run: uv sync --index-strategy unsafe-best-match
- name: Install API dependencies
working-directory: ./hindsight-api
run: uv sync --no-install-project --index-strategy unsafe-best-match
- name: Install test dependencies in API venv
working-directory: ./hindsight-api
- name: Install TypeScript client
run: |
uv pip install ../hindsight-clients/python requests anthropic
uv pip install ../hindsight-integrations/litellm
uv pip install ../hindsight-integrations/openai
- name: Verify Python dependencies
working-directory: ./hindsight-api
run: |
echo "=== Verifying Python dependencies ==="
uv run python -c "
import sys
print(f'Python: {sys.executable}')
print(f'Prefix: {sys.prefix}')
# Check required packages
packages = [
'hindsight_client',
'hindsight_litellm',
'hindsight_openai',
'anthropic',
'openai',
]
missing = []
for pkg in packages:
try:
__import__(pkg)
print(f' ✓ {pkg}')
except ImportError as e:
print(f' ✗ {pkg}: {e}')
missing.append(pkg)
if missing:
print(f'\nERROR: Missing packages: {missing}')
sys.exit(1)
print('\nAll Python dependencies verified!')
"
- name: Install TypeScript client dependencies
run: npm ci
- name: Build TypeScript client
run: npm run build --workspace=hindsight-clients/typescript
- name: Install TypeScript client globally
working-directory: ./hindsight-clients/typescript
run: npm install -g .
- name: Make TypeScript client available for temp files
run: |
# ESM modules don't use NODE_PATH, so create node_modules in /tmp
# where test scripts are written
mkdir -p /tmp/node_modules/@vectorize-io
ln -s ${{ github.workspace }}/hindsight-clients/typescript /tmp/node_modules/@vectorize-io/hindsight-client
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Build and install hindsight CLI
working-directory: ./hindsight-cli
run: |
cargo build --release
sudo cp target/release/hindsight /usr/local/bin/
npm ci --workspace=hindsight-clients/typescript
npm run build --workspace=hindsight-clients/typescript
- name: Create .env file
run: |
@@ -616,21 +855,105 @@ jobs:
sleep 1
done
- name: Test documentation examples
working-directory: ./hindsight-api
env:
REPO_ROOT: ${{ github.workspace }}
run: uv run python ../scripts/test-doc-examples.py
- name: Write test summary
if: always()
- name: Run Python doc examples
working-directory: ./hindsight-clients/python
run: |
echo "=== Documentation Test Summary ==="
cat /tmp/doc-test-summary.md
cat /tmp/doc-test-summary.md >> $GITHUB_STEP_SUMMARY
for f in ../../hindsight-docs/examples/api/*.py; do
echo "Running $f..."
uv run python "$f"
done
- name: Run Node.js doc examples
run: |
for f in hindsight-docs/examples/api/*.mjs; do
echo "Running $f..."
node "$f"
done
- name: Configure CLI
run: hindsight configure --api-url http://localhost:8888
- name: Run CLI doc examples
run: |
for f in hindsight-docs/examples/api/*.sh; do
echo "Running $f..."
bash "$f"
done
- name: Show API server logs
if: always()
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
cat /tmp/api-server.log || echo "No API server log found"
verify-generated-files:
runs-on: ubuntu-latest
env:
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-cargo-gen-${{ hashFiles('**/Cargo.lock') }}
- name: Install Node dependencies
run: npm ci
- name: Install Python dependencies
run: |
cd hindsight-dev && uv sync --index-strategy unsafe-best-match
cd ../hindsight-api && uv sync --index-strategy unsafe-best-match
cd ../hindsight-embed && uv sync --index-strategy unsafe-best-match
- name: Run generate-openapi
run: ./scripts/generate-openapi.sh
- name: Run generate-clients
run: ./scripts/generate-clients.sh
- name: Run lint
run: ./scripts/hooks/lint.sh
- name: Verify no uncommitted changes
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "❌ Error: Generated files are out of sync with committed files."
echo ""
echo "The following files have changed after running generation scripts:"
git status --porcelain
echo ""
echo "Please run the following commands locally and commit the changes:"
echo " ./scripts/generate-openapi.sh"
echo " ./scripts/generate-clients.sh"
echo " ./scripts/hooks/lint.sh"
echo ""
git diff --stat
exit 1
fi
echo "✓ All generated files are up to date"
+6 -1
View File
@@ -12,8 +12,10 @@ wheels/
# Node
node_modules/
# Environment variables
# Environment variables and local config
.env
docker-compose.yml
docker-compose.override.yml
# IDE
.idea/
@@ -32,6 +34,9 @@ logs/
.DS_Store
# Generated docs files
hindsight-docs/static/llms-full.txt
hindsight-dev/benchmarks/locomo/results/
hindsight-dev/benchmarks/longmemeval/results/
+3 -1
View File
@@ -14,6 +14,7 @@ This document captures architectural decisions and coding conventions for the Hi
hindsight/ # Python package for embedded usage
hindsight-api/ # FastAPI server (core memory engine)
hindsight-cli/ # Rust CLI client
hindsight-embed/ # Embedded CLI (no server needed)
hindsight-control-plane/ # Next.js admin UI
hindsight-docs/ # Docusaurus documentation site
hindsight-dev/ # Development tools and benchmarks
@@ -148,4 +149,5 @@ Note: The maintained wrapper `hindsight_client.py` and `README.md` are preserved
# Branding
## Colors
- Primary: gradient from #0074d9 to #009296
- Primary: gradient from #0074d9 to #009296
+155
View File
@@ -0,0 +1,155 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Hindsight is an agent memory system that provides long-term memory for AI agents using biomimetic data structures. It stores memories as World facts, Experiences, Opinions, and Observations across memory banks.
## Development Commands
### API Server (Python/FastAPI)
```bash
# Start API server (loads .env automatically)
./scripts/dev/start-api.sh
# Run tests
cd hindsight-api && uv run pytest tests/
# Run specific test file
cd hindsight-api && uv run pytest tests/test_http_api_integration.py -v
# Lint
cd hindsight-api && uv run ruff check .
```
### Control Plane (Next.js)
```bash
./scripts/dev/start-control-plane.sh
# Or manually:
cd hindsight-control-plane && npm run dev
```
### Documentation Site (Docusaurus)
```bash
./scripts/dev/start-docs.sh
```
### Generating Clients/OpenAPI
```bash
# Regenerate OpenAPI spec after API changes
./scripts/generate-openapi.sh
# Regenerate all client SDKs (Python, TypeScript, Rust)
./scripts/generate-clients.sh
```
### Benchmarks
```bash
./scripts/benchmarks/run-longmemeval.sh
./scripts/benchmarks/run-locomo.sh
./scripts/benchmarks/start-visualizer.sh # View results at localhost:8001
```
## Architecture
### Monorepo Structure
- **hindsight-api/**: Core FastAPI server with memory engine (Python, uv)
- **hindsight/**: Embedded Python bundle (hindsight-all package)
- **hindsight-control-plane/**: Admin UI (Next.js, npm)
- **hindsight-cli/**: CLI tool (Rust, cargo)
- **hindsight-clients/**: Generated SDK clients (Python, TypeScript, Rust)
- **hindsight-docs/**: Docusaurus documentation site
- **hindsight-integrations/**: Framework integrations (LiteLLM, OpenAI)
- **hindsight-dev/**: Development tools and benchmarks
### Core Engine (hindsight-api/hindsight_api/engine/)
- `memory_engine.py`: Main orchestrator for retain/recall/reflect operations
- `llm_wrapper.py`: LLM abstraction supporting OpenAI, Anthropic, Gemini, Groq, Ollama, LM Studio
- `embeddings.py`: Embedding generation (local or TEI)
- `cross_encoder.py`: Reranking (local or TEI)
- `entity_resolver.py`: Entity extraction and normalization
- `query_analyzer.py`: Query intent analysis
- `retain/`: Memory ingestion pipeline
- `search/`: Multi-strategy retrieval (semantic, BM25, graph, temporal)
### API Layer (hindsight-api/hindsight_api/api/)
FastAPI routers for all endpoints. Main operations:
- **Retain**: Store memories, extracts facts/entities/relationships
- **Recall**: Retrieve memories via parallel search strategies + reranking
- **Reflect**: Deep analysis forming new opinions/observations
### Database
PostgreSQL with pgvector. Schema managed via Alembic migrations in `hindsight-api/hindsight_api/alembic/`. Migrations run automatically on API startup.
Key tables: `banks`, `memory_units`, `documents`, `entities`, `entity_links`
## Key Conventions
### Code Quality
**Always run the lint script after making Python or TypeScript/Node changes:**
```bash
./scripts/hooks/lint.sh
```
This runs the same checks as the pre-commit hook (Ruff for Python, ESLint/Prettier for TypeScript).
### Memory Banks
- Each bank is isolated (no cross-bank data access)
- Banks have dispositions (skepticism, literalism, empathy traits 1-5) affecting reflect
- Banks can have background context
### API Design
- All endpoints operate on a single bank per request
- Multi-bank queries are client responsibility
- Disposition traits only affect reflect, not recall
### Python Style
- Python 3.11+, type hints required
- Async throughout (asyncpg, async FastAPI)
- Pydantic models for request/response
- Ruff for linting (line-length 120)
### TypeScript Style
- Next.js App Router for control plane
- Tailwind CSS with shadcn/ui components
### Adding New API Configuration Flags
When adding a new environment variable configuration:
1. **config.py** (`hindsight-api/hindsight_api/config.py`):
- Add `ENV_*` constant for the environment variable name
- Add `DEFAULT_*` constant for the default value
- Add field to `HindsightConfig` dataclass
- Add initialization in `from_env()` method
2. **main.py** (`hindsight-api/hindsight_api/main.py`):
- Add field to the manual `HindsightConfig()` constructor call (search for "CLI override")
3. **Use the config** in code:
```python
from ...config import get_config
config = get_config()
value = config.your_new_field
```
4. **Documentation** (`hindsight-docs/docs/developer/configuration.md`):
- Add to appropriate section table with Variable, Description, Default
## Environment Setup
```bash
cp .env.example .env
# Edit .env with LLM API key
# Python deps
uv sync --directory hindsight-api/
# Node deps (workspace)
npm install
```
Required env vars:
- `HINDSIGHT_API_LLM_PROVIDER`: openai, anthropic, gemini, groq, ollama, lmstudio
- `HINDSIGHT_API_LLM_API_KEY`: Your API key
- `HINDSIGHT_API_LLM_MODEL`: Model name (e.g., o3-mini, claude-sonnet-4-20250514)
+30 -1
View File
@@ -51,7 +51,36 @@ cd hindsight-api
uv run pytest tests/
```
### Code style
### Code Style
We use [Ruff](https://docs.astral.sh/ruff/) for Python linting and formatting, and ESLint/Prettier for TypeScript.
#### Setting up git hooks (recommended)
Set up git hooks to automatically lint and format code before each commit:
```bash
./scripts/setup-hooks.sh
```
This configures git to use the hooks in `.githooks/`, which run all scripts in `scripts/hooks/` on commit. The lint hook runs in parallel:
- **Python**: `ruff check --fix`, `ruff format`, `ty check`
- **TypeScript**: `eslint --fix`, `prettier`
#### Manual linting and formatting
```bash
# Run all lints (same as pre-commit)
./scripts/hooks/lint.sh
# Or run individually for Python:
cd hindsight-api
uv run ruff check --fix . # Lint and auto-fix
uv run ruff format . # Format code
uv run ty check hindsight_api # Type check
```
#### Style guidelines
- Use Python type hints
- Follow existing code patterns
+35 -9
View File
@@ -2,14 +2,13 @@
![Hindsight Banner](./hindsight-docs/static/img/banner.svg)
[Documentation](https://vectorize-io.github.io/hindsight) • [Paper](#coming-soon) • [Examples](https://github.com/vectorize-io/hindsight-cookbook)
[Documentation](https://hindsight.vectorize.io) • [Paper](https://arxiv.org/abs/2512.12818) • [Cookbook](https://hindsight.vectorize.io/cookbook) • [Hindsight Cloud](https://vectorize.io/hindsight/cloud)
[![CI](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml/badge.svg)](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![PyPI - hindsight-api](https://img.shields.io/pypi/v/hindsight-api?label=hindsight-api)](https://pypi.org/project/hindsight-api/)
[![PyPI - hindsight-client](https://img.shields.io/pypi/v/hindsight-client?label=hindsight-client)](https://pypi.org/project/hindsight-client/)
[![npm - @vectorize-io/hindsight-client](https://img.shields.io/npm/v/@vectorize-io/hindsight-client)](https://www.npmjs.com/package/@vectorize-io/hindsight-client)
[![Slack Community](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/hindsight-space/shared_invite/zt-3klo21kua-VUCC_zHP5rIcXFB1_5yw6A)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
![PyPI - Downloads](https://img.shields.io/pypi/dm/hindsight-api?label=PyPI)
![NPM Downloads](https://img.shields.io/npm/dm/%40vectorize-io%2Fhindsight-client?logoColor=orange&label=NPM&color=blue&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40vectorize-io%2Fhindsight-client)
</div>
@@ -18,7 +17,7 @@
## What is Hindsight?
Hindsight™ is an agent memory system built to create smarter agents that learn over time. It eliminates the shortcomings of alternative techniques such as RAG and knowledge graph.
Hindsight™ is an agent memory system built to create smarter agents that learn over time. It eliminates the shortcomings of alternative techniques such as RAG and knowledge graph and delivers state-of-the-art performance on long term memory tasks.
Hindsight addresses common challenges that have frustrated AI engineers building agents to automate tasks and assist users with conversational interfaces. Many of these challenges stem directly from a lack of memory.
@@ -26,27 +25,48 @@ Hindsight addresses common challenges that have frustrated AI engineers building
- **Hallucinations:** Long term memory can be seeded with external knowledge to ground agent behavior in reliable sources to augment training data.
- **Cognitive Overload:** As workflows get complex, retrievals, tool calls, user messages and agent responses can grow to fill the context window leading to context rot. Short term memory optimization allows agents to reduce tokens and focus context by removing irrelevant details.
## How Hindsight Works
## How is Hindsight Different From Other Memory Systems?
![Overview](./hindsight-docs/static/img/hindsight-overview.webp)
Hindsight organizes memory into four networks to mimic the way human memory works:
Most agent memory implementation rely on basic vector search or sometimes use a knowledge graph. Hindsight uses biomimetic data structures to organize agent memories in a way that is more like how human memory works:
- **World:** Facts about the world ("The stove gets hot")
- **Experiences:** Agent's own experiences ("I touched the stove and it really hurt")
- **Opinion:** Beliefs with confidence scores ("I shouldn't touch the stove again" - .99 confidence)
- **Observation:** Complex mental models derived by reflecting on facts and experiences ("Curling irons, ovens, and fire are also hot. I shouldn't touch those either.")
Memories in Hindsight are stored in banks (i.e. memory banks). When memories are added to Hindsight, they are pushed into either the world facts or experiences memory pathway. They are then represented as a combination of entities, relationships, and time series with sparse/dense vector representations to aid in later recall.
Hindsight provides three simple methods to interact with the system:
- **Retain:** Provide information to Hindsight that you want it to remember
- **Recall:** Retrieve memories from Hindsight
- **Reflect:** Reflect on memories and experiences to generate new observations and insights from existing memories.
Memories in Hindsight are stored in banks (e.g. memory banks). When memories are retained, they are transformed to construct a series of search indexes, time series data, and entity/relationship graphs.
### Agent Memory That Learns
A key goal of Hindsight is to build agent memory that enables agents to learn and improve over time. This is the role of the `reflect` operation which provides the agent to form broader opinions and observations over time.
For example, imagine a product support agent that is helping a user troubleshoot a problem. It uses a `search-documentation` tool it found on an MCP server. Later in the conversation, the agent discovers that the documentation returned from the tool wasn't for the product the user was asking about. The agent now has an experience in its memory bank. And just like humans, we want that agent to learn from its experience.
As the agent gains more experiences, `reflect` allows the agent to form observations about what worked, what didn't, and what to do differently the next time it encounters a similar task.
---
## Memory Performance & Accuracy
Hindsight has achieved state-of-the-art performance on the LongMemEval benchmark, widely used to assess memory system performance across a variety of conversational
AI scenarios. The current reported performance of Hindsight and other agent memory solutions as of December 2025 is shown here:
![Overview](./hindsight-docs/static/img/hindsight-bench.jpg)
The benchmark performance data for Hindsight and GPT-4o (full context) have been reproduced by research collaborators at the Virginia Tech [Sanghani Center for Artificial Intelligence and Data Analytics](https://sanghani.cs.vt.edu/) and The Washington Post. Other scores are self-reported by software vendors.
A thorough examination of the techniques implemented in Hindsight and detailed breakdowns of benchmark performance are [available on arXiv](https://arxiv.org/abs/2512.12818). This research is currently being prepared for conference submission and the wider peer review process.
The benchmark results from this research can be inspected in our [visual benchmark explorer](https://hindsight-benchmarks.vercel.app). As additional improvements are made to Hindsight, new benchmark data will be available for review using this same tool.
## Quick Start
### Docker (recommended)
@@ -61,6 +81,8 @@ docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
ghcr.io/vectorize-io/hindsight:latest
```
You can modify the LLM provider by setting `HINDSIGHT_API_LLM_PROVIDER`. Valid options are `openai`, `anthropic`, `gemini`, `groq`, `ollama`, and `lmstudio`. The documentation provides more details on [supported models](https://hindsight.vectorize.io/developer/models).
API: http://localhost:8888
UI: http://localhost:9999
@@ -223,6 +245,10 @@ client.reflect(bank_id="my-bank", query="What should I know about Alice?")
- [Slack](https://join.slack.com/t/hindsight-space/shared_invite/zt-3klo21kua-VUCC_zHP5rIcXFB1_5yw6A)
- [GitHub Issues](https://github.com/vectorize-io/hindsight/issues)
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=vectorize-io/hindsight&type=date&legend=top-left)](https://www.star-history.com/#vectorize-io/hindsight&type=date&legend=top-left)
---
## Contributing
+66 -61
View File
@@ -2,16 +2,19 @@
# Supports building API-only, Control Plane-only, or both
#
# Build args:
# INCLUDE_API=true/false - Include API (default: true)
# INCLUDE_CP=true/false - Include Control Plane (default: true)
# INCLUDE_API=true/false - Include API (default: true)
# INCLUDE_CP=true/false - Include Control Plane (default: true)
# PRELOAD_ML_MODELS=true/false - Pre-download ML models during build (default: true)
#
# Examples:
# docker build -t hindsight . # Both (standalone)
# docker build -t hindsight-api --build-arg INCLUDE_CP=false . # API only
# docker build -t hindsight-cp --build-arg INCLUDE_API=false . # Control Plane only
# docker build -t hindsight . # Both (standalone)
# docker build -t hindsight-api --build-arg INCLUDE_CP=false . # API only
# docker build -t hindsight-cp --build-arg INCLUDE_API=false . # Control Plane only
# docker build -t hindsight --build-arg PRELOAD_ML_MODELS=false . # Skip ML model preload
ARG INCLUDE_API=true
ARG INCLUDE_CP=true
ARG PRELOAD_ML_MODELS=true
# =============================================================================
# Stage: API Builder
@@ -60,8 +63,8 @@ WORKDIR /app
COPY package.json package-lock.json ./
COPY hindsight-clients/typescript/ ./hindsight-clients/typescript/
# Install and build SDK using workspace
RUN npm ci -w @vectorize-io/hindsight-client
# Install and build SDK using workspace (--ignore-scripts skips git hooks setup)
RUN npm ci --ignore-scripts -w @vectorize-io/hindsight-client
RUN npm run build -w @vectorize-io/hindsight-client
# =============================================================================
@@ -72,30 +75,48 @@ FROM node:20-slim AS cp-builder
ARG INCLUDE_CP
RUN if [ "$INCLUDE_CP" != "true" ]; then echo "Skipping CP build" && exit 0; fi
WORKDIR /app
# Copy built SDK
COPY --from=sdk-builder /app/hindsight-clients/typescript /app/sdk
# Create directory structure matching the monorepo layout
# This is required because build:standalone script expects .next/standalone/memory-poc/hindsight-control-plane
WORKDIR /app/memory-poc/hindsight-control-plane
# Install Control Plane dependencies
# Only copy package.json (not package-lock.json) to ensure npm installs
# correct platform-specific native bindings for lightningcss/tailwindcss
COPY hindsight-control-plane/package.json ./
# Remove the file: dependency on SDK (we'll copy it directly later)
RUN sed -i '/"@vectorize-io\/hindsight-client":/d' package.json
RUN npm install
# Copy Control Plane source (excluding node_modules via .dockerignore)
COPY hindsight-control-plane/ ./
# Remove package-lock.json to avoid conflicts with installed native bindings
RUN rm -f package-lock.json
# Also remove the file: dependency from package.json (restored by COPY above)
RUN rm -f package-lock.json && sed -i '/"@vectorize-io\/hindsight-client":/d' package.json
# Link SDK (temporary for build)
RUN cd /app/sdk && npm link && cd /app && npm link @vectorize-io/hindsight-client
# Copy built SDK directly into node_modules (more reliable than npm link in Docker)
COPY --from=sdk-builder /app/hindsight-clients/typescript ./node_modules/@vectorize-io/hindsight-client
# Build Control Plane
RUN npm run build
# Build Control Plane - run next build first, then custom standalone copy
# (The build:standalone script expects a specific path structure that differs in Docker)
RUN npm exec -- next build
# Create public directory if it doesn't exist
RUN mkdir -p public
# Create standalone directory structure manually
# Note: Must exclude node_modules from find to avoid wrong server.js from next/dist/experimental/testmode/
# Note: Must explicitly copy .next since glob * doesn't match hidden directories
RUN STANDALONE_ROOT=$(find .next/standalone -path '*/node_modules' -prune -o -name 'server.js' -print | head -1 | xargs dirname) && \
mkdir -p standalone && \
cp -r "$STANDALONE_ROOT"/* standalone/ && \
cp -r "$STANDALONE_ROOT"/.next standalone/.next && \
# Copy node_modules if separate from app dir (monorepo structure)
if [ -d ".next/standalone/node_modules" ] && [ "$STANDALONE_ROOT" != ".next/standalone" ]; then \
cp -r .next/standalone/node_modules standalone/node_modules; \
fi && \
cp -r .next/static standalone/.next/static && \
mkdir -p standalone/public && \
cp -r public/* standalone/public/ 2>/dev/null || true && \
# Verify required files exist
test -f standalone/server.js || (echo "ERROR: server.js missing!" && exit 1) && \
test -f standalone/.next/BUILD_ID || (echo "ERROR: BUILD_ID missing!" && exit 1)
# =============================================================================
# Stage: Final Image - API Only
@@ -104,18 +125,18 @@ FROM python:3.11-slim AS api-only
WORKDIR /app
# Install pg0 dependencies
# Note: libicu version varies by Debian version - try common versions in order
RUN apt-get update && apt-get install -y \
curl \
procps \
libxml2 \
libssl3 \
libgssapi-krb5-2 \
libossp-uuid16 \
&& apt-get install -y libicu72 || apt-get install -y libicu74 || apt-get install -y libicu* \
&& (apt-get install -y libicu72 2>/dev/null || apt-get install -y libicu74 2>/dev/null || apt-get install -y libicu76 2>/dev/null || true) \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir uv
# Create non-root user (PostgreSQL cannot run as root)
RUN useradd -m -s /bin/bash hindsight
# Copy API with virtual environment from builder
@@ -125,28 +146,23 @@ COPY --from=api-builder /app/api /app/api
COPY docker/standalone/start-all.sh /app/start-all.sh
RUN chmod +x /app/start-all.sh
# Create data directory for pg0 and set ownership
RUN mkdir -p /app/data && chown -R hindsight:hindsight /app
RUN chown -R hindsight:hindsight /app
# Switch to non-root user
USER hindsight
# Set PATH for hindsight user
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-cache PostgreSQL binaries by starting/stopping pg0-embedded
ENV PG0_HOME=/home/hindsight/.pg0-cache
ENV PG0_HOME=/home/hindsight/.pg0
# Pre-download ML models to avoid runtime download
RUN /app/api/.venv/bin/python -c "\
# Pre-download ML models to avoid runtime download (conditional)
ARG PRELOAD_ML_MODELS
RUN if [ "$PRELOAD_ML_MODELS" = "true" ]; then \
/app/api/.venv/bin/python -c "\
from sentence_transformers import SentenceTransformer, CrossEncoder; \
print('Downloading embedding model...'); \
SentenceTransformer('BAAI/bge-small-en-v1.5'); \
print('Downloading cross-encoder model...'); \
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); \
print('Models cached successfully')"
print('Models cached successfully')"; \
else echo "Skipping ML model preload"; fi
EXPOSE 8888
@@ -171,9 +187,9 @@ COPY --from=sdk-builder /app/hindsight-clients/typescript /app/sdk
# Copy Control Plane standalone build
WORKDIR /app/control-plane
COPY --from=cp-builder /app/.next/standalone ./
COPY --from=cp-builder /app/.next/static ./.next/static
COPY --from=cp-builder /app/public ./public
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/standalone ./
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/.next/static ./.next/static
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/public ./public
WORKDIR /app
@@ -200,20 +216,21 @@ FROM python:3.11-slim AS standalone
WORKDIR /app
# Install Node.js, curl, uv, and pg0 dependencies
# Install Node.js, curl, uv, and system dependencies
# Note: libicu version varies by Debian version - try common versions in order
RUN apt-get update && apt-get install -y \
curl \
procps \
libxml2 \
libssl3 \
libgssapi-krb5-2 \
libossp-uuid16 \
&& apt-get install -y libicu72 || apt-get install -y libicu74 || apt-get install -y libicu* \
&& (apt-get install -y libicu72 2>/dev/null || apt-get install -y libicu74 2>/dev/null || apt-get install -y libicu76 2>/dev/null || true) \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir uv
# Create non-root user (PostgreSQL cannot run as root)
RUN useradd -m -s /bin/bash hindsight
# Copy API with virtual environment from builder
@@ -224,9 +241,9 @@ COPY --from=sdk-builder /app/hindsight-clients/typescript /app/sdk
# Copy Control Plane standalone build
WORKDIR /app/control-plane
COPY --from=cp-builder /app/.next/standalone ./
COPY --from=cp-builder /app/.next/static ./.next/static
COPY --from=cp-builder /app/public ./public
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/standalone ./
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/.next/static ./.next/static
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/public ./public
WORKDIR /app
@@ -234,35 +251,23 @@ WORKDIR /app
COPY docker/standalone/start-all.sh /app/start-all.sh
RUN chmod +x /app/start-all.sh
# Create data directory for pg0 and set ownership
RUN mkdir -p /app/data && chown -R hindsight:hindsight /app
RUN chown -R hindsight:hindsight /app
# Switch to non-root user
USER hindsight
# Set PATH for hindsight user
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-cache PostgreSQL binaries by starting/stopping pg0-embedded
ENV PG0_HOME=/home/hindsight/.pg0-cache
RUN /app/api/.venv/bin/python -c "\
from pg0 import Pg0; \
print('Pre-caching PostgreSQL binaries...'); \
pg = Pg0(name='hindsight', port=5555, username='hindsight', password='hindsight', database='hindsight'); \
pg.start(); \
pg.stop(); \
print('PostgreSQL pre-cached to PG0_HOME')" || echo "Pre-download skipped"
ENV PG0_HOME=/home/hindsight/.pg0
# Pre-download ML models to avoid runtime download
RUN /app/api/.venv/bin/python -c "\
# Pre-download ML models to avoid runtime download (conditional)
ARG PRELOAD_ML_MODELS
RUN if [ "$PRELOAD_ML_MODELS" = "true" ]; then \
/app/api/.venv/bin/python -c "\
from sentence_transformers import SentenceTransformer, CrossEncoder; \
print('Downloading embedding model...'); \
SentenceTransformer('BAAI/bge-small-en-v1.5'); \
print('Downloading cross-encoder model...'); \
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); \
print('Models cached successfully')"
print('Models cached successfully')"; \
else echo "Skipping ML model preload"; fi
EXPOSE 8888 9999
+66 -11
View File
@@ -5,16 +5,70 @@ set -e
ENABLE_API="${HINDSIGHT_ENABLE_API:-true}"
ENABLE_CP="${HINDSIGHT_ENABLE_CP:-true}"
# Copy pre-cached PostgreSQL data if runtime directory is empty (first run with volume)
if [ "$ENABLE_API" = "true" ]; then
PG0_CACHE="/home/hindsight/.pg0-cache"
PG0_HOME="/home/hindsight/.pg0"
if [ -d "$PG0_CACHE" ] && [ "$(ls -A $PG0_CACHE 2>/dev/null)" ]; then
if [ ! "$(ls -A $PG0_HOME 2>/dev/null)" ]; then
echo "📦 Copying pre-cached PostgreSQL data..."
cp -r "$PG0_CACHE"/* "$PG0_HOME"/ 2>/dev/null || true
fi
# =============================================================================
# Dependency waiting (opt-in via HINDSIGHT_WAIT_FOR_DEPS=true)
#
# Problem: When running with LM Studio, the LLM may take time to load models.
# If Hindsight starts before LM Studio is ready, it fails on LLM verification.
# This wait loop ensures dependencies are ready before starting.
# =============================================================================
if [ "${HINDSIGHT_WAIT_FOR_DEPS:-false}" = "true" ]; then
LLM_BASE_URL="${HINDSIGHT_API_LLM_BASE_URL:-http://host.docker.internal:1234/v1}"
MAX_RETRIES="${HINDSIGHT_RETRY_MAX:-0}" # 0 = infinite
RETRY_INTERVAL="${HINDSIGHT_RETRY_INTERVAL:-10}"
# Check if external database is configured (skip check for embedded pg0)
SKIP_DB_CHECK=false
if [ -z "${HINDSIGHT_API_DATABASE_URL}" ]; then
SKIP_DB_CHECK=true
else
DB_CHECK_HOST=$(echo "$HINDSIGHT_API_DATABASE_URL" | sed -E 's|.*@([^:/]+):([0-9]+)/.*|\1 \2|')
fi
check_db() {
if $SKIP_DB_CHECK; then
return 0
fi
if command -v pg_isready &> /dev/null; then
pg_isready -h $(echo $DB_CHECK_HOST | cut -d' ' -f1) -p $(echo $DB_CHECK_HOST | cut -d' ' -f2) &>/dev/null
else
python3 -c "import socket; s=socket.socket(); s.settimeout(5); exit(0 if s.connect_ex(('$(echo $DB_CHECK_HOST | cut -d' ' -f1)', $(echo $DB_CHECK_HOST | cut -d' ' -f2))) == 0 else 1)" 2>/dev/null
fi
}
check_llm() {
curl -sf "${LLM_BASE_URL}/models" --connect-timeout 5 &>/dev/null
}
echo "⏳ Waiting for dependencies to be ready..."
attempt=1
while true; do
db_ok=false
llm_ok=false
if check_db; then
db_ok=true
fi
if check_llm; then
llm_ok=true
fi
if $db_ok && $llm_ok; then
echo "✅ Dependencies ready!"
break
fi
if [ "$MAX_RETRIES" -ne 0 ] && [ "$attempt" -ge "$MAX_RETRIES" ]; then
echo "❌ Max retries ($MAX_RETRIES) reached. Dependencies not available."
exit 1
fi
echo " Attempt $attempt: DB=$( $db_ok && echo 'ok' || echo 'waiting' ), LLM=$( $llm_ok && echo 'ok' || echo 'waiting' )"
sleep "$RETRY_INTERVAL"
((attempt++))
done
fi
# Track PIDs for wait
@@ -23,7 +77,8 @@ PIDS=()
# Start API if enabled
if [ "$ENABLE_API" = "true" ]; then
cd /app/api
hindsight-api 2>&1 | sed -u 's/^/[api] /' &
# Run API directly - Python's PYTHONUNBUFFERED=1 handles output buffering
hindsight-api &
API_PID=$!
PIDS+=($API_PID)
@@ -42,7 +97,7 @@ fi
if [ "$ENABLE_CP" = "true" ]; then
echo "🎛️ Starting Control Plane..."
cd /app/control-plane
PORT=9999 node server.js 2>&1 | grep -v -E "^[[:space:]]*(▲|✓|-|$)" | sed -u 's/^/[control-plane] /' &
PORT=9999 node server.js &
CP_PID=$!
PIDS+=($CP_PID)
else
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.1.5
appVersion: "0.1.5"
version: 0.2.1
appVersion: "0.2.1"
keywords:
- ai
- memory
+11
View File
@@ -110,3 +110,14 @@ API URL for control plane
{{- define "hindsight.apiUrl" -}}
{{- printf "http://%s-api:%d" (include "hindsight.fullname" .) (.Values.api.service.port | int) }}
{{- end }}
{{/*
Get the name of the secret to use
*/}}
{{- define "hindsight.secretName" -}}
{{- if .Values.existingSecret }}
{{- .Values.existingSecret }}
{{- else }}
{{- printf "%s-secret" (include "hindsight.fullname" .) }}
{{- end }}
{{- end }}
+15 -4
View File
@@ -15,7 +15,9 @@ spec:
template:
metadata:
annotations:
{{- if not .Values.existingSecret }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- end }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -37,27 +39,36 @@ spec:
- name: http
containerPort: {{ .Values.api.service.targetPort }}
protocol: TCP
{{- if .Values.existingSecret }}
envFrom:
- secretRef:
name: {{ .Values.existingSecret }}
{{- end }}
env:
- name: HINDSIGHT_API_DATABASE_URL
value: {{ include "hindsight.databaseUrl" . | quote }}
{{- /* POSTGRES_PASSWORD must be defined before DATABASE_URL for $(VAR) interpolation */}}
{{- if not .Values.postgresql.enabled }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "hindsight.fullname" . }}-secret
name: {{ include "hindsight.secretName" . }}
key: postgres-password
{{- end }}
- name: HINDSIGHT_API_DATABASE_URL
value: {{ include "hindsight.databaseUrl" . | quote }}
{{- range $key, $value := .Values.api.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- /* Only use api.secrets when not using existingSecret (for chart-managed secrets) */}}
{{- if not .Values.existingSecret }}
{{- range $key, $value := .Values.api.secrets }}
- name: {{ $key }}
valueFrom:
secretKeyRef:
name: {{ include "hindsight.fullname" $ }}-secret
name: {{ include "hindsight.secretName" $ }}
key: {{ $key }}
{{- end }}
{{- end }}
livenessProbe:
{{- toYaml .Values.api.livenessProbe | nindent 10 }}
readinessProbe:
@@ -15,7 +15,9 @@ spec:
template:
metadata:
annotations:
{{- if not .Values.existingSecret }}
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
{{- end }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -37,6 +39,11 @@ spec:
- name: http
containerPort: {{ .Values.controlPlane.service.targetPort }}
protocol: TCP
{{- if .Values.existingSecret }}
envFrom:
- secretRef:
name: {{ .Values.existingSecret }}
{{- end }}
env:
- name: HINDSIGHT_CP_DATAPLANE_API_URL
value: {{ include "hindsight.apiUrl" . | quote }}
@@ -44,13 +51,16 @@ spec:
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- /* Only use controlPlane.secrets when not using existingSecret (for chart-managed secrets) */}}
{{- if not .Values.existingSecret }}
{{- range $key, $value := .Values.controlPlane.secrets }}
- name: {{ $key }}
valueFrom:
secretKeyRef:
name: {{ include "hindsight.fullname" $ }}-secret
name: {{ include "hindsight.secretName" $ }}
key: {{ $key }}
{{- end }}
{{- end }}
livenessProbe:
{{- toYaml .Values.controlPlane.livenessProbe | nindent 10 }}
readinessProbe:
+3 -1
View File
@@ -1,7 +1,8 @@
{{- if not .Values.existingSecret }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "hindsight.fullname" . }}-secret
name: {{ include "hindsight.secretName" . }}
labels:
{{- include "hindsight.labels" . | nindent 4 }}
type: Opaque
@@ -15,3 +16,4 @@ data:
{{- if and (not .Values.postgresql.enabled) .Values.postgresql.external.password }}
postgres-password: {{ .Values.postgresql.external.password | b64enc | quote }}
{{- end }}
{{- end }}
+9
View File
@@ -3,6 +3,15 @@
# Chart version - use this to set a consistent image tag across all components
version: "0.1.1"
# Use an existing secret instead of creating one from values
# When set, all keys from this secret are injected as environment variables via envFrom
# Required keys:
# - postgres-password: PostgreSQL password (when postgresql.enabled=false)
# Optional keys (any key becomes an env var):
# - HINDSIGHT_API_LLM_API_KEY: API key for LLM provider
# - Any other env vars you want to inject
# existingSecret: "my-hindsight-secret"
# Global settings
replicaCount: 1
+137 -1
View File
@@ -1 +1,137 @@
# Memory
# Hindsight API
**Memory System for AI Agents** — Temporal + Semantic + Entity Memory Architecture using PostgreSQL with pgvector.
Hindsight gives AI agents persistent memory that works like human memory: it stores facts, tracks entities and relationships, handles temporal reasoning ("what happened last spring?"), and forms opinions based on configurable disposition traits.
## Installation
```bash
pip install hindsight-api
```
## Quick Start
### Run the Server
```bash
# Set your LLM provider
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_API_KEY=sk-xxxxxxxxxxxx
# Start the server (uses embedded PostgreSQL by default)
hindsight-api
```
The server starts at http://localhost:8888 with:
- REST API for memory operations
- MCP server at `/mcp` for tool-use integration
### Use the Python API
```python
from hindsight_api import MemoryEngine
# Create and initialize the memory engine
memory = MemoryEngine()
await memory.initialize()
# Create a memory bank for your agent
bank = await memory.create_memory_bank(
name="my-assistant",
background="A helpful coding assistant"
)
# Store a memory
await memory.retain(
memory_bank_id=bank.id,
content="The user prefers Python for data science projects"
)
# Recall memories
results = await memory.recall(
memory_bank_id=bank.id,
query="What programming language does the user prefer?"
)
# Reflect with reasoning
response = await memory.reflect(
memory_bank_id=bank.id,
query="Should I recommend Python or R for this ML project?"
)
```
## CLI Options
```bash
hindsight-api --help
# Common options
hindsight-api --port 9000 # Custom port (default: 8888)
hindsight-api --host 127.0.0.1 # Bind to localhost only
hindsight-api --workers 4 # Multiple worker processes
hindsight-api --log-level debug # Verbose logging
```
## Configuration
Configure via environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_DATABASE_URL` | PostgreSQL connection string | `pg0` (embedded) |
| `HINDSIGHT_API_LLM_PROVIDER` | `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio` | `openai` |
| `HINDSIGHT_API_LLM_API_KEY` | API key for LLM provider | - |
| `HINDSIGHT_API_LLM_MODEL` | Model name | `gpt-4o-mini` |
| `HINDSIGHT_API_HOST` | Server bind address | `0.0.0.0` |
| `HINDSIGHT_API_PORT` | Server port | `8888` |
### Example with External PostgreSQL
```bash
export HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@localhost:5432/hindsight
export HINDSIGHT_API_LLM_PROVIDER=groq
export HINDSIGHT_API_LLM_API_KEY=gsk_xxxxxxxxxxxx
hindsight-api
```
## Docker
```bash
docker run --rm -it -p 8888:8888 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
## MCP Server
For local MCP integration without running the full API server:
```bash
hindsight-local-mcp
```
This runs a stdio-based MCP server that can be used directly with MCP-compatible clients.
## Key Features
- **Multi-Strategy Retrieval (TEMPR)** — Semantic, keyword, graph, and temporal search combined with RRF fusion
- **Entity Graph** — Automatic entity extraction and relationship tracking
- **Temporal Reasoning** — Native support for time-based queries
- **Disposition Traits** — Configurable skepticism, literalism, and empathy influence opinion formation
- **Three Memory Types** — World facts, bank actions, and formed opinions with confidence scores
## Documentation
Full documentation: [https://hindsight.vectorize.io](https://hindsight.vectorize.io)
- [Installation Guide](https://hindsight.vectorize.io/developer/installation)
- [Configuration Reference](https://hindsight.vectorize.io/developer/configuration)
- [API Reference](https://hindsight.vectorize.io/api-reference)
- [Python SDK](https://hindsight.vectorize.io/sdks/python)
## License
Apache 2.0
+12 -9
View File
@@ -3,26 +3,29 @@ Memory System for AI Agents.
Temporal + Semantic Memory Architecture using PostgreSQL with pgvector.
"""
from .config import HindsightConfig, get_config
from .engine.cross_encoder import CrossEncoderModel, LocalSTCrossEncoder, RemoteTEICrossEncoder
from .engine.embeddings import Embeddings, LocalSTEmbeddings, RemoteTEIEmbeddings
from .engine.llm_wrapper import LLMConfig
from .engine.memory_engine import MemoryEngine
from .engine.search.trace import (
SearchTrace,
QueryInfo,
EntryPoint,
NodeVisit,
WeightComponents,
LinkInfo,
NodeVisit,
PruningDecision,
SearchSummary,
QueryInfo,
SearchPhaseMetrics,
SearchSummary,
SearchTrace,
WeightComponents,
)
from .engine.search.tracer import SearchTracer
from .engine.embeddings import Embeddings, LocalSTEmbeddings, RemoteTEIEmbeddings
from .engine.cross_encoder import CrossEncoderModel, LocalSTCrossEncoder, RemoteTEICrossEncoder
from .engine.llm_wrapper import LLMConfig
from .config import HindsightConfig, get_config
from .models import RequestContext
__all__ = [
"MemoryEngine",
"RequestContext",
"HindsightConfig",
"get_config",
"SearchTrace",
@@ -0,0 +1 @@
# Admin CLI for Hindsight
+252
View File
@@ -0,0 +1,252 @@
"""
Hindsight Admin CLI - backup and restore operations.
"""
import asyncio
import io
import json
import logging
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import asyncpg
import typer
from ..config import HindsightConfig
from ..pg0 import parse_pg0_url, resolve_database_url
def _fq_table(table: str, schema: str) -> str:
"""Get fully-qualified table name with schema prefix."""
return f"{schema}.{table}"
# Setup logging
logging.basicConfig(
level=logging.INFO,
format="%(message)s",
)
logger = logging.getLogger(__name__)
app = typer.Typer(name="hindsight-admin", help="Hindsight administrative commands")
# Tables to backup/restore in dependency order
# Import must happen in this order due to foreign key constraints
BACKUP_TABLES = [
"banks",
"documents",
"entities",
"chunks",
"memory_units",
"unit_entities",
"entity_cooccurrences",
"memory_links",
]
MANIFEST_VERSION = "1"
async def _backup(database_url: str, output_path: Path, schema: str = "public") -> dict[str, Any]:
"""Backup all tables to a zip file using binary COPY protocol."""
conn = await asyncpg.connect(database_url)
try:
tables: dict[str, Any] = {}
manifest: dict[str, Any] = {
"version": MANIFEST_VERSION,
"created_at": datetime.now(timezone.utc).isoformat(),
"schema": schema,
"tables": tables,
}
# Use a transaction with REPEATABLE READ isolation to get a consistent
# snapshot across all tables. This prevents race conditions where
# entity_cooccurrences could reference entities created after the
# entities table was backed up.
async with conn.transaction(isolation="repeatable_read"):
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
for i, table in enumerate(BACKUP_TABLES, 1):
typer.echo(f" [{i}/{len(BACKUP_TABLES)}] Backing up {table}...", nl=False)
buffer = io.BytesIO()
# Use binary COPY for exact type preservation
# asyncpg requires schema_name as separate parameter
await conn.copy_from_table(table, schema_name=schema, output=buffer, format="binary")
data = buffer.getvalue()
zf.writestr(f"{table}.bin", data)
# Get row count for manifest
qualified_table = _fq_table(table, schema)
row_count = await conn.fetchval(f"SELECT COUNT(*) FROM {qualified_table}")
tables[table] = {
"rows": row_count,
"size_bytes": len(data),
}
typer.echo(f" {row_count} rows")
zf.writestr("manifest.json", json.dumps(manifest, indent=2))
return manifest
finally:
await conn.close()
async def _restore(database_url: str, input_path: Path, schema: str = "public") -> dict[str, Any]:
"""Restore all tables from a zip file using binary COPY protocol."""
conn = await asyncpg.connect(database_url)
try:
with zipfile.ZipFile(input_path, "r") as zf:
# Read and validate manifest
manifest: dict[str, Any] = json.loads(zf.read("manifest.json"))
if manifest.get("version") != MANIFEST_VERSION:
raise ValueError(f"Unsupported backup version: {manifest.get('version')}")
# Use a transaction for atomic restore - either all tables are
# restored or none are, preventing partial/inconsistent state.
async with conn.transaction():
typer.echo(" Clearing existing data...")
# Truncate tables in reverse order (respects FK constraints)
for table in reversed(BACKUP_TABLES):
qualified_table = _fq_table(table, schema)
await conn.execute(f"TRUNCATE TABLE {qualified_table} CASCADE")
# Restore tables in forward order
for i, table in enumerate(BACKUP_TABLES, 1):
filename = f"{table}.bin"
if filename not in zf.namelist():
typer.echo(f" [{i}/{len(BACKUP_TABLES)}] {table}: skipped (not in backup)")
continue
expected_rows = manifest["tables"].get(table, {}).get("rows", "?")
typer.echo(f" [{i}/{len(BACKUP_TABLES)}] Restoring {table}... {expected_rows} rows")
data = zf.read(filename)
buffer = io.BytesIO(data)
# asyncpg requires schema_name as separate parameter
await conn.copy_to_table(table, schema_name=schema, source=buffer, format="binary")
# Refresh materialized view
typer.echo(" Refreshing materialized views...")
await conn.execute(f"REFRESH MATERIALIZED VIEW {_fq_table('memory_units_bm25', schema)}")
return manifest
finally:
await conn.close()
async def _run_backup(db_url: str, output: Path, schema: str = "public") -> dict[str, Any]:
"""Resolve database URL and run backup."""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
return await _backup(resolved_url, output, schema)
async def _run_restore(db_url: str, input_file: Path, schema: str = "public") -> dict[str, Any]:
"""Resolve database URL and run restore."""
is_pg0, instance_name, _ = parse_pg0_url(db_url)
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
return await _restore(resolved_url, input_file, schema)
@app.command()
def backup(
output: Path = typer.Argument(..., help="Output file path (.zip)"),
schema: str = typer.Option("public", "--schema", "-s", help="Database schema to backup"),
):
"""Backup the Hindsight database to a zip file."""
config = HindsightConfig.from_env()
if not config.database_url:
typer.echo("Error: Database URL not configured.", err=True)
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
raise typer.Exit(1)
if output.suffix != ".zip":
output = output.with_suffix(".zip")
typer.echo(f"Backing up database (schema: {schema}) to {output}...")
manifest = asyncio.run(_run_backup(config.database_url, output, schema))
total_rows = sum(t["rows"] for t in manifest["tables"].values())
typer.echo(f"Backed up {total_rows} rows across {len(BACKUP_TABLES)} tables")
typer.echo(f"Backup saved to {output}")
@app.command()
def restore(
input_file: Path = typer.Argument(..., help="Input backup file (.zip)"),
schema: str = typer.Option("public", "--schema", "-s", help="Database schema to restore to"),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
):
"""Restore the database from a backup file. WARNING: This deletes all existing data."""
config = HindsightConfig.from_env()
if not config.database_url:
typer.echo("Error: Database URL not configured.", err=True)
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
raise typer.Exit(1)
if not input_file.exists():
typer.echo(f"Error: File not found: {input_file}", err=True)
raise typer.Exit(1)
if not yes:
typer.confirm(
"This will DELETE all existing data and replace it with the backup. Continue?",
abort=True,
)
typer.echo(f"Restoring database (schema: {schema}) from {input_file}...")
manifest = asyncio.run(_run_restore(config.database_url, input_file, schema))
total_rows = sum(t["rows"] for t in manifest["tables"].values())
typer.echo(f"Restored {total_rows} rows across {len(BACKUP_TABLES)} tables")
typer.echo("Restore complete")
async def _run_migration(db_url: str, schema: str = "public") -> None:
"""Resolve database URL and run migrations."""
from ..migrations import run_migrations
is_pg0, instance_name, _ = parse_pg0_url(db_url)
if is_pg0:
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
resolved_url = await resolve_database_url(db_url)
run_migrations(resolved_url, schema=schema)
@app.command(name="run-db-migration")
def run_db_migration(
schema: str = typer.Option("public", "--schema", "-s", help="Database schema to run migrations on"),
):
"""Run database migrations to the latest version."""
config = HindsightConfig.from_env()
if not config.database_url:
typer.echo("Error: Database URL not configured.", err=True)
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
raise typer.Exit(1)
typer.echo(f"Running database migrations (schema: {schema})...")
asyncio.run(_run_migration(config.database_url, schema))
typer.echo("Database migrations completed successfully")
def main():
app()
if __name__ == "__main__":
main()
+28 -8
View File
@@ -2,20 +2,19 @@
Alembic environment configuration for SQLAlchemy with pgvector.
Uses synchronous psycopg2 driver for migrations to avoid pgbouncer issues.
"""
import logging
import os
import sys
from pathlib import Path
from sqlalchemy import pool, engine_from_config
from sqlalchemy.engine import Connection
from alembic import context
from dotenv import load_dotenv
from sqlalchemy import engine_from_config, pool
# Import your models here
from hindsight_api.models import Base
# Load environment variables based on HINDSIGHT_API_DATABASE_URL env var or default to local
def load_env():
"""Load environment variables from .env"""
@@ -30,6 +29,7 @@ def load_env():
if env_file.exists():
load_dotenv(env_file)
load_env()
# this is the Alembic Config object, which provides
@@ -109,6 +109,9 @@ def run_migrations_online() -> None:
get_database_url() # Process and set the database URL in config
# Check if we're targeting a specific schema (for multi-tenant isolation)
target_schema = config.get_main_option("target_schema")
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
@@ -121,17 +124,34 @@ def run_migrations_online() -> None:
def set_read_write_mode(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE")
# If targeting a specific schema, set search_path
# Include public in search_path for access to shared extensions (pgvector)
if target_schema:
cursor.execute(f'CREATE SCHEMA IF NOT EXISTS "{target_schema}"')
cursor.execute(f'SET search_path TO "{target_schema}", public')
cursor.close()
with connectable.connect() as connection:
# Also explicitly set read-write mode on this connection
connection.execute(text("SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE"))
# If targeting a specific schema, set search_path
# Include public in search_path for access to shared extensions (pgvector)
if target_schema:
connection.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{target_schema}"'))
connection.execute(text(f'SET search_path TO "{target_schema}", public'))
connection.commit() # Commit the SET command
context.configure(
connection=connection,
target_metadata=target_metadata
)
# Configure context with version_table_schema if using a specific schema
context_opts = {
"connection": connection,
"target_metadata": target_metadata,
}
if target_schema:
context_opts["version_table_schema"] = target_schema
context.configure(**context_opts)
with context.begin_transaction():
context.run_migrations()
@@ -5,120 +5,150 @@ Revises:
Create Date: 2025-11-27 11:54:19.228030
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
from pgvector.sqlalchemy import Vector
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '5a366d414dce'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "5a366d414dce"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema - create all tables from scratch."""
# Enable required extensions
op.execute('CREATE EXTENSION IF NOT EXISTS vector')
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
# Create banks table
op.create_table(
'banks',
sa.Column('bank_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=True),
sa.Column('personality', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column('background', sa.Text(), nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('bank_id', name=op.f('pk_banks'))
"banks",
sa.Column("bank_id", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=True),
sa.Column(
"personality",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column("background", sa.Text(), nullable=True),
sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("bank_id", name=op.f("pk_banks")),
)
# Create documents table
op.create_table(
'documents',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('bank_id', sa.Text(), nullable=False),
sa.Column('original_text', sa.Text(), nullable=True),
sa.Column('content_hash', sa.Text(), nullable=True),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id', 'bank_id', name=op.f('pk_documents'))
"documents",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("bank_id", sa.Text(), nullable=False),
sa.Column("original_text", sa.Text(), nullable=True),
sa.Column("content_hash", sa.Text(), nullable=True),
sa.Column(
"metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False
),
sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id", "bank_id", name=op.f("pk_documents")),
)
op.create_index('idx_documents_bank_id', 'documents', ['bank_id'])
op.create_index('idx_documents_content_hash', 'documents', ['content_hash'])
op.create_index("idx_documents_bank_id", "documents", ["bank_id"])
op.create_index("idx_documents_content_hash", "documents", ["content_hash"])
# Create async_operations table
op.create_table(
'async_operations',
sa.Column('operation_id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False),
sa.Column('bank_id', sa.Text(), nullable=False),
sa.Column('operation_type', sa.Text(), nullable=False),
sa.Column('status', sa.Text(), server_default='pending', nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('completed_at', postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('result_metadata', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.PrimaryKeyConstraint('operation_id', name=op.f('pk_async_operations')),
sa.CheckConstraint("status IN ('pending', 'processing', 'completed', 'failed')", name='async_operations_status_check')
"async_operations",
sa.Column(
"operation_id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False
),
sa.Column("bank_id", sa.Text(), nullable=False),
sa.Column("operation_type", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), server_default="pending", nullable=False),
sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("completed_at", postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column(
"result_metadata",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.PrimaryKeyConstraint("operation_id", name=op.f("pk_async_operations")),
sa.CheckConstraint(
"status IN ('pending', 'processing', 'completed', 'failed')", name="async_operations_status_check"
),
)
op.create_index('idx_async_operations_bank_id', 'async_operations', ['bank_id'])
op.create_index('idx_async_operations_status', 'async_operations', ['status'])
op.create_index('idx_async_operations_bank_status', 'async_operations', ['bank_id', 'status'])
op.create_index("idx_async_operations_bank_id", "async_operations", ["bank_id"])
op.create_index("idx_async_operations_status", "async_operations", ["status"])
op.create_index("idx_async_operations_bank_status", "async_operations", ["bank_id", "status"])
# Create entities table
op.create_table(
'entities',
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False),
sa.Column('canonical_name', sa.Text(), nullable=False),
sa.Column('bank_id', sa.Text(), nullable=False),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column('first_seen', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('last_seen', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('mention_count', sa.Integer(), server_default='1', nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_entities'))
"entities",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("canonical_name", sa.Text(), nullable=False),
sa.Column("bank_id", sa.Text(), nullable=False),
sa.Column(
"metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False
),
sa.Column("first_seen", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("last_seen", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("mention_count", sa.Integer(), server_default="1", nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_entities")),
)
op.create_index('idx_entities_bank_id', 'entities', ['bank_id'])
op.create_index('idx_entities_canonical_name', 'entities', ['canonical_name'])
op.create_index('idx_entities_bank_name', 'entities', ['bank_id', 'canonical_name'])
op.create_index("idx_entities_bank_id", "entities", ["bank_id"])
op.create_index("idx_entities_canonical_name", "entities", ["canonical_name"])
op.create_index("idx_entities_bank_name", "entities", ["bank_id", "canonical_name"])
# Create unique index on (bank_id, LOWER(canonical_name)) for entity resolution
op.execute('CREATE UNIQUE INDEX idx_entities_bank_lower_name ON entities (bank_id, LOWER(canonical_name))')
op.execute("CREATE UNIQUE INDEX idx_entities_bank_lower_name ON entities (bank_id, LOWER(canonical_name))")
# Create memory_units table
op.create_table(
'memory_units',
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False),
sa.Column('bank_id', sa.Text(), nullable=False),
sa.Column('document_id', sa.Text(), nullable=True),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('embedding', Vector(384), nullable=True),
sa.Column('context', sa.Text(), nullable=True),
sa.Column('event_date', postgresql.TIMESTAMP(timezone=True), nullable=False),
sa.Column('occurred_start', postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column('occurred_end', postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column('mentioned_at', postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column('fact_type', sa.Text(), server_default='world', nullable=False),
sa.Column('confidence_score', sa.Float(), nullable=True),
sa.Column('access_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['document_id', 'bank_id'], ['documents.id', 'documents.bank_id'], name='memory_units_document_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_memory_units')),
sa.CheckConstraint("fact_type IN ('world', 'bank', 'opinion', 'observation')", name='memory_units_fact_type_check'),
sa.CheckConstraint("confidence_score IS NULL OR (confidence_score >= 0.0 AND confidence_score <= 1.0)", name='memory_units_confidence_range_check'),
"memory_units",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("bank_id", sa.Text(), nullable=False),
sa.Column("document_id", sa.Text(), nullable=True),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("embedding", Vector(384), nullable=True),
sa.Column("context", sa.Text(), nullable=True),
sa.Column("event_date", postgresql.TIMESTAMP(timezone=True), nullable=False),
sa.Column("occurred_start", postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column("occurred_end", postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column("mentioned_at", postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column("fact_type", sa.Text(), server_default="world", nullable=False),
sa.Column("confidence_score", sa.Float(), nullable=True),
sa.Column("access_count", sa.Integer(), server_default="0", nullable=False),
sa.Column(
"metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False
),
sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["document_id", "bank_id"],
["documents.id", "documents.bank_id"],
name="memory_units_document_fkey",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_memory_units")),
sa.CheckConstraint(
"fact_type IN ('world', 'bank', 'opinion', 'observation')", name="memory_units_fact_type_check"
),
sa.CheckConstraint(
"confidence_score IS NULL OR (confidence_score >= 0.0 AND confidence_score <= 1.0)",
name="memory_units_confidence_range_check",
),
sa.CheckConstraint(
"(fact_type = 'opinion' AND confidence_score IS NOT NULL) OR "
"(fact_type = 'observation') OR "
"(fact_type NOT IN ('opinion', 'observation') AND confidence_score IS NULL)",
name='confidence_score_fact_type_check'
)
name="confidence_score_fact_type_check",
),
)
# Add search_vector column for full-text search
@@ -128,18 +158,41 @@ def upgrade() -> None:
GENERATED ALWAYS AS (to_tsvector('english', COALESCE(text, '') || ' ' || COALESCE(context, ''))) STORED
""")
op.create_index('idx_memory_units_bank_id', 'memory_units', ['bank_id'])
op.create_index('idx_memory_units_document_id', 'memory_units', ['document_id'])
op.create_index('idx_memory_units_event_date', 'memory_units', [sa.text('event_date DESC')])
op.create_index('idx_memory_units_bank_date', 'memory_units', ['bank_id', sa.text('event_date DESC')])
op.create_index('idx_memory_units_access_count', 'memory_units', [sa.text('access_count DESC')])
op.create_index('idx_memory_units_fact_type', 'memory_units', ['fact_type'])
op.create_index('idx_memory_units_bank_fact_type', 'memory_units', ['bank_id', 'fact_type'])
op.create_index('idx_memory_units_bank_type_date', 'memory_units', ['bank_id', 'fact_type', sa.text('event_date DESC')])
op.create_index('idx_memory_units_opinion_confidence', 'memory_units', ['bank_id', sa.text('confidence_score DESC')], postgresql_where=sa.text("fact_type = 'opinion'"))
op.create_index('idx_memory_units_opinion_date', 'memory_units', ['bank_id', sa.text('event_date DESC')], postgresql_where=sa.text("fact_type = 'opinion'"))
op.create_index('idx_memory_units_observation_date', 'memory_units', ['bank_id', sa.text('event_date DESC')], postgresql_where=sa.text("fact_type = 'observation'"))
op.create_index('idx_memory_units_embedding', 'memory_units', ['embedding'], postgresql_using='hnsw', postgresql_ops={'embedding': 'vector_cosine_ops'})
op.create_index("idx_memory_units_bank_id", "memory_units", ["bank_id"])
op.create_index("idx_memory_units_document_id", "memory_units", ["document_id"])
op.create_index("idx_memory_units_event_date", "memory_units", [sa.text("event_date DESC")])
op.create_index("idx_memory_units_bank_date", "memory_units", ["bank_id", sa.text("event_date DESC")])
op.create_index("idx_memory_units_access_count", "memory_units", [sa.text("access_count DESC")])
op.create_index("idx_memory_units_fact_type", "memory_units", ["fact_type"])
op.create_index("idx_memory_units_bank_fact_type", "memory_units", ["bank_id", "fact_type"])
op.create_index(
"idx_memory_units_bank_type_date", "memory_units", ["bank_id", "fact_type", sa.text("event_date DESC")]
)
op.create_index(
"idx_memory_units_opinion_confidence",
"memory_units",
["bank_id", sa.text("confidence_score DESC")],
postgresql_where=sa.text("fact_type = 'opinion'"),
)
op.create_index(
"idx_memory_units_opinion_date",
"memory_units",
["bank_id", sa.text("event_date DESC")],
postgresql_where=sa.text("fact_type = 'opinion'"),
)
op.create_index(
"idx_memory_units_observation_date",
"memory_units",
["bank_id", sa.text("event_date DESC")],
postgresql_where=sa.text("fact_type = 'observation'"),
)
op.create_index(
"idx_memory_units_embedding",
"memory_units",
["embedding"],
postgresql_using="hnsw",
postgresql_ops={"embedding": "vector_cosine_ops"},
)
# Create BM25 full-text search index on search_vector
op.execute("""
@@ -158,116 +211,149 @@ def upgrade() -> None:
FROM memory_units
""")
op.create_index('idx_memory_units_bm25_bank', 'memory_units_bm25', ['bank_id'])
op.create_index('idx_memory_units_bm25_text_vector', 'memory_units_bm25', ['text_vector'], postgresql_using='gin')
op.create_index("idx_memory_units_bm25_bank", "memory_units_bm25", ["bank_id"])
op.create_index("idx_memory_units_bm25_text_vector", "memory_units_bm25", ["text_vector"], postgresql_using="gin")
# Create entity_cooccurrences table
op.create_table(
'entity_cooccurrences',
sa.Column('entity_id_1', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('entity_id_2', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('cooccurrence_count', sa.Integer(), server_default='1', nullable=False),
sa.Column('last_cooccurred', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['entity_id_1'], ['entities.id'], name=op.f('fk_entity_cooccurrences_entity_id_1_entities'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['entity_id_2'], ['entities.id'], name=op.f('fk_entity_cooccurrences_entity_id_2_entities'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('entity_id_1', 'entity_id_2', name=op.f('pk_entity_cooccurrences')),
sa.CheckConstraint('entity_id_1 < entity_id_2', name='entity_cooccurrence_order_check')
"entity_cooccurrences",
sa.Column("entity_id_1", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("entity_id_2", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("cooccurrence_count", sa.Integer(), server_default="1", nullable=False),
sa.Column(
"last_cooccurred", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.ForeignKeyConstraint(
["entity_id_1"],
["entities.id"],
name=op.f("fk_entity_cooccurrences_entity_id_1_entities"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["entity_id_2"],
["entities.id"],
name=op.f("fk_entity_cooccurrences_entity_id_2_entities"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("entity_id_1", "entity_id_2", name=op.f("pk_entity_cooccurrences")),
sa.CheckConstraint("entity_id_1 < entity_id_2", name="entity_cooccurrence_order_check"),
)
op.create_index('idx_entity_cooccurrences_entity1', 'entity_cooccurrences', ['entity_id_1'])
op.create_index('idx_entity_cooccurrences_entity2', 'entity_cooccurrences', ['entity_id_2'])
op.create_index('idx_entity_cooccurrences_count', 'entity_cooccurrences', [sa.text('cooccurrence_count DESC')])
op.create_index("idx_entity_cooccurrences_entity1", "entity_cooccurrences", ["entity_id_1"])
op.create_index("idx_entity_cooccurrences_entity2", "entity_cooccurrences", ["entity_id_2"])
op.create_index("idx_entity_cooccurrences_count", "entity_cooccurrences", [sa.text("cooccurrence_count DESC")])
# Create memory_links table
op.create_table(
'memory_links',
sa.Column('from_unit_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('to_unit_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('link_type', sa.Text(), nullable=False),
sa.Column('entity_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('weight', sa.Float(), server_default='1.0', nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], name=op.f('fk_memory_links_entity_id_entities'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['from_unit_id'], ['memory_units.id'], name=op.f('fk_memory_links_from_unit_id_memory_units'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['to_unit_id'], ['memory_units.id'], name=op.f('fk_memory_links_to_unit_id_memory_units'), ondelete='CASCADE'),
sa.CheckConstraint("link_type IN ('temporal', 'semantic', 'entity', 'causes', 'caused_by', 'enables', 'prevents')", name='memory_links_link_type_check'),
sa.CheckConstraint('weight >= 0.0 AND weight <= 1.0', name='memory_links_weight_check')
"memory_links",
sa.Column("from_unit_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("to_unit_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("link_type", sa.Text(), nullable=False),
sa.Column("entity_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("weight", sa.Float(), server_default="1.0", nullable=False),
sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["entity_id"], ["entities.id"], name=op.f("fk_memory_links_entity_id_entities"), ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["from_unit_id"],
["memory_units.id"],
name=op.f("fk_memory_links_from_unit_id_memory_units"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["to_unit_id"],
["memory_units.id"],
name=op.f("fk_memory_links_to_unit_id_memory_units"),
ondelete="CASCADE",
),
sa.CheckConstraint(
"link_type IN ('temporal', 'semantic', 'entity', 'causes', 'caused_by', 'enables', 'prevents')",
name="memory_links_link_type_check",
),
sa.CheckConstraint("weight >= 0.0 AND weight <= 1.0", name="memory_links_weight_check"),
)
# Create unique constraint using COALESCE for nullable entity_id
op.execute("CREATE UNIQUE INDEX idx_memory_links_unique ON memory_links (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid))")
op.create_index('idx_memory_links_from_unit', 'memory_links', ['from_unit_id'])
op.create_index('idx_memory_links_to_unit', 'memory_links', ['to_unit_id'])
op.create_index('idx_memory_links_entity', 'memory_links', ['entity_id'])
op.create_index('idx_memory_links_link_type', 'memory_links', ['link_type'])
op.execute(
"CREATE UNIQUE INDEX idx_memory_links_unique ON memory_links (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid))"
)
op.create_index("idx_memory_links_from_unit", "memory_links", ["from_unit_id"])
op.create_index("idx_memory_links_to_unit", "memory_links", ["to_unit_id"])
op.create_index("idx_memory_links_entity", "memory_links", ["entity_id"])
op.create_index("idx_memory_links_link_type", "memory_links", ["link_type"])
# Create unit_entities table
op.create_table(
'unit_entities',
sa.Column('unit_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('entity_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], name=op.f('fk_unit_entities_entity_id_entities'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['unit_id'], ['memory_units.id'], name=op.f('fk_unit_entities_unit_id_memory_units'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('unit_id', 'entity_id', name=op.f('pk_unit_entities'))
"unit_entities",
sa.Column("unit_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("entity_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(
["entity_id"], ["entities.id"], name=op.f("fk_unit_entities_entity_id_entities"), ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["unit_id"], ["memory_units.id"], name=op.f("fk_unit_entities_unit_id_memory_units"), ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("unit_id", "entity_id", name=op.f("pk_unit_entities")),
)
op.create_index('idx_unit_entities_unit', 'unit_entities', ['unit_id'])
op.create_index('idx_unit_entities_entity', 'unit_entities', ['entity_id'])
op.create_index("idx_unit_entities_unit", "unit_entities", ["unit_id"])
op.create_index("idx_unit_entities_entity", "unit_entities", ["entity_id"])
def downgrade() -> None:
"""Downgrade schema - drop all tables."""
# Drop tables in reverse dependency order
op.drop_index('idx_unit_entities_entity', table_name='unit_entities')
op.drop_index('idx_unit_entities_unit', table_name='unit_entities')
op.drop_table('unit_entities')
op.drop_index("idx_unit_entities_entity", table_name="unit_entities")
op.drop_index("idx_unit_entities_unit", table_name="unit_entities")
op.drop_table("unit_entities")
op.drop_index('idx_memory_links_link_type', table_name='memory_links')
op.drop_index('idx_memory_links_entity', table_name='memory_links')
op.drop_index('idx_memory_links_to_unit', table_name='memory_links')
op.drop_index('idx_memory_links_from_unit', table_name='memory_links')
op.execute('DROP INDEX IF EXISTS idx_memory_links_unique')
op.drop_table('memory_links')
op.drop_index("idx_memory_links_link_type", table_name="memory_links")
op.drop_index("idx_memory_links_entity", table_name="memory_links")
op.drop_index("idx_memory_links_to_unit", table_name="memory_links")
op.drop_index("idx_memory_links_from_unit", table_name="memory_links")
op.execute("DROP INDEX IF EXISTS idx_memory_links_unique")
op.drop_table("memory_links")
op.drop_index('idx_entity_cooccurrences_count', table_name='entity_cooccurrences')
op.drop_index('idx_entity_cooccurrences_entity2', table_name='entity_cooccurrences')
op.drop_index('idx_entity_cooccurrences_entity1', table_name='entity_cooccurrences')
op.drop_table('entity_cooccurrences')
op.drop_index("idx_entity_cooccurrences_count", table_name="entity_cooccurrences")
op.drop_index("idx_entity_cooccurrences_entity2", table_name="entity_cooccurrences")
op.drop_index("idx_entity_cooccurrences_entity1", table_name="entity_cooccurrences")
op.drop_table("entity_cooccurrences")
# Drop BM25 materialized view and index
op.drop_index('idx_memory_units_bm25_text_vector', table_name='memory_units_bm25')
op.drop_index('idx_memory_units_bm25_bank', table_name='memory_units_bm25')
op.execute('DROP MATERIALIZED VIEW IF EXISTS memory_units_bm25')
op.drop_index("idx_memory_units_bm25_text_vector", table_name="memory_units_bm25")
op.drop_index("idx_memory_units_bm25_bank", table_name="memory_units_bm25")
op.execute("DROP MATERIALIZED VIEW IF EXISTS memory_units_bm25")
op.drop_index('idx_memory_units_embedding', table_name='memory_units')
op.drop_index('idx_memory_units_observation_date', table_name='memory_units')
op.drop_index('idx_memory_units_opinion_date', table_name='memory_units')
op.drop_index('idx_memory_units_opinion_confidence', table_name='memory_units')
op.drop_index('idx_memory_units_bank_type_date', table_name='memory_units')
op.drop_index('idx_memory_units_bank_fact_type', table_name='memory_units')
op.drop_index('idx_memory_units_fact_type', table_name='memory_units')
op.drop_index('idx_memory_units_access_count', table_name='memory_units')
op.drop_index('idx_memory_units_bank_date', table_name='memory_units')
op.drop_index('idx_memory_units_event_date', table_name='memory_units')
op.drop_index('idx_memory_units_document_id', table_name='memory_units')
op.drop_index('idx_memory_units_bank_id', table_name='memory_units')
op.execute('DROP INDEX IF EXISTS idx_memory_units_text_search')
op.drop_table('memory_units')
op.drop_index("idx_memory_units_embedding", table_name="memory_units")
op.drop_index("idx_memory_units_observation_date", table_name="memory_units")
op.drop_index("idx_memory_units_opinion_date", table_name="memory_units")
op.drop_index("idx_memory_units_opinion_confidence", table_name="memory_units")
op.drop_index("idx_memory_units_bank_type_date", table_name="memory_units")
op.drop_index("idx_memory_units_bank_fact_type", table_name="memory_units")
op.drop_index("idx_memory_units_fact_type", table_name="memory_units")
op.drop_index("idx_memory_units_access_count", table_name="memory_units")
op.drop_index("idx_memory_units_bank_date", table_name="memory_units")
op.drop_index("idx_memory_units_event_date", table_name="memory_units")
op.drop_index("idx_memory_units_document_id", table_name="memory_units")
op.drop_index("idx_memory_units_bank_id", table_name="memory_units")
op.execute("DROP INDEX IF EXISTS idx_memory_units_text_search")
op.drop_table("memory_units")
op.execute('DROP INDEX IF EXISTS idx_entities_bank_lower_name')
op.drop_index('idx_entities_bank_name', table_name='entities')
op.drop_index('idx_entities_canonical_name', table_name='entities')
op.drop_index('idx_entities_bank_id', table_name='entities')
op.drop_table('entities')
op.execute("DROP INDEX IF EXISTS idx_entities_bank_lower_name")
op.drop_index("idx_entities_bank_name", table_name="entities")
op.drop_index("idx_entities_canonical_name", table_name="entities")
op.drop_index("idx_entities_bank_id", table_name="entities")
op.drop_table("entities")
op.drop_index('idx_async_operations_bank_status', table_name='async_operations')
op.drop_index('idx_async_operations_status', table_name='async_operations')
op.drop_index('idx_async_operations_bank_id', table_name='async_operations')
op.drop_table('async_operations')
op.drop_index("idx_async_operations_bank_status", table_name="async_operations")
op.drop_index("idx_async_operations_status", table_name="async_operations")
op.drop_index("idx_async_operations_bank_id", table_name="async_operations")
op.drop_table("async_operations")
op.drop_index('idx_documents_content_hash', table_name='documents')
op.drop_index('idx_documents_bank_id', table_name='documents')
op.drop_table('documents')
op.drop_index("idx_documents_content_hash", table_name="documents")
op.drop_index("idx_documents_bank_id", table_name="documents")
op.drop_table("documents")
op.drop_table('banks')
op.drop_table("banks")
# Drop extensions (optional - comment out if you want to keep them)
# op.execute('DROP EXTENSION IF EXISTS vector')
@@ -5,18 +5,18 @@ Revises: 5a366d414dce
Create Date: 2025-11-28 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'b7c4d8e9f1a2'
down_revision: Union[str, Sequence[str], None] = '5a366d414dce'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "b7c4d8e9f1a2"
down_revision: str | Sequence[str] | None = "5a366d414dce"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
@@ -24,47 +24,47 @@ def upgrade() -> None:
# Create chunks table with single text PK (bank_id_document_id_chunk_index)
op.create_table(
'chunks',
sa.Column('chunk_id', sa.Text(), nullable=False),
sa.Column('document_id', sa.Text(), nullable=False),
sa.Column('bank_id', sa.Text(), nullable=False),
sa.Column('chunk_index', sa.Integer(), nullable=False),
sa.Column('chunk_text', sa.Text(), nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['document_id', 'bank_id'], ['documents.id', 'documents.bank_id'], name='chunks_document_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('chunk_id', name=op.f('pk_chunks'))
"chunks",
sa.Column("chunk_id", sa.Text(), nullable=False),
sa.Column("document_id", sa.Text(), nullable=False),
sa.Column("bank_id", sa.Text(), nullable=False),
sa.Column("chunk_index", sa.Integer(), nullable=False),
sa.Column("chunk_text", sa.Text(), nullable=False),
sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["document_id", "bank_id"],
["documents.id", "documents.bank_id"],
name="chunks_document_fkey",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("chunk_id", name=op.f("pk_chunks")),
)
# Add indexes for efficient queries
op.create_index('idx_chunks_document_id', 'chunks', ['document_id'])
op.create_index('idx_chunks_bank_id', 'chunks', ['bank_id'])
op.create_index("idx_chunks_document_id", "chunks", ["document_id"])
op.create_index("idx_chunks_bank_id", "chunks", ["bank_id"])
# Add chunk_id column to memory_units (nullable, as existing records won't have chunks)
op.add_column('memory_units', sa.Column('chunk_id', sa.Text(), nullable=True))
op.add_column("memory_units", sa.Column("chunk_id", sa.Text(), nullable=True))
# Add foreign key constraint to chunks table
op.create_foreign_key(
'memory_units_chunk_fkey',
'memory_units',
'chunks',
['chunk_id'],
['chunk_id'],
ondelete='SET NULL'
"memory_units_chunk_fkey", "memory_units", "chunks", ["chunk_id"], ["chunk_id"], ondelete="SET NULL"
)
# Add index on chunk_id for efficient lookups
op.create_index('idx_memory_units_chunk_id', 'memory_units', ['chunk_id'])
op.create_index("idx_memory_units_chunk_id", "memory_units", ["chunk_id"])
def downgrade() -> None:
"""Remove chunks table and chunk_id from memory_units."""
# Drop index and foreign key from memory_units
op.drop_index('idx_memory_units_chunk_id', table_name='memory_units')
op.drop_constraint('memory_units_chunk_fkey', 'memory_units', type_='foreignkey')
op.drop_column('memory_units', 'chunk_id')
op.drop_index("idx_memory_units_chunk_id", table_name="memory_units")
op.drop_constraint("memory_units_chunk_fkey", "memory_units", type_="foreignkey")
op.drop_column("memory_units", "chunk_id")
# Drop chunks table indexes and table
op.drop_index('idx_chunks_bank_id', table_name='chunks')
op.drop_index('idx_chunks_document_id', table_name='chunks')
op.drop_table('chunks')
op.drop_index("idx_chunks_bank_id", table_name="chunks")
op.drop_index("idx_chunks_document_id", table_name="chunks")
op.drop_table("chunks")
@@ -5,35 +5,35 @@ Revises: b7c4d8e9f1a2
Create Date: 2025-12-02 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'c8e5f2a3b4d1'
down_revision: Union[str, Sequence[str], None] = 'b7c4d8e9f1a2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "c8e5f2a3b4d1"
down_revision: str | Sequence[str] | None = "b7c4d8e9f1a2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Add retain_params JSONB column to documents table."""
# Add retain_params column to store parameters passed during retain
op.add_column('documents', sa.Column('retain_params', postgresql.JSONB(), nullable=True))
op.add_column("documents", sa.Column("retain_params", postgresql.JSONB(), nullable=True))
# Add index for efficient queries on retain_params
op.create_index('idx_documents_retain_params', 'documents', ['retain_params'], postgresql_using='gin')
op.create_index("idx_documents_retain_params", "documents", ["retain_params"], postgresql_using="gin")
def downgrade() -> None:
"""Remove retain_params column from documents table."""
# Drop index
op.drop_index('idx_documents_retain_params', table_name='documents')
op.drop_index("idx_documents_retain_params", table_name="documents")
# Drop column
op.drop_column('documents', 'retain_params')
op.drop_column("documents", "retain_params")
@@ -5,44 +5,49 @@ Revises: c8e5f2a3b4d1
Create Date: 2024-12-04 15:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import context, op
# revision identifiers, used by Alembic.
revision = 'd9f6a3b4c5e2'
down_revision = 'c8e5f2a3b4d1'
revision = "d9f6a3b4c5e2"
down_revision = "c8e5f2a3b4d1"
branch_labels = None
depends_on = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (e.g., 'tenant_x.' or '' for public)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade():
schema = _get_schema_prefix()
# Drop old check constraint FIRST (before updating data)
op.drop_constraint('memory_units_fact_type_check', 'memory_units', type_='check')
op.drop_constraint("memory_units_fact_type_check", "memory_units", type_="check")
# Update existing 'bank' values to 'experience'
op.execute("UPDATE memory_units SET fact_type = 'experience' WHERE fact_type = 'bank'")
op.execute(f"UPDATE {schema}memory_units SET fact_type = 'experience' WHERE fact_type = 'bank'")
# Also update any 'interactions' values (in case of partial migration)
op.execute("UPDATE memory_units SET fact_type = 'experience' WHERE fact_type = 'interactions'")
op.execute(f"UPDATE {schema}memory_units SET fact_type = 'experience' WHERE fact_type = 'interactions'")
# Create new check constraint with 'experience' instead of 'bank'
op.create_check_constraint(
'memory_units_fact_type_check',
'memory_units',
"fact_type IN ('world', 'experience', 'opinion', 'observation')"
"memory_units_fact_type_check", "memory_units", "fact_type IN ('world', 'experience', 'opinion', 'observation')"
)
def downgrade():
schema = _get_schema_prefix()
# Drop new check constraint FIRST
op.drop_constraint('memory_units_fact_type_check', 'memory_units', type_='check')
op.drop_constraint("memory_units_fact_type_check", "memory_units", type_="check")
# Update 'experience' back to 'bank'
op.execute("UPDATE memory_units SET fact_type = 'bank' WHERE fact_type = 'experience'")
op.execute(f"UPDATE {schema}memory_units SET fact_type = 'bank' WHERE fact_type = 'experience'")
# Recreate old check constraint
op.create_check_constraint(
'memory_units_fact_type_check',
'memory_units',
"fact_type IN ('world', 'bank', 'opinion', 'observation')"
"memory_units_fact_type_check", "memory_units", "fact_type IN ('world', 'bank', 'opinion', 'observation')"
)
@@ -8,22 +8,49 @@ Migrate disposition traits from Big Five (openness, conscientiousness, extravers
agreeableness, neuroticism, bias_strength with 0-1 float values) to the new 3-trait
system (skepticism, literalism, empathy with 1-5 integer values).
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = 'e0a1b2c3d4e5'
down_revision: Union[str, Sequence[str], None] = 'rename_personality'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "e0a1b2c3d4e5"
down_revision: str | Sequence[str] | None = "rename_personality"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (e.g., 'tenant_x.' or '' for public)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _get_target_schema() -> str:
"""Get the target schema name (tenant schema or 'public')."""
schema = context.config.get_main_option("target_schema")
return schema if schema else "public"
def upgrade() -> None:
"""Convert Big Five disposition to 3-trait disposition."""
conn = op.get_bind()
schema = _get_schema_prefix()
target_schema = _get_target_schema()
# Check if disposition column exists (should have been created by previous migration)
result = conn.execute(
sa.text("""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'disposition'
"""),
{"schema": target_schema},
)
if not result.fetchone():
# Column doesn't exist yet (shouldn't happen but be safe)
return
# Update all existing banks to use the new disposition format
# Convert from old format to new format with reasonable mappings:
@@ -31,32 +58,54 @@ def upgrade() -> None:
# - literalism: derived from conscientiousness (detail-oriented people are more literal)
# - empathy: derived from agreeableness + inverse of neuroticism
# Default all to 3 (neutral) for simplicity
conn.execute(sa.text("""
UPDATE banks
SET disposition = '{"skepticism": 3, "literalism": 3, "empathy": 3}'::jsonb
conn.execute(
sa.text(f"""
UPDATE {schema}banks
SET disposition = '{{"skepticism": 3, "literalism": 3, "empathy": 3}}'::jsonb
WHERE disposition IS NOT NULL
"""))
""")
)
# Update the default for new banks
conn.execute(sa.text("""
ALTER TABLE banks
ALTER COLUMN disposition SET DEFAULT '{"skepticism": 3, "literalism": 3, "empathy": 3}'::jsonb
"""))
conn.execute(
sa.text(f"""
ALTER TABLE {schema}banks
ALTER COLUMN disposition SET DEFAULT '{{"skepticism": 3, "literalism": 3, "empathy": 3}}'::jsonb
""")
)
def downgrade() -> None:
"""Convert back to Big Five disposition."""
conn = op.get_bind()
schema = _get_schema_prefix()
target_schema = _get_target_schema()
# Check if disposition column exists
result = conn.execute(
sa.text("""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'disposition'
"""),
{"schema": target_schema},
)
if not result.fetchone():
return
# Revert to Big Five format with default values
conn.execute(sa.text("""
UPDATE banks
SET disposition = '{"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.5, "agreeableness": 0.5, "neuroticism": 0.5, "bias_strength": 0.5}'::jsonb
conn.execute(
sa.text(f"""
UPDATE {schema}banks
SET disposition = '{{"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.5, "agreeableness": 0.5, "neuroticism": 0.5, "bias_strength": 0.5}}'::jsonb
WHERE disposition IS NOT NULL
"""))
""")
)
# Update the default for new banks
conn.execute(sa.text("""
ALTER TABLE banks
ALTER COLUMN disposition SET DEFAULT '{"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.5, "agreeableness": 0.5, "neuroticism": 0.5, "bias_strength": 0.5}'::jsonb
"""))
conn.execute(
sa.text(f"""
ALTER TABLE {schema}banks
ALTER COLUMN disposition SET DEFAULT '{{"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.5, "agreeableness": 0.5, "neuroticism": 0.5, "bias_strength": 0.5}}'::jsonb
""")
)
@@ -5,61 +5,81 @@ Revises: d9f6a3b4c5e2
Create Date: 2024-12-04
"""
from typing import Sequence, Union
from alembic import op
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'rename_personality'
down_revision: Union[str, Sequence[str], None] = 'd9f6a3b4c5e2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
revision: str = "rename_personality"
down_revision: str | Sequence[str] | None = "d9f6a3b4c5e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_target_schema() -> str:
"""Get the target schema name (tenant schema or 'public')."""
schema = context.config.get_main_option("target_schema")
return schema if schema else "public"
def upgrade() -> None:
"""Rename personality column to disposition in banks table (if it exists)."""
conn = op.get_bind()
target_schema = _get_target_schema()
# Check if 'personality' column exists (old database)
result = conn.execute(sa.text("""
result = conn.execute(
sa.text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'banks' AND column_name = 'personality'
"""))
WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'personality'
"""),
{"schema": target_schema},
)
has_personality = result.fetchone() is not None
# Check if 'disposition' column exists (new database)
result = conn.execute(sa.text("""
result = conn.execute(
sa.text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'banks' AND column_name = 'disposition'
"""))
WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'disposition'
"""),
{"schema": target_schema},
)
has_disposition = result.fetchone() is not None
if has_personality and not has_disposition:
# Old database: rename personality -> disposition
op.alter_column('banks', 'personality', new_column_name='disposition')
op.alter_column("banks", "personality", new_column_name="disposition")
elif not has_personality and not has_disposition:
# Neither exists (shouldn't happen, but be safe): add disposition column
op.add_column('banks', sa.Column(
'disposition',
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False
))
op.add_column(
"banks",
sa.Column(
"disposition",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
)
# else: disposition already exists, nothing to do
def downgrade() -> None:
"""Revert disposition column back to personality."""
conn = op.get_bind()
result = conn.execute(sa.text("""
target_schema = _get_target_schema()
result = conn.execute(
sa.text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'banks' AND column_name = 'disposition'
"""))
WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'disposition'
"""),
{"schema": target_schema},
)
if result.fetchone():
op.alter_column('banks', 'disposition', new_column_name='personality')
op.alter_column("banks", "disposition", new_column_name="personality")
+49 -25
View File
@@ -3,8 +3,11 @@ Unified API module for Hindsight.
Provides both HTTP REST API and MCP (Model Context Protocol) server.
"""
import logging
from contextlib import asynccontextmanager
from typing import Optional
from fastapi import FastAPI
from hindsight_api import MemoryEngine
@@ -17,7 +20,7 @@ def create_app(
http_api_enabled: bool = True,
mcp_api_enabled: bool = False,
mcp_mount_path: str = "/mcp",
initialize_memory: bool = True
initialize_memory: bool = True,
) -> FastAPI:
"""
Create and configure the unified Hindsight API application.
@@ -43,49 +46,70 @@ def create_app(
# Both HTTP and MCP
app = create_app(memory, mcp_api_enabled=True)
"""
mcp_app = None
# Create MCP app first if enabled (we need its lifespan for chaining)
if mcp_api_enabled:
try:
from .mcp import create_mcp_app
mcp_app = create_mcp_app(memory=memory)
except ImportError as e:
logger.error(f"MCP server requested but dependencies not available: {e}")
logger.error("Install with: pip install hindsight-api[mcp]")
raise
# Import and create HTTP API if enabled
if http_api_enabled:
from .http import create_app as create_http_app
app = create_http_app(
memory=memory,
initialize_memory=initialize_memory
)
app = create_http_app(memory=memory, initialize_memory=initialize_memory)
logger.info("HTTP REST API enabled")
else:
# Create minimal FastAPI app
app = FastAPI(title="Hindsight API", version="0.0.7")
logger.info("HTTP REST API disabled")
# Mount MCP server if enabled
if mcp_api_enabled:
try:
from .mcp import create_mcp_app
# Mount MCP server and chain its lifespan if enabled
if mcp_app is not None:
# Get the MCP app's underlying Starlette app for lifespan access
mcp_starlette_app = mcp_app.mcp_app
# Create MCP app with dynamic bank_id support
# Supports: /mcp/{bank_id}/sse (bank-specific SSE endpoint)
mcp_app = create_mcp_app(memory=memory)
app.mount(mcp_mount_path, mcp_app)
logger.info(f"MCP server enabled at {mcp_mount_path}/{{bank_id}}/sse")
except ImportError as e:
logger.error(f"MCP server requested but dependencies not available: {e}")
logger.error("Install with: pip install hindsight-api[mcp]")
raise
# Store the original lifespan
original_lifespan = app.router.lifespan_context
@asynccontextmanager
async def chained_lifespan(app_instance: FastAPI):
"""Chain the MCP lifespan with the main app lifespan."""
# Start MCP lifespan first
async with mcp_starlette_app.router.lifespan_context(mcp_starlette_app):
logger.info("MCP lifespan started")
# Then start the original app lifespan
async with original_lifespan(app_instance):
yield
logger.info("MCP lifespan stopped")
# Replace the app's lifespan with the chained version
app.router.lifespan_context = chained_lifespan
# Mount the MCP middleware
app.mount(mcp_mount_path, mcp_app)
logger.info(f"MCP server enabled at {mcp_mount_path}/")
return app
# Re-export commonly used items for backwards compatibility
from .http import (
RecallRequest,
RecallResult,
RecallResponse,
MemoryItem,
RetainRequest,
ReflectRequest,
ReflectResponse,
CreateBankRequest,
DispositionTraits,
MemoryItem,
RecallRequest,
RecallResponse,
RecallResult,
ReflectRequest,
ReflectResponse,
RetainRequest,
)
__all__ = [
File diff suppressed because it is too large Load Diff
+234 -70
View File
@@ -4,28 +4,39 @@ import json
import logging
import os
from contextvars import ContextVar
from typing import Optional
from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api.api.http import BankListItem, BankListResponse, BankProfileResponse, DispositionTraits
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.models import RequestContext
# Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower()
_log_level_map = {"critical": logging.CRITICAL, "error": logging.ERROR, "warning": logging.WARNING,
"info": logging.INFO, "debug": logging.DEBUG, "trace": logging.DEBUG}
_log_level_map = {
"critical": logging.CRITICAL,
"error": logging.ERROR,
"warning": logging.WARNING,
"info": logging.INFO,
"debug": logging.DEBUG,
"trace": logging.DEBUG,
}
logging.basicConfig(
level=_log_level_map.get(_log_level_str, logging.INFO),
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s"
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
)
logger = logging.getLogger(__name__)
# Context variable to hold the current bank_id from the URL path
_current_bank_id: ContextVar[Optional[str]] = ContextVar("current_bank_id", default=None)
# Default bank_id from environment variable
DEFAULT_BANK_ID = os.environ.get("HINDSIGHT_MCP_BANK_ID", "default")
# Context variable to hold the current bank_id
_current_bank_id: ContextVar[str | None] = ContextVar("current_bank_id", default=None)
def get_current_bank_id() -> Optional[str]:
"""Get the current bank_id from context (set from URL path)."""
def get_current_bank_id() -> str | None:
"""Get the current bank_id from context."""
return _current_bank_id.get()
@@ -37,12 +48,13 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
memory: MemoryEngine instance (required)
Returns:
Configured FastMCP server instance
Configured FastMCP server instance with stateless_http enabled
"""
mcp = FastMCP("hindsight-mcp-server")
# Use stateless_http=True for Claude Code compatibility
mcp = FastMCP("hindsight-mcp-server", stateless_http=True)
@mcp.tool()
async def retain(content: str, context: str = "general") -> str:
async def retain(content: str, context: str = "general", bank_id: str | None = None) -> str:
"""
Store important information to long-term memory.
@@ -58,20 +70,24 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
Args:
content: The fact/memory to store (be specific and include relevant details)
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
"""
try:
bank_id = get_current_bank_id()
await memory.put_batch_async(
bank_id=bank_id,
contents=[{"content": content, "context": context}]
target_bank = bank_id or get_current_bank_id()
if target_bank is None:
return "Error: No bank_id configured"
await memory.retain_batch_async(
bank_id=target_bank,
contents=[{"content": content, "context": context}],
request_context=RequestContext(),
)
return "Memory stored successfully"
return f"Memory stored successfully in bank '{target_bank}'"
except Exception as e:
logger.error(f"Error storing memory: {e}", exc_info=True)
return f"Error: {str(e)}"
@mcp.tool()
async def recall(query: str, max_results: int = 10) -> str:
async def recall(query: str, max_tokens: int = 4096, bank_id: str | None = None) -> str:
"""
Search memories to provide personalized, context-aware responses.
@@ -83,45 +99,184 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
Args:
query: Natural language search query (e.g., "user's food preferences", "what projects is user working on")
max_results: Maximum number of results to return (default: 10)
max_tokens: Maximum tokens in the response (default: 4096)
bank_id: Optional bank to search in (defaults to session bank). Use for cross-bank operations.
"""
try:
bank_id = get_current_bank_id()
target_bank = bank_id or get_current_bank_id()
if target_bank is None:
return "Error: No bank_id configured"
from hindsight_api.engine.memory_engine import Budget
search_result = await memory.recall_async(
bank_id=bank_id,
recall_result = await memory.recall_async(
bank_id=target_bank,
query=query,
fact_type=list(VALID_RECALL_FACT_TYPES),
budget=Budget.LOW
budget=Budget.HIGH,
max_tokens=max_tokens,
request_context=RequestContext(),
)
results = [
{
"id": fact.id,
"text": fact.text,
"type": fact.fact_type,
"context": fact.context,
"event_date": fact.event_date,
}
for fact in search_result.results[:max_results]
]
return json.dumps({"results": results}, indent=2)
# Use model's JSON serialization
return recall_result.model_dump_json(indent=2)
except Exception as e:
logger.error(f"Error searching: {e}", exc_info=True)
return json.dumps({"error": str(e), "results": []})
return f'{{"error": "{e}", "results": []}}'
@mcp.tool()
async def reflect(query: str, context: str | None = None, budget: str = "low", bank_id: str | None = None) -> str:
"""
Generate thoughtful analysis by synthesizing stored memories with the bank's personality.
WHEN TO USE THIS TOOL:
Use reflect when you need reasoned analysis, not just fact retrieval. This tool
thinks through the question using everything the bank knows and its personality traits.
EXAMPLES OF GOOD QUERIES:
- "What patterns have emerged in how I approach debugging?"
- "Based on my past decisions, what architectural style do I prefer?"
- "What might be the best approach for this problem given what you know about me?"
- "How should I prioritize these tasks based on my goals?"
HOW IT DIFFERS FROM RECALL:
- recall: Returns raw facts matching your search (fast lookup)
- reflect: Reasons across memories to form a synthesized answer (deeper analysis)
Use recall for "what did I say about X?" and reflect for "what should I do about X?"
Args:
query: The question or topic to reflect on
context: Optional context about why this reflection is needed
budget: Search budget - 'low', 'mid', or 'high' (default: 'low')
bank_id: Optional bank to reflect in (defaults to session bank). Use for cross-bank operations.
"""
try:
target_bank = bank_id or get_current_bank_id()
if target_bank is None:
return "Error: No bank_id configured"
from hindsight_api.engine.memory_engine import Budget
# Map string budget to enum
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(budget.lower(), Budget.LOW)
reflect_result = await memory.reflect_async(
bank_id=target_bank,
query=query,
budget=budget_enum,
context=context,
request_context=RequestContext(),
)
return reflect_result.model_dump_json(indent=2)
except Exception as e:
logger.error(f"Error reflecting: {e}", exc_info=True)
return f'{{"error": "{e}", "text": ""}}'
@mcp.tool()
async def list_banks() -> str:
"""
List all available memory banks.
Use this to discover banks for orchestration or to find
the correct bank_id for cross-bank operations.
Returns:
JSON object with banks array containing bank_id, name, disposition, background, and timestamps
"""
try:
banks = await memory.list_banks(request_context=RequestContext())
bank_items = [
BankListItem(
bank_id=b.get("bank_id") or b.get("id"),
name=b.get("name"),
disposition=DispositionTraits(
**b.get("disposition", {"skepticism": 3, "literalism": 3, "empathy": 3})
),
background=b.get("background"),
created_at=str(b.get("created_at")) if b.get("created_at") else None,
updated_at=str(b.get("updated_at")) if b.get("updated_at") else None,
)
for b in banks
]
return BankListResponse(banks=bank_items).model_dump_json(indent=2)
except Exception as e:
logger.error(f"Error listing banks: {e}", exc_info=True)
return f'{{"error": "{e}", "banks": []}}'
@mcp.tool()
async def create_bank(bank_id: str, name: str | None = None, background: str | None = None) -> str:
"""
Create or update a memory bank.
Use this to create new banks for different agents, sessions, or purposes.
Banks are isolated memory stores - each bank has its own memories and personality.
Args:
bank_id: Unique identifier for the bank (e.g., 'orchestrator-memory', 'agent-1')
name: Human-readable name for the bank
background: Context about what this bank stores or its purpose
"""
try:
# Get or create the bank profile (auto-creates with defaults)
await memory.get_bank_profile(bank_id, request_context=RequestContext())
# Update name and/or background if provided
if name is not None or background is not None:
await memory.update_bank(bank_id, name=name, background=background, request_context=RequestContext())
# Get final profile and return using BankProfileResponse model
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
disposition = profile.get("disposition")
if hasattr(disposition, "model_dump"):
disposition_traits = DispositionTraits(**disposition.model_dump())
else:
disposition_traits = DispositionTraits(
**dict(disposition or {"skepticism": 3, "literalism": 3, "empathy": 3})
)
response = BankProfileResponse(
bank_id=bank_id,
name=profile.get("name") or "",
disposition=disposition_traits,
background=profile.get("background") or "",
)
return response.model_dump_json(indent=2)
except Exception as e:
logger.error(f"Error creating bank: {e}", exc_info=True)
return json.dumps({"error": str(e)})
return mcp
class MCPMiddleware:
"""ASGI middleware that extracts bank_id from path and sets context."""
"""ASGI middleware that extracts bank_id from header or path and sets context.
Bank ID can be provided via:
1. X-Bank-Id header (recommended for Claude Code)
2. URL path: /mcp/{bank_id}/
3. Environment variable HINDSIGHT_MCP_BANK_ID (fallback default)
For Claude Code, configure with:
claude mcp add --transport http hindsight http://localhost:8888/mcp \\
--header "X-Bank-Id: my-bank"
"""
def __init__(self, app, memory: MemoryEngine):
self.app = app
self.memory = memory
self.mcp_server = create_mcp_server(memory)
self.mcp_app = self.mcp_server.http_app()
self.mcp_app = self.mcp_server.http_app(path="/")
# Expose the lifespan for the parent app to chain
self.lifespan = self.mcp_app.lifespan_handler if hasattr(self.mcp_app, "lifespan_handler") else None
def _get_header(self, scope: dict, name: str) -> str | None:
"""Extract a header value from ASGI scope."""
name_lower = name.lower().encode()
for header_name, header_value in scope.get("headers", []):
if header_name.lower() == name_lower:
return header_value.decode()
return None
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
@@ -133,46 +288,50 @@ class MCPMiddleware:
# Strip any mount prefix (e.g., /mcp) that FastAPI might not have stripped
root_path = scope.get("root_path", "")
if root_path and path.startswith(root_path):
path = path[len(root_path):] or "/"
path = path[len(root_path) :] or "/"
# Also handle case where mount path wasn't stripped (e.g., /mcp/...)
if path.startswith("/mcp/"):
path = path[4:] # Remove /mcp prefix
elif path == "/mcp":
path = "/"
# Extract bank_id from path: /{bank_id}/ or /{bank_id}
# http_app expects requests at /
if not path.startswith("/") or len(path) <= 1:
# No bank_id in path - return error
await self._send_error(send, 400, "bank_id required in path: /mcp/{bank_id}/")
return
# Try to get bank_id from header first (for Claude Code compatibility)
bank_id = self._get_header(scope, "X-Bank-Id")
# Extract bank_id from first path segment
parts = path[1:].split("/", 1)
if not parts[0]:
await self._send_error(send, 400, "bank_id required in path: /mcp/{bank_id}/")
return
# MCP endpoint paths that should not be treated as bank_ids
MCP_ENDPOINTS = {"sse", "messages"}
bank_id = parts[0]
new_path = "/" + parts[1] if len(parts) > 1 else "/"
# If no header, try to extract from path: /{bank_id}/...
new_path = path
if not bank_id and path.startswith("/") and len(path) > 1:
parts = path[1:].split("/", 1)
# Don't treat MCP endpoints as bank_ids
if parts[0] and parts[0] not in MCP_ENDPOINTS:
# First segment looks like a bank_id
bank_id = parts[0]
new_path = "/" + parts[1] if len(parts) > 1 else "/"
# Fall back to default bank_id
if not bank_id:
bank_id = DEFAULT_BANK_ID
logger.debug(f"Using default bank_id: {bank_id}")
# Set bank_id context
token = _current_bank_id.set(bank_id)
try:
new_scope = scope.copy()
new_scope["path"] = new_path
# Clear root_path since we're passing directly to the app
new_scope["root_path"] = ""
# Wrap send to rewrite the SSE endpoint URL to include bank_id
# The SSE app sends "event: endpoint\ndata: /messages\n" but we need
# the client to POST to /{bank_id}/messages instead
# Wrap send to rewrite the SSE endpoint URL to include bank_id if using path-based routing
async def send_wrapper(message):
if message["type"] == "http.response.body":
body = message.get("body", b"")
if body and b"/messages" in body:
# Rewrite /messages to /{bank_id}/messages in SSE endpoint event
body = body.replace(
b"data: /messages",
f"data: /{bank_id}/messages".encode()
)
body = body.replace(b"data: /messages", f"data: /{bank_id}/messages".encode())
message = {**message, "body": body}
await send(message)
@@ -183,24 +342,29 @@ class MCPMiddleware:
async def _send_error(self, send, status: int, message: str):
"""Send an error response."""
body = json.dumps({"error": message}).encode()
await send({
"type": "http.response.start",
"status": status,
"headers": [(b"content-type", b"application/json")],
})
await send({
"type": "http.response.body",
"body": body,
})
await send(
{
"type": "http.response.start",
"status": status,
"headers": [(b"content-type", b"application/json")],
}
)
await send(
{
"type": "http.response.body",
"body": body,
}
)
def create_mcp_app(memory: MemoryEngine):
"""
Create an ASGI app that handles MCP requests.
URL pattern: /mcp/{bank_id}/
The bank_id is extracted from the URL path and made available to tools.
Bank ID can be provided via:
1. X-Bank-Id header: claude mcp add --transport http hindsight http://localhost:8888/mcp --header "X-Bank-Id: my-bank"
2. URL path: /mcp/{bank_id}/
3. Environment variable HINDSIGHT_MCP_BANK_ID (fallback, default: "default")
Args:
memory: MemoryEngine instance
+13 -6
View File
@@ -6,7 +6,7 @@ Shows the logo and tagline with gradient colors.
# Gradient colors: #0074d9 -> #009296
GRADIENT_START = (0, 116, 217) # #0074d9
GRADIENT_END = (0, 146, 150) # #009296
GRADIENT_END = (0, 146, 150) # #009296
# Pre-generated logo (generated by test-logo.py)
LOGO = """\
@@ -31,8 +31,8 @@ def gradient_text(text: str, start: tuple = GRADIENT_START, end: tuple = GRADIEN
result = []
length = len(text)
for i, char in enumerate(text):
if char == ' ':
result.append(' ')
if char == " ":
result.append(" ")
else:
t = i / max(length - 1, 1)
r, g, b = _interpolate_color(start, end, t)
@@ -74,9 +74,16 @@ def dim(text: str) -> str:
return f"\033[38;2;128;128;128m{text}\033[0m"
def print_startup_info(host: str, port: int, database_url: str, llm_provider: str,
llm_model: str, embeddings_provider: str, reranker_provider: str,
mcp_enabled: bool = False):
def print_startup_info(
host: str,
port: int,
database_url: str,
llm_provider: str,
llm_model: str,
embeddings_provider: str,
reranker_provider: str,
mcp_enabled: bool = False,
):
"""Print styled startup information."""
print(color_start("Starting Hindsight API..."))
print(f" {dim('URL:')} {color(f'http://{host}:{port}', 0.2)}")
+120 -16
View File
@@ -3,10 +3,15 @@ Centralized configuration for Hindsight API.
All environment variables and their defaults are defined here.
"""
import logging
import os
from dataclasses import dataclass
from typing import Optional
import logging
from dotenv import find_dotenv, load_dotenv
# Load .env file, searching current and parent directories (overrides existing env vars)
load_dotenv(find_dotenv(usecwd=True), override=True)
logger = logging.getLogger(__name__)
@@ -16,10 +21,15 @@ ENV_LLM_PROVIDER = "HINDSIGHT_API_LLM_PROVIDER"
ENV_LLM_API_KEY = "HINDSIGHT_API_LLM_API_KEY"
ENV_LLM_MODEL = "HINDSIGHT_API_LLM_MODEL"
ENV_LLM_BASE_URL = "HINDSIGHT_API_LLM_BASE_URL"
ENV_LLM_MAX_CONCURRENT = "HINDSIGHT_API_LLM_MAX_CONCURRENT"
ENV_LLM_TIMEOUT = "HINDSIGHT_API_LLM_TIMEOUT"
ENV_LLM_GROQ_SERVICE_TIER = "HINDSIGHT_API_LLM_GROQ_SERVICE_TIER"
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL"
ENV_EMBEDDINGS_OPENAI_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY"
ENV_EMBEDDINGS_OPENAI_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL"
ENV_RERANKER_PROVIDER = "HINDSIGHT_API_RERANKER_PROVIDER"
ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL"
@@ -30,14 +40,34 @@ ENV_PORT = "HINDSIGHT_API_PORT"
ENV_LOG_LEVEL = "HINDSIGHT_API_LOG_LEVEL"
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
# Observation thresholds
ENV_OBSERVATION_MIN_FACTS = "HINDSIGHT_API_OBSERVATION_MIN_FACTS"
ENV_OBSERVATION_TOP_ENTITIES = "HINDSIGHT_API_OBSERVATION_TOP_ENTITIES"
# Retain settings
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
# Optimization flags
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
# Database migrations
ENV_RUN_MIGRATIONS_ON_STARTUP = "HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP"
# Default values
DEFAULT_DATABASE_URL = "pg0"
DEFAULT_LLM_PROVIDER = "openai"
DEFAULT_LLM_MODEL = "gpt-5-mini"
DEFAULT_LLM_MAX_CONCURRENT = 32
DEFAULT_LLM_TIMEOUT = 120.0 # seconds
DEFAULT_EMBEDDINGS_PROVIDER = "local"
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
DEFAULT_EMBEDDINGS_OPENAI_MODEL = "text-embedding-3-small"
DEFAULT_EMBEDDING_DIMENSION = 384
DEFAULT_RERANKER_PROVIDER = "local"
DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
@@ -47,9 +77,40 @@ DEFAULT_PORT = 8888
DEFAULT_LOG_LEVEL = "info"
DEFAULT_MCP_ENABLED = True
DEFAULT_GRAPH_RETRIEVER = "bfs" # Options: "bfs", "mpfp"
DEFAULT_MCP_LOCAL_BANK_ID = "mcp"
# Required embedding dimension for database schema
EMBEDDING_DIMENSION = 384
# Observation thresholds
DEFAULT_OBSERVATION_MIN_FACTS = 5 # Min facts required to generate entity observations
DEFAULT_OBSERVATION_TOP_ENTITIES = 5 # Max entities to process per retain batch
# Retain settings
DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction LLM call
# Database migrations
DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True
# Default MCP tool descriptions (can be customized via env vars)
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
Use this tool PROACTIVELY whenever the user shares:
- Personal facts, preferences, or interests
- Important events or milestones
- User history, experiences, or background
- Decisions, opinions, or stated preferences
- Goals, plans, or future intentions
- Relationships or people mentioned
- Work context, projects, or responsibilities"""
DEFAULT_MCP_RECALL_DESCRIPTION = """Search memories to provide personalized, context-aware responses.
Use this tool PROACTIVELY to:
- Check user's preferences before making suggestions
- Recall user's history to provide continuity
- Remember user's goals and context
- Personalize responses based on past interactions"""
# Default embedding dimension (used by initial migration, adjusted at runtime)
EMBEDDING_DIMENSION = DEFAULT_EMBEDDING_DIMENSION
@dataclass
@@ -61,19 +122,21 @@ class HindsightConfig:
# LLM
llm_provider: str
llm_api_key: Optional[str]
llm_api_key: str | None
llm_model: str
llm_base_url: Optional[str]
llm_base_url: str | None
llm_max_concurrent: int
llm_timeout: float
# Embeddings
embeddings_provider: str
embeddings_local_model: str
embeddings_tei_url: Optional[str]
embeddings_tei_url: str | None
# Reranker
reranker_provider: str
reranker_local_model: str
reranker_tei_url: Optional[str]
reranker_tei_url: str | None
# Server
host: str
@@ -84,37 +147,62 @@ class HindsightConfig:
# Recall
graph_retriever: str
# Observation thresholds
observation_min_facts: int
observation_top_entities: int
# Retain settings
retain_max_completion_tokens: int
# Optimization flags
skip_llm_verification: bool
lazy_reranker: bool
# Database migrations
run_migrations_on_startup: bool
@classmethod
def from_env(cls) -> "HindsightConfig":
"""Create configuration from environment variables."""
return cls(
# Database
database_url=os.getenv(ENV_DATABASE_URL, DEFAULT_DATABASE_URL),
# LLM
llm_provider=os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER),
llm_api_key=os.getenv(ENV_LLM_API_KEY),
llm_model=os.getenv(ENV_LLM_MODEL, DEFAULT_LLM_MODEL),
llm_base_url=os.getenv(ENV_LLM_BASE_URL) or None,
llm_max_concurrent=int(os.getenv(ENV_LLM_MAX_CONCURRENT, str(DEFAULT_LLM_MAX_CONCURRENT))),
llm_timeout=float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT))),
# Embeddings
embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER),
embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL),
embeddings_tei_url=os.getenv(ENV_EMBEDDINGS_TEI_URL),
# Reranker
reranker_provider=os.getenv(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER),
reranker_local_model=os.getenv(ENV_RERANKER_LOCAL_MODEL, DEFAULT_RERANKER_LOCAL_MODEL),
reranker_tei_url=os.getenv(ENV_RERANKER_TEI_URL),
# Server
host=os.getenv(ENV_HOST, DEFAULT_HOST),
port=int(os.getenv(ENV_PORT, DEFAULT_PORT)),
log_level=os.getenv(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL),
mcp_enabled=os.getenv(ENV_MCP_ENABLED, str(DEFAULT_MCP_ENABLED)).lower() == "true",
# Recall
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
# Optimization flags
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
# Observation thresholds
observation_min_facts=int(os.getenv(ENV_OBSERVATION_MIN_FACTS, str(DEFAULT_OBSERVATION_MIN_FACTS))),
observation_top_entities=int(
os.getenv(ENV_OBSERVATION_TOP_ENTITIES, str(DEFAULT_OBSERVATION_TOP_ENTITIES))
),
# Retain settings
retain_max_completion_tokens=int(
os.getenv(ENV_RETAIN_MAX_COMPLETION_TOKENS, str(DEFAULT_RETAIN_MAX_COMPLETION_TOKENS))
),
# Database migrations
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
)
def get_llm_base_url(self) -> str:
@@ -127,6 +215,8 @@ class HindsightConfig:
return "https://api.groq.com/openai/v1"
elif provider == "ollama":
return "http://localhost:11434/v1"
elif provider == "lmstudio":
return "http://localhost:1234/v1"
else:
return ""
@@ -146,7 +236,8 @@ class HindsightConfig:
"""Configure Python logging based on the log level."""
logging.basicConfig(
level=self.get_python_log_level(),
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s"
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
force=True, # Override any existing configuration
)
def log_config(self) -> None:
@@ -158,6 +249,19 @@ class HindsightConfig:
logger.info(f"Graph retriever: {self.graph_retriever}")
# Cached config instance
_config_cache: HindsightConfig | None = None
def get_config() -> HindsightConfig:
"""Get the current configuration from environment variables."""
return HindsightConfig.from_env()
"""Get the cached configuration, loading from environment on first call."""
global _config_cache
if _config_cache is None:
_config_cache = HindsightConfig.from_env()
return _config_cache
def clear_config_cache() -> None:
"""Clear the config cache. Useful for testing or reloading config."""
global _config_cache
_config_cache = None
+204
View File
@@ -0,0 +1,204 @@
"""
Daemon mode support for Hindsight API.
Provides idle timeout and lockfile management for running as a background daemon.
"""
import asyncio
import fcntl
import logging
import os
import sys
import time
from pathlib import Path
logger = logging.getLogger(__name__)
# Default daemon configuration
DEFAULT_DAEMON_PORT = 8889
DEFAULT_IDLE_TIMEOUT = 0 # 0 = no auto-exit (hindsight-embed passes its own timeout)
LOCKFILE_PATH = Path.home() / ".hindsight" / "daemon.lock"
DAEMON_LOG_PATH = Path.home() / ".hindsight" / "daemon.log"
class IdleTimeoutMiddleware:
"""ASGI middleware that tracks activity and exits after idle timeout."""
def __init__(self, app, idle_timeout: int = DEFAULT_IDLE_TIMEOUT):
self.app = app
self.idle_timeout = idle_timeout
self.last_activity = time.time()
self._checker_task = None
async def __call__(self, scope, receive, send):
# Update activity timestamp on each request
self.last_activity = time.time()
await self.app(scope, receive, send)
def start_idle_checker(self):
"""Start the background task that checks for idle timeout."""
self._checker_task = asyncio.create_task(self._check_idle())
async def _check_idle(self):
"""Background task that exits the process after idle timeout."""
# If idle_timeout is 0, don't auto-exit
if self.idle_timeout <= 0:
return
while True:
await asyncio.sleep(30) # Check every 30 seconds
idle_time = time.time() - self.last_activity
if idle_time > self.idle_timeout:
logger.info(f"Idle timeout reached ({self.idle_timeout}s), shutting down daemon")
# Give a moment for any in-flight requests
await asyncio.sleep(1)
os._exit(0)
class DaemonLock:
"""
File-based lock to prevent multiple daemon instances.
Uses fcntl.flock for atomic locking on Unix systems.
"""
def __init__(self, lockfile: Path = LOCKFILE_PATH):
self.lockfile = lockfile
self._fd = None
def acquire(self) -> bool:
"""
Try to acquire the daemon lock.
Returns True if lock acquired, False if another daemon is running.
"""
self.lockfile.parent.mkdir(parents=True, exist_ok=True)
try:
self._fd = open(self.lockfile, "w")
fcntl.flock(self._fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
# Write PID for debugging
self._fd.write(str(os.getpid()))
self._fd.flush()
return True
except (IOError, OSError):
# Lock is held by another process
if self._fd:
self._fd.close()
self._fd = None
return False
def release(self):
"""Release the daemon lock."""
if self._fd:
try:
fcntl.flock(self._fd.fileno(), fcntl.LOCK_UN)
self._fd.close()
except Exception:
pass
finally:
self._fd = None
# Remove lockfile
try:
self.lockfile.unlink()
except Exception:
pass
def is_locked(self) -> bool:
"""Check if the lock is held by another process."""
if not self.lockfile.exists():
return False
try:
fd = open(self.lockfile, "r")
fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
# We got the lock, so no one else has it
fcntl.flock(fd.fileno(), fcntl.LOCK_UN)
fd.close()
return False
except (IOError, OSError):
return True
def get_pid(self) -> int | None:
"""Get the PID of the daemon holding the lock."""
if not self.lockfile.exists():
return None
try:
with open(self.lockfile, "r") as f:
return int(f.read().strip())
except (ValueError, IOError):
return None
def daemonize():
"""
Fork the current process into a background daemon.
Uses double-fork technique to properly detach from terminal.
"""
# First fork
pid = os.fork()
if pid > 0:
# Parent exits
sys.exit(0)
# Create new session
os.setsid()
# Second fork to prevent zombie processes
pid = os.fork()
if pid > 0:
sys.exit(0)
# Redirect standard file descriptors to log file
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
sys.stdout.flush()
sys.stderr.flush()
# Redirect stdin to /dev/null
with open("/dev/null", "r") as devnull:
os.dup2(devnull.fileno(), sys.stdin.fileno())
# Redirect stdout/stderr to log file
log_fd = open(DAEMON_LOG_PATH, "a")
os.dup2(log_fd.fileno(), sys.stdout.fileno())
os.dup2(log_fd.fileno(), sys.stderr.fileno())
def check_daemon_running(port: int = DEFAULT_DAEMON_PORT) -> bool:
"""Check if a daemon is running and responsive on the given port."""
import socket
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1)
result = sock.connect_ex(("127.0.0.1", port))
sock.close()
return result == 0
except Exception:
return False
def stop_daemon(port: int = DEFAULT_DAEMON_PORT) -> bool:
"""Stop a running daemon by sending SIGTERM to the process."""
lock = DaemonLock()
pid = lock.get_pid()
if pid is None:
return False
try:
import signal
os.kill(pid, signal.SIGTERM)
# Wait for process to exit
for _ in range(50): # Wait up to 5 seconds
time.sleep(0.1)
try:
os.kill(pid, 0) # Check if process exists
except OSError:
return True # Process exited
return False
except OSError:
return False
+20 -9
View File
@@ -7,24 +7,30 @@ This package contains all the implementation details of the memory engine:
- Supporting modules: embeddings, cross_encoder, entity_resolver, etc.
"""
from .memory_engine import MemoryEngine
from .cross_encoder import CrossEncoderModel, LocalSTCrossEncoder, RemoteTEICrossEncoder
from .db_utils import acquire_with_retry
from .embeddings import Embeddings, LocalSTEmbeddings, RemoteTEIEmbeddings
from .cross_encoder import CrossEncoderModel, LocalSTCrossEncoder, RemoteTEICrossEncoder
from .llm_wrapper import LLMConfig
from .memory_engine import (
MemoryEngine,
UnqualifiedTableError,
fq_table,
get_current_schema,
validate_sql_schema,
)
from .response_models import MemoryFact, RecallResult, ReflectResult
from .search.trace import (
SearchTrace,
QueryInfo,
EntryPoint,
NodeVisit,
WeightComponents,
LinkInfo,
NodeVisit,
PruningDecision,
SearchSummary,
QueryInfo,
SearchPhaseMetrics,
SearchSummary,
SearchTrace,
WeightComponents,
)
from .search.tracer import SearchTracer
from .llm_wrapper import LLMConfig
from .response_models import RecallResult, ReflectResult, MemoryFact
__all__ = [
"MemoryEngine",
@@ -49,4 +55,9 @@ __all__ = [
"RecallResult",
"ReflectResult",
"MemoryFact",
# Schema safety utilities
"fq_table",
"get_current_schema",
"validate_sql_schema",
"UnqualifiedTableError",
]
@@ -5,19 +5,19 @@ Provides an interface for reranking with different backends.
Configuration via environment variables - see hindsight_api.config for all env var names.
"""
from abc import ABC, abstractmethod
from typing import List, Tuple, Optional
import logging
import os
from abc import ABC, abstractmethod
import httpx
from ..config import (
ENV_RERANKER_PROVIDER,
ENV_RERANKER_LOCAL_MODEL,
ENV_RERANKER_TEI_URL,
DEFAULT_RERANKER_PROVIDER,
DEFAULT_RERANKER_LOCAL_MODEL,
DEFAULT_RERANKER_PROVIDER,
ENV_RERANKER_LOCAL_MODEL,
ENV_RERANKER_PROVIDER,
ENV_RERANKER_TEI_URL,
)
logger = logging.getLogger(__name__)
@@ -47,7 +47,7 @@ class CrossEncoderModel(ABC):
pass
@abstractmethod
def predict(self, pairs: List[Tuple[str, str]]) -> List[float]:
def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Score query-document pairs for relevance.
@@ -72,7 +72,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
- Trained for passage re-ranking
"""
def __init__(self, model_name: Optional[str] = None):
def __init__(self, model_name: str | None = None):
"""
Initialize local SentenceTransformers cross-encoder.
@@ -104,7 +104,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
self._model = CrossEncoder(self.model_name)
logger.info("Reranker: local provider initialized")
def predict(self, pairs: List[Tuple[str, str]]) -> List[float]:
def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Score query-document pairs for relevance.
@@ -117,7 +117,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
if self._model is None:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
scores = self._model.predict(pairs, show_progress_bar=False)
return scores.tolist() if hasattr(scores, 'tolist') else list(scores)
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
class RemoteTEICrossEncoder(CrossEncoderModel):
@@ -153,8 +153,8 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
self.batch_size = batch_size
self.max_retries = max_retries
self.retry_delay = retry_delay
self._client: Optional[httpx.Client] = None
self._model_id: Optional[str] = None
self._client: httpx.Client | None = None
self._model_id: str | None = None
@property
def provider_name(self) -> str:
@@ -163,6 +163,7 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response:
"""Make an HTTP request with automatic retries on transient errors."""
import time
last_error = None
delay = self.retry_delay
@@ -177,14 +178,18 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as e:
last_error = e
if attempt < self.max_retries:
logger.warning(f"TEI request failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s...")
logger.warning(
f"TEI request failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s..."
)
time.sleep(delay)
delay *= 2 # Exponential backoff
except httpx.HTTPStatusError as e:
# Retry on 5xx server errors
if e.response.status_code >= 500 and attempt < self.max_retries:
last_error = e
logger.warning(f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s...")
logger.warning(
f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s..."
)
time.sleep(delay)
delay *= 2
else:
@@ -209,7 +214,7 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
except httpx.HTTPError as e:
raise RuntimeError(f"Failed to connect to TEI server at {self.base_url}: {e}")
def predict(self, pairs: List[Tuple[str, str]]) -> List[float]:
def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Score query-document pairs using the remote TEI reranker.
@@ -229,7 +234,7 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
# Process in batches
for i in range(0, len(pairs), self.batch_size):
batch = pairs[i:i + self.batch_size]
batch = pairs[i : i + self.batch_size]
# TEI rerank endpoint expects query and texts separately
# All pairs in a batch should have the same query for optimal performance
@@ -287,15 +292,11 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
if provider == "tei":
url = os.environ.get(ENV_RERANKER_TEI_URL)
if not url:
raise ValueError(
f"{ENV_RERANKER_TEI_URL} is required when {ENV_RERANKER_PROVIDER} is 'tei'"
)
raise ValueError(f"{ENV_RERANKER_TEI_URL} is required when {ENV_RERANKER_PROVIDER} is 'tei'")
return RemoteTEICrossEncoder(base_url=url)
elif provider == "local":
model = os.environ.get(ENV_RERANKER_LOCAL_MODEL)
model_name = model or DEFAULT_RERANKER_LOCAL_MODEL
return LocalSTCrossEncoder(model_name=model_name)
else:
raise ValueError(
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei'"
)
raise ValueError(f"Unknown reranker provider: {provider}. Supported: 'local', 'tei'")
@@ -1,9 +1,11 @@
"""
Database utility functions for connection management with retry logic.
"""
import asyncio
import logging
from contextlib import asynccontextmanager
import asyncpg
logger = logging.getLogger(__name__)
@@ -54,16 +56,14 @@ async def retry_with_backoff(
except retryable_exceptions as e:
last_exception = e
if attempt < max_retries:
delay = min(base_delay * (2 ** attempt), max_delay)
delay = min(base_delay * (2**attempt), max_delay)
logger.warning(
f"Database operation failed (attempt {attempt + 1}/{max_retries + 1}): {e}. "
f"Retrying in {delay:.1f}s..."
)
await asyncio.sleep(delay)
else:
logger.error(
f"Database operation failed after {max_retries + 1} attempts: {e}"
)
logger.error(f"Database operation failed after {max_retries + 1} attempts: {e}")
raise last_exception
@@ -83,6 +83,7 @@ async def acquire_with_retry(pool: asyncpg.Pool, max_retries: int = DEFAULT_MAX_
Yields:
An asyncpg connection
"""
async def acquire():
return await pool.acquire()
+197 -46
View File
@@ -3,25 +3,28 @@ Embeddings abstraction for the memory system.
Provides an interface for generating embeddings with different backends.
IMPORTANT: All embeddings must produce 384-dimensional vectors to match
the database schema (pgvector column defined as vector(384)).
The embedding dimension is auto-detected from the model at initialization.
The database schema is automatically adjusted to match the model's dimension.
Configuration via environment variables - see hindsight_api.config for all env var names.
"""
from abc import ABC, abstractmethod
from typing import List, Optional
import logging
import os
from abc import ABC, abstractmethod
import httpx
from ..config import (
ENV_EMBEDDINGS_PROVIDER,
ENV_EMBEDDINGS_LOCAL_MODEL,
ENV_EMBEDDINGS_TEI_URL,
DEFAULT_EMBEDDINGS_PROVIDER,
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
EMBEDDING_DIMENSION,
DEFAULT_EMBEDDINGS_OPENAI_MODEL,
DEFAULT_EMBEDDINGS_PROVIDER,
ENV_EMBEDDINGS_LOCAL_MODEL,
ENV_EMBEDDINGS_OPENAI_API_KEY,
ENV_EMBEDDINGS_OPENAI_MODEL,
ENV_EMBEDDINGS_PROVIDER,
ENV_EMBEDDINGS_TEI_URL,
ENV_LLM_API_KEY,
)
logger = logging.getLogger(__name__)
@@ -31,8 +34,8 @@ class Embeddings(ABC):
"""
Abstract base class for embedding generation.
All implementations MUST generate 384-dimensional embeddings to match
the database schema.
The embedding dimension is determined by the model and detected at initialization.
The database schema is automatically adjusted to match the model's dimension.
"""
@property
@@ -41,6 +44,12 @@ class Embeddings(ABC):
"""Return a human-readable name for this provider (e.g., 'local', 'tei')."""
pass
@property
@abstractmethod
def dimension(self) -> int:
"""Return the embedding dimension produced by this model."""
pass
@abstractmethod
async def initialize(self) -> None:
"""
@@ -52,15 +61,15 @@ class Embeddings(ABC):
pass
@abstractmethod
def encode(self, texts: List[str]) -> List[List[float]]:
def encode(self, texts: list[str]) -> list[list[float]]:
"""
Generate 384-dimensional embeddings for a list of texts.
Generate embeddings for a list of texts.
Args:
texts: List of text strings to encode
Returns:
List of 384-dimensional embedding vectors (each is a list of floats)
List of embedding vectors (each is a list of floats)
"""
pass
@@ -70,27 +79,31 @@ class LocalSTEmbeddings(Embeddings):
Local embeddings implementation using SentenceTransformers.
Call initialize() during startup to load the model and avoid cold starts.
Default model is BAAI/bge-small-en-v1.5 which produces 384-dimensional
embeddings matching the database schema.
The embedding dimension is auto-detected from the model.
"""
def __init__(self, model_name: Optional[str] = None):
def __init__(self, model_name: str | None = None):
"""
Initialize local SentenceTransformers embeddings.
Args:
model_name: Name of the SentenceTransformer model to use.
Must produce 384-dimensional embeddings.
Default: BAAI/bge-small-en-v1.5
"""
self.model_name = model_name or DEFAULT_EMBEDDINGS_LOCAL_MODEL
self._model = None
self._dimension: int | None = None
@property
def provider_name(self) -> str:
return "local"
@property
def dimension(self) -> int:
if self._dimension is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
return self._dimension
async def initialize(self) -> None:
"""Load the embedding model."""
if self._model is not None:
@@ -112,26 +125,18 @@ class LocalSTEmbeddings(Embeddings):
model_kwargs={"low_cpu_mem_usage": False, "device_map": None},
)
# Validate dimension matches database schema
model_dim = self._model.get_sentence_embedding_dimension()
if model_dim != EMBEDDING_DIMENSION:
raise ValueError(
f"Model {self.model_name} produces {model_dim}-dimensional embeddings, "
f"but database schema requires {EMBEDDING_DIMENSION} dimensions. "
f"Use a model that produces {EMBEDDING_DIMENSION}-dimensional embeddings."
)
self._dimension = self._model.get_sentence_embedding_dimension()
logger.info(f"Embeddings: local provider initialized (dim: {self._dimension})")
logger.info(f"Embeddings: local provider initialized (dim: {model_dim})")
def encode(self, texts: List[str]) -> List[List[float]]:
def encode(self, texts: list[str]) -> list[list[float]]:
"""
Generate 384-dimensional embeddings for a list of texts.
Generate embeddings for a list of texts.
Args:
texts: List of text strings to encode
Returns:
List of 384-dimensional embedding vectors
List of embedding vectors
"""
if self._model is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
@@ -146,7 +151,7 @@ class RemoteTEIEmbeddings(Embeddings):
TEI provides a high-performance inference server for embedding models.
See: https://github.com/huggingface/text-embeddings-inference
The server should be running a model that produces 384-dimensional embeddings.
The embedding dimension is auto-detected from the server at initialization.
"""
def __init__(
@@ -172,16 +177,24 @@ class RemoteTEIEmbeddings(Embeddings):
self.batch_size = batch_size
self.max_retries = max_retries
self.retry_delay = retry_delay
self._client: Optional[httpx.Client] = None
self._model_id: Optional[str] = None
self._client: httpx.Client | None = None
self._model_id: str | None = None
self._dimension: int | None = None
@property
def provider_name(self) -> str:
return "tei"
@property
def dimension(self) -> int:
if self._dimension is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
return self._dimension
def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response:
"""Make an HTTP request with automatic retries on transient errors."""
import time
last_error = None
delay = self.retry_delay
@@ -196,14 +209,18 @@ class RemoteTEIEmbeddings(Embeddings):
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as e:
last_error = e
if attempt < self.max_retries:
logger.warning(f"TEI request failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s...")
logger.warning(
f"TEI request failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s..."
)
time.sleep(delay)
delay *= 2 # Exponential backoff
except httpx.HTTPStatusError as e:
# Retry on 5xx server errors
if e.response.status_code >= 500 and attempt < self.max_retries:
last_error = e
logger.warning(f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s...")
logger.warning(
f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s..."
)
time.sleep(delay)
delay *= 2
else:
@@ -224,11 +241,28 @@ class RemoteTEIEmbeddings(Embeddings):
response = self._request_with_retry("GET", f"{self.base_url}/info")
info = response.json()
self._model_id = info.get("model_id", "unknown")
logger.info(f"Embeddings: TEI provider initialized (model: {self._model_id})")
# Get dimension from server info or by doing a test embedding
if "max_input_length" in info and "model_dtype" in info:
# Try to get dimension from info endpoint (some TEI versions expose it)
# If not available, do a test embedding
pass
# Do a test embedding to detect dimension
test_response = self._request_with_retry(
"POST",
f"{self.base_url}/embed",
json={"inputs": ["test"]},
)
test_embeddings = test_response.json()
if test_embeddings and len(test_embeddings) > 0:
self._dimension = len(test_embeddings[0])
logger.info(f"Embeddings: TEI provider initialized (model: {self._model_id}, dim: {self._dimension})")
except httpx.HTTPError as e:
raise RuntimeError(f"Failed to connect to TEI server at {self.base_url}: {e}")
def encode(self, texts: List[str]) -> List[List[float]]:
def encode(self, texts: list[str]) -> list[list[float]]:
"""
Generate embeddings using the remote TEI server.
@@ -248,7 +282,7 @@ class RemoteTEIEmbeddings(Embeddings):
# Process in batches
for i in range(0, len(texts), self.batch_size):
batch = texts[i:i + self.batch_size]
batch = texts[i : i + self.batch_size]
try:
response = self._request_with_retry(
@@ -264,6 +298,117 @@ class RemoteTEIEmbeddings(Embeddings):
return all_embeddings
class OpenAIEmbeddings(Embeddings):
"""
OpenAI embeddings implementation using the OpenAI API.
Supports text-embedding-3-small (1536 dims), text-embedding-3-large (3072 dims),
and text-embedding-ada-002 (1536 dims, legacy).
The embedding dimension is auto-detected from the model at initialization.
"""
# Known dimensions for OpenAI embedding models
MODEL_DIMENSIONS = {
"text-embedding-3-small": 1536,
"text-embedding-3-large": 3072,
"text-embedding-ada-002": 1536,
}
def __init__(
self,
api_key: str,
model: str = DEFAULT_EMBEDDINGS_OPENAI_MODEL,
batch_size: int = 100,
max_retries: int = 3,
):
"""
Initialize OpenAI embeddings client.
Args:
api_key: OpenAI API key
model: OpenAI embedding model name (default: text-embedding-3-small)
batch_size: Maximum batch size for embedding requests (default: 100)
max_retries: Maximum number of retries for failed requests (default: 3)
"""
self.api_key = api_key
self.model = model
self.batch_size = batch_size
self.max_retries = max_retries
self._client = None
self._dimension: int | None = None
@property
def provider_name(self) -> str:
return "openai"
@property
def dimension(self) -> int:
if self._dimension is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
return self._dimension
async def initialize(self) -> None:
"""Initialize the OpenAI client and detect dimension."""
if self._client is not None:
return
try:
from openai import OpenAI
except ImportError:
raise ImportError("openai is required for OpenAIEmbeddings. Install it with: pip install openai")
logger.info(f"Embeddings: initializing OpenAI provider with model {self.model}")
self._client = OpenAI(api_key=self.api_key, max_retries=self.max_retries)
# Try to get dimension from known models, otherwise do a test embedding
if self.model in self.MODEL_DIMENSIONS:
self._dimension = self.MODEL_DIMENSIONS[self.model]
else:
# Do a test embedding to detect dimension
response = self._client.embeddings.create(
model=self.model,
input=["test"],
)
if response.data:
self._dimension = len(response.data[0].embedding)
logger.info(f"Embeddings: OpenAI provider initialized (model: {self.model}, dim: {self._dimension})")
def encode(self, texts: list[str]) -> list[list[float]]:
"""
Generate embeddings using the OpenAI API.
Args:
texts: List of text strings to encode
Returns:
List of embedding vectors
"""
if self._client is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
if not texts:
return []
all_embeddings = []
# Process in batches
for i in range(0, len(texts), self.batch_size):
batch = texts[i : i + self.batch_size]
response = self._client.embeddings.create(
model=self.model,
input=batch,
)
# Sort by index to ensure correct order
batch_embeddings = sorted(response.data, key=lambda x: x.index)
all_embeddings.extend([e.embedding for e in batch_embeddings])
return all_embeddings
def create_embeddings_from_env() -> Embeddings:
"""
Create an Embeddings instance based on environment variables.
@@ -278,15 +423,21 @@ def create_embeddings_from_env() -> Embeddings:
if provider == "tei":
url = os.environ.get(ENV_EMBEDDINGS_TEI_URL)
if not url:
raise ValueError(
f"{ENV_EMBEDDINGS_TEI_URL} is required when {ENV_EMBEDDINGS_PROVIDER} is 'tei'"
)
raise ValueError(f"{ENV_EMBEDDINGS_TEI_URL} is required when {ENV_EMBEDDINGS_PROVIDER} is 'tei'")
return RemoteTEIEmbeddings(base_url=url)
elif provider == "local":
model = os.environ.get(ENV_EMBEDDINGS_LOCAL_MODEL)
model_name = model or DEFAULT_EMBEDDINGS_LOCAL_MODEL
return LocalSTEmbeddings(model_name=model_name)
elif provider == "openai":
# Use dedicated embeddings API key, or fall back to LLM API key
api_key = os.environ.get(ENV_EMBEDDINGS_OPENAI_API_KEY) or os.environ.get(ENV_LLM_API_KEY)
if not api_key:
raise ValueError(
f"{ENV_EMBEDDINGS_OPENAI_API_KEY} or {ENV_LLM_API_KEY} is required "
f"when {ENV_EMBEDDINGS_PROVIDER} is 'openai'"
)
model = os.environ.get(ENV_EMBEDDINGS_OPENAI_MODEL, DEFAULT_EMBEDDINGS_OPENAI_MODEL)
return OpenAIEmbeddings(api_key=api_key, model=model)
else:
raise ValueError(
f"Unknown embeddings provider: {provider}. Supported: 'local', 'tei'"
)
raise ValueError(f"Unknown embeddings provider: {provider}. Supported: 'local', 'tei', 'openai'")
@@ -4,12 +4,14 @@ Entity extraction and resolution for memory system.
Uses spaCy for entity extraction and implements resolution logic
to disambiguate entities across memory units.
"""
import asyncpg
from typing import List, Dict, Optional, Set, Any
from difflib import SequenceMatcher
from datetime import datetime, timezone
from .db_utils import acquire_with_retry
from datetime import UTC, datetime
from difflib import SequenceMatcher
import asyncpg
from .db_utils import acquire_with_retry
from .memory_engine import fq_table
# Load spaCy model (singleton)
_nlp = None
@@ -32,11 +34,11 @@ class EntityResolver:
async def resolve_entities_batch(
self,
bank_id: str,
entities_data: List[Dict],
entities_data: list[dict],
context: str,
unit_event_date,
conn=None,
) -> List[str]:
) -> list[str]:
"""
Resolve multiple entities in batch (MUCH faster than sequential).
@@ -62,36 +64,38 @@ class EntityResolver:
else:
return await self._resolve_entities_batch_impl(conn, bank_id, entities_data, context, unit_event_date)
async def _resolve_entities_batch_impl(self, conn, bank_id: str, entities_data: List[Dict], context: str, unit_event_date) -> List[str]:
async def _resolve_entities_batch_impl(
self, conn, bank_id: str, entities_data: list[dict], context: str, unit_event_date
) -> list[str]:
# Query ALL candidates for this bank
all_entities = await conn.fetch(
"""
f"""
SELECT canonical_name, id, metadata, last_seen, mention_count
FROM entities
FROM {fq_table("entities")}
WHERE bank_id = $1
""",
bank_id
bank_id,
)
# Build entity ID to name mapping for co-occurrence lookups
entity_id_to_name = {row['id']: row['canonical_name'].lower() for row in all_entities}
entity_id_to_name = {row["id"]: row["canonical_name"].lower() for row in all_entities}
# Query ALL co-occurrences for this bank's entities in one query
# This builds a map of entity_id -> set of co-occurring entity names
all_cooccurrences = await conn.fetch(
"""
f"""
SELECT ec.entity_id_1, ec.entity_id_2, ec.cooccurrence_count
FROM entity_cooccurrences ec
WHERE ec.entity_id_1 IN (SELECT id FROM entities WHERE bank_id = $1)
OR ec.entity_id_2 IN (SELECT id FROM entities WHERE bank_id = $1)
FROM {fq_table("entity_cooccurrences")} ec
WHERE ec.entity_id_1 IN (SELECT id FROM {fq_table("entities")} WHERE bank_id = $1)
OR ec.entity_id_2 IN (SELECT id FROM {fq_table("entities")} WHERE bank_id = $1)
""",
bank_id
bank_id,
)
# Build co-occurrence map: entity_id -> set of co-occurring entity names (lowercase)
cooccurrence_map: Dict[str, Set[str]] = {}
cooccurrence_map: dict[str, set[str]] = {}
for row in all_cooccurrences:
eid1, eid2 = row['entity_id_1'], row['entity_id_2']
eid1, eid2 = row["entity_id_1"], row["entity_id_2"]
# Add both directions
if eid1 not in cooccurrence_map:
cooccurrence_map[eid1] = set()
@@ -105,22 +109,24 @@ class EntityResolver:
# Build candidate map for each entity text
all_candidates = {} # Maps entity_text -> list of candidates
entity_texts = list(set(e['text'] for e in entities_data))
entity_texts = list(set(e["text"] for e in entities_data))
for entity_text in entity_texts:
matching = []
entity_text_lower = entity_text.lower()
for row in all_entities:
canonical_name = row['canonical_name']
ent_id = row['id']
metadata = row['metadata']
last_seen = row['last_seen']
mention_count = row['mention_count']
canonical_name = row["canonical_name"]
ent_id = row["id"]
metadata = row["metadata"]
last_seen = row["last_seen"]
mention_count = row["mention_count"]
canonical_lower = canonical_name.lower()
# Match if exact or substring match
if (entity_text_lower == canonical_lower or
entity_text_lower in canonical_lower or
canonical_lower in entity_text_lower):
if (
entity_text_lower == canonical_lower
or entity_text_lower in canonical_lower
or canonical_lower in entity_text_lower
):
matching.append((ent_id, canonical_name, metadata, last_seen, mention_count))
all_candidates[entity_text] = matching
@@ -130,10 +136,10 @@ class EntityResolver:
entities_to_create = [] # (idx, entity_data, event_date)
for idx, entity_data in enumerate(entities_data):
entity_text = entity_data['text']
nearby_entities = entity_data.get('nearby_entities', [])
entity_text = entity_data["text"]
nearby_entities = entity_data.get("nearby_entities", [])
# Use per-entity date if available, otherwise fall back to batch-level date
entity_event_date = entity_data.get('event_date', unit_event_date)
entity_event_date = entity_data.get("event_date", unit_event_date)
candidates = all_candidates.get(entity_text, [])
@@ -146,17 +152,13 @@ class EntityResolver:
best_candidate = None
best_score = 0.0
nearby_entity_set = {e['text'].lower() for e in nearby_entities if e['text'] != entity_text}
nearby_entity_set = {e["text"].lower() for e in nearby_entities if e["text"] != entity_text}
for candidate_id, canonical_name, metadata, last_seen, mention_count in candidates:
score = 0.0
# 1. Name similarity (0-0.5)
name_similarity = SequenceMatcher(
None,
entity_text.lower(),
canonical_name.lower()
).ratio()
name_similarity = SequenceMatcher(None, entity_text.lower(), canonical_name.lower()).ratio()
score += name_similarity * 0.5
# 2. Co-occurring entities (0-0.3)
@@ -169,8 +171,10 @@ class EntityResolver:
# 3. Temporal proximity (0-0.2)
if last_seen and entity_event_date:
# Normalize timezone awareness for comparison
event_date_utc = entity_event_date if entity_event_date.tzinfo else entity_event_date.replace(tzinfo=timezone.utc)
last_seen_utc = last_seen if last_seen.tzinfo else last_seen.replace(tzinfo=timezone.utc)
event_date_utc = (
entity_event_date if entity_event_date.tzinfo else entity_event_date.replace(tzinfo=UTC)
)
last_seen_utc = last_seen if last_seen.tzinfo else last_seen.replace(tzinfo=UTC)
days_diff = abs((event_date_utc - last_seen_utc).total_seconds() / 86400)
if days_diff < 7:
temporal_score = max(0, 1.0 - (days_diff / 7))
@@ -192,13 +196,13 @@ class EntityResolver:
# Batch update existing entities
if entities_to_update:
await conn.executemany(
"""
UPDATE entities SET
f"""
UPDATE {fq_table("entities")} SET
mention_count = mention_count + 1,
last_seen = $2
WHERE id = $1::uuid
""",
entities_to_update
entities_to_update,
)
# Batch create new entities using COPY + INSERT for maximum speed
@@ -208,7 +212,7 @@ class EntityResolver:
# For duplicates, we only insert once and reuse the ID
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()
name_lower = entity_data["text"].lower()
if name_lower not in unique_entities:
unique_entities[name_lower] = (entity_data, event_date, [idx])
else:
@@ -222,31 +226,31 @@ class EntityResolver:
indices_map = [] # Maps result index -> list of original indices
for name_lower, (entity_data, event_date, indices) in unique_entities.items():
entity_names.append(entity_data['text'])
entity_names.append(entity_data["text"])
entity_dates.append(event_date)
indices_map.append(indices)
# Batch INSERT ... ON CONFLICT with RETURNING
# This is much faster than individual inserts
rows = await conn.fetch(
"""
INSERT INTO entities (bank_id, canonical_name, first_seen, last_seen, mention_count)
f"""
INSERT INTO {fq_table("entities")} (bank_id, canonical_name, first_seen, last_seen, mention_count)
SELECT $1, name, event_date, event_date, 1
FROM unnest($2::text[], $3::timestamptz[]) AS t(name, event_date)
ON CONFLICT (bank_id, LOWER(canonical_name))
DO UPDATE SET
mention_count = entities.mention_count + 1,
mention_count = {fq_table("entities")}.mention_count + 1,
last_seen = EXCLUDED.last_seen
RETURNING id
""",
bank_id,
entity_names,
entity_dates
entity_dates,
)
# Map returned IDs back to original indices
for result_idx, row in enumerate(rows):
entity_id = row['id']
entity_id = row["id"]
for original_idx in indices_map[result_idx]:
entity_ids[original_idx] = entity_id
@@ -257,7 +261,7 @@ class EntityResolver:
bank_id: str,
entity_text: str,
context: str,
nearby_entities: List[Dict],
nearby_entities: list[dict],
unit_event_date,
) -> str:
"""
@@ -276,9 +280,9 @@ class EntityResolver:
async with acquire_with_retry(self.pool) as conn:
# Find candidate entities with similar name
candidates = await conn.fetch(
"""
f"""
SELECT id, canonical_name, metadata, last_seen
FROM entities
FROM {fq_table("entities")}
WHERE bank_id = $1
AND (
canonical_name ILIKE $2
@@ -287,14 +291,14 @@ class EntityResolver:
)
ORDER BY mention_count DESC
""",
bank_id, entity_text, f"%{entity_text}%"
bank_id,
entity_text,
f"%{entity_text}%",
)
if not candidates:
# New entity - create it
return await self._create_entity(
conn, bank_id, entity_text, unit_event_date
)
return await self._create_entity(conn, bank_id, entity_text, unit_event_date)
# Score candidates based on:
# 1. Name similarity
@@ -306,31 +310,27 @@ class EntityResolver:
best_score = 0.0
best_name_similarity = 0.0
nearby_entity_set = {e['text'].lower() for e in nearby_entities if e['text'] != entity_text}
nearby_entity_set = {e["text"].lower() for e in nearby_entities if e["text"] != entity_text}
for row in candidates:
candidate_id = row['id']
canonical_name = row['canonical_name']
metadata = row['metadata']
last_seen = row['last_seen']
candidate_id = row["id"]
canonical_name = row["canonical_name"]
metadata = row["metadata"]
last_seen = row["last_seen"]
score = 0.0
# 1. Name similarity (0-1)
name_similarity = SequenceMatcher(
None,
entity_text.lower(),
canonical_name.lower()
).ratio()
name_similarity = SequenceMatcher(None, entity_text.lower(), canonical_name.lower()).ratio()
score += name_similarity * 0.5
# 2. Co-occurring entities (0-0.5)
# Get entities that co-occurred with this candidate before
# Use the materialized co-occurrence cache for fast lookup
co_entity_rows = await conn.fetch(
"""
f"""
SELECT e.canonical_name, ec.cooccurrence_count
FROM entity_cooccurrences ec
JOIN entities e ON (
FROM {fq_table("entity_cooccurrences")} ec
JOIN {fq_table("entities")} e ON (
CASE
WHEN ec.entity_id_1 = $1 THEN ec.entity_id_2
WHEN ec.entity_id_2 = $1 THEN ec.entity_id_1
@@ -338,9 +338,9 @@ class EntityResolver:
)
WHERE ec.entity_id_1 = $1 OR ec.entity_id_2 = $1
""",
candidate_id
candidate_id,
)
co_entities = {r['canonical_name'].lower() for r in co_entity_rows}
co_entities = {r["canonical_name"].lower() for r in co_entity_rows}
# Check overlap with nearby entities
overlap = len(nearby_entity_set & co_entities)
@@ -366,20 +366,19 @@ class EntityResolver:
if best_score > threshold:
# Update entity
await conn.execute(
"""
UPDATE entities
f"""
UPDATE {fq_table("entities")}
SET mention_count = mention_count + 1,
last_seen = $1
WHERE id = $2
""",
unit_event_date, best_candidate
unit_event_date,
best_candidate,
)
return best_candidate
else:
# Not confident - create new entity
return await self._create_entity(
conn, bank_id, entity_text, unit_event_date
)
return await self._create_entity(conn, bank_id, entity_text, unit_event_date)
async def _create_entity(
self,
@@ -404,16 +403,19 @@ class EntityResolver:
Entity ID
"""
entity_id = await conn.fetchval(
"""
INSERT INTO entities (bank_id, canonical_name, first_seen, last_seen, mention_count)
f"""
INSERT INTO {fq_table("entities")} (bank_id, canonical_name, first_seen, last_seen, mention_count)
VALUES ($1, $2, $3, $4, 1)
ON CONFLICT (bank_id, LOWER(canonical_name))
DO UPDATE SET
mention_count = entities.mention_count + 1,
mention_count = {fq_table("entities")}.mention_count + 1,
last_seen = EXCLUDED.last_seen
RETURNING id
""",
bank_id, entity_text, event_date, event_date
bank_id,
entity_text,
event_date,
event_date,
)
return entity_id
@@ -429,25 +431,27 @@ class EntityResolver:
async with acquire_with_retry(self.pool) as conn:
# Insert unit-entity link
await conn.execute(
"""
INSERT INTO unit_entities (unit_id, entity_id)
f"""
INSERT INTO {fq_table("unit_entities")} (unit_id, entity_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
""",
unit_id, entity_id
unit_id,
entity_id,
)
# Update co-occurrence cache: find other entities in this unit
rows = await conn.fetch(
"""
f"""
SELECT entity_id
FROM unit_entities
FROM {fq_table("unit_entities")}
WHERE unit_id = $1 AND entity_id != $2
""",
unit_id, entity_id
unit_id,
entity_id,
)
other_entities = [row['entity_id'] for row in rows]
other_entities = [row["entity_id"] for row in rows]
# Update co-occurrences for each pair
for other_entity_id in other_entities:
@@ -469,18 +473,19 @@ class EntityResolver:
entity_id_1, entity_id_2 = entity_id_2, entity_id_1
await conn.execute(
"""
INSERT INTO entity_cooccurrences (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
f"""
INSERT INTO {fq_table("entity_cooccurrences")} (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
VALUES ($1, $2, 1, NOW())
ON CONFLICT (entity_id_1, entity_id_2)
DO UPDATE SET
cooccurrence_count = entity_cooccurrences.cooccurrence_count + 1,
cooccurrence_count = {fq_table("entity_cooccurrences")}.cooccurrence_count + 1,
last_cooccurred = NOW()
""",
entity_id_1, entity_id_2
entity_id_1,
entity_id_2,
)
async def link_units_to_entities_batch(self, unit_entity_pairs: List[tuple[str, str]], conn=None):
async def link_units_to_entities_batch(self, unit_entity_pairs: list[tuple[str, str]], conn=None):
"""
Link multiple memory units to entities in batch (MUCH faster than sequential).
@@ -499,15 +504,15 @@ class EntityResolver:
else:
return await self._link_units_to_entities_batch_impl(conn, unit_entity_pairs)
async def _link_units_to_entities_batch_impl(self, conn, unit_entity_pairs: List[tuple[str, str]]):
async def _link_units_to_entities_batch_impl(self, conn, unit_entity_pairs: list[tuple[str, str]]):
# Batch insert all unit-entity links
await conn.executemany(
"""
INSERT INTO unit_entities (unit_id, entity_id)
f"""
INSERT INTO {fq_table("unit_entities")} (unit_id, entity_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
""",
unit_entity_pairs
unit_entity_pairs,
)
# Build map of unit -> entities for co-occurrence calculation
@@ -524,7 +529,7 @@ class EntityResolver:
entity_list = list(entity_ids) # Convert set to list for iteration
# For each pair of entities in this unit, create co-occurrence
for i, entity_id_1 in enumerate(entity_list):
for entity_id_2 in entity_list[i+1:]:
for entity_id_2 in entity_list[i + 1 :]:
# Skip if same entity (shouldn't happen with set, but be safe)
if entity_id_1 == entity_id_2:
continue
@@ -535,20 +540,20 @@ class EntityResolver:
# Batch update co-occurrences
if cooccurrence_pairs:
now = datetime.now(timezone.utc)
now = datetime.now(UTC)
await conn.executemany(
"""
INSERT INTO entity_cooccurrences (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
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 = entity_cooccurrences.cooccurrence_count + 1,
cooccurrence_count = {fq_table("entity_cooccurrences")}.cooccurrence_count + 1,
last_cooccurred = EXCLUDED.last_cooccurred
""",
[(e1, e2, 1, now) for e1, e2 in cooccurrence_pairs]
[(e1, e2, 1, now) for e1, e2 in cooccurrence_pairs],
)
async def get_units_by_entity(self, entity_id: str, limit: int = 100) -> List[str]:
async def get_units_by_entity(self, entity_id: str, limit: int = 100) -> list[str]:
"""
Get all units that mention an entity.
@@ -561,22 +566,23 @@ class EntityResolver:
"""
async with acquire_with_retry(self.pool) as conn:
rows = await conn.fetch(
"""
f"""
SELECT unit_id
FROM unit_entities
FROM {fq_table("unit_entities")}
WHERE entity_id = $1
ORDER BY unit_id
LIMIT $2
""",
entity_id, limit
entity_id,
limit,
)
return [row['unit_id'] for row in rows]
return [row["unit_id"] for row in rows]
async def get_entity_by_text(
self,
bank_id: str,
entity_text: str,
) -> Optional[str]:
) -> str | None:
"""
Find an entity by text (for query resolution).
@@ -589,14 +595,15 @@ class EntityResolver:
"""
async with acquire_with_retry(self.pool) as conn:
row = await conn.fetchrow(
"""
SELECT id FROM entities
f"""
SELECT id FROM {fq_table("entities")}
WHERE bank_id = $1
AND canonical_name ILIKE $2
ORDER BY mention_count DESC
LIMIT 1
""",
bank_id, entity_text
bank_id,
entity_text,
)
return row['id'] if row else None
return row["id"] if row else None
@@ -0,0 +1,598 @@
"""Abstract interface for MemoryEngine public methods.
This module defines the public API that HTTP endpoints and extensions should use
to interact with the memory system. All methods require a RequestContext for
authentication when a TenantExtension is configured.
"""
from abc import ABC, abstractmethod
from datetime import datetime
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.models import RequestContext
class MemoryEngineInterface(ABC):
"""
Abstract interface for the Memory Engine.
This defines the public API that should be used by HTTP endpoints and extensions.
All methods require a RequestContext for authentication.
"""
# =========================================================================
# Health & Status
# =========================================================================
@abstractmethod
async def health_check(self) -> dict:
"""
Check the health of the memory system.
Returns:
Dict with 'status' key ('healthy' or 'unhealthy') and additional info.
"""
...
# =========================================================================
# Core Memory Operations
# =========================================================================
@abstractmethod
async def retain_batch_async(
self,
bank_id: str,
contents: list[dict[str, Any]],
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Retain a batch of memory items.
Args:
bank_id: The memory bank ID.
contents: List of content dicts with 'content', optional 'event_date',
'context', 'metadata', 'document_id'.
request_context: Request context for authentication.
Returns:
Dict with processing results.
"""
...
@abstractmethod
async def recall_async(
self,
bank_id: str,
query: str,
*,
budget: "Budget | None" = None,
max_tokens: int = 4096,
enable_trace: bool = False,
fact_type: list[str] | None = None,
question_date: datetime | None = None,
include_entities: bool = False,
max_entity_tokens: int = 500,
include_chunks: bool = False,
max_chunk_tokens: int = 8192,
request_context: "RequestContext",
) -> "RecallResult":
"""
Recall memories relevant to a query.
Args:
bank_id: The memory bank ID.
query: The search query.
budget: Search budget (LOW, MID, HIGH).
max_tokens: Maximum tokens in response.
enable_trace: Include trace information.
fact_type: Filter by fact types.
question_date: Context date for temporal relevance.
include_entities: Include entity observations.
max_entity_tokens: Max tokens for entity observations.
include_chunks: Include raw chunks.
max_chunk_tokens: Max tokens for chunks.
request_context: Request context for authentication.
Returns:
RecallResult with matching memories.
"""
...
@abstractmethod
async def reflect_async(
self,
bank_id: str,
query: str,
*,
budget: "Budget | None" = None,
context: str | None = None,
max_tokens: int = 4096,
response_schema: dict | None = None,
request_context: "RequestContext",
) -> "ReflectResult":
"""
Reflect on a query and generate a thoughtful response.
Args:
bank_id: The memory bank ID.
query: The question to reflect on.
budget: Search budget for retrieving context.
context: Additional context for the reflection.
max_tokens: Maximum tokens for the response.
response_schema: Optional JSON Schema for structured output.
request_context: Request context for authentication.
Returns:
ReflectResult with generated response and supporting facts.
"""
...
# =========================================================================
# Bank Management
# =========================================================================
@abstractmethod
async def list_banks(
self,
*,
request_context: "RequestContext",
) -> list[dict[str, Any]]:
"""
List all memory banks.
Args:
request_context: Request context for authentication.
Returns:
List of bank info dicts.
"""
...
@abstractmethod
async def get_bank_profile(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Get bank profile including disposition and background.
Args:
bank_id: The memory bank ID.
request_context: Request context for authentication.
Returns:
Bank profile dict.
"""
...
@abstractmethod
async def update_bank_disposition(
self,
bank_id: str,
disposition: dict[str, int],
*,
request_context: "RequestContext",
) -> None:
"""
Update bank disposition traits.
Args:
bank_id: The memory bank ID.
disposition: Dict with trait values.
request_context: Request context for authentication.
"""
...
@abstractmethod
async def merge_bank_background(
self,
bank_id: str,
new_info: str,
*,
update_disposition: bool = True,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Merge new background information into bank profile.
Args:
bank_id: The memory bank ID.
new_info: New background information to merge.
update_disposition: Whether to infer disposition from background.
request_context: Request context for authentication.
Returns:
Updated background info.
"""
...
@abstractmethod
async def delete_bank(
self,
bank_id: str,
*,
fact_type: str | None = None,
request_context: "RequestContext",
) -> dict[str, int]:
"""
Delete a bank or its memories.
Args:
bank_id: The memory bank ID.
fact_type: If specified, only delete memories of this type.
request_context: Request context for authentication.
Returns:
Dict with deletion counts.
"""
...
# =========================================================================
# Memory Units
# =========================================================================
@abstractmethod
async def list_memory_units(
self,
bank_id: str,
*,
fact_type: str | None = None,
search_query: str | None = None,
limit: int = 100,
offset: int = 0,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
List memory units with pagination.
Args:
bank_id: The memory bank ID.
fact_type: Filter by fact type.
search_query: Full-text search query.
limit: Maximum results.
offset: Pagination offset.
request_context: Request context for authentication.
Returns:
Dict with 'items', 'total', 'limit', 'offset'.
"""
...
@abstractmethod
async def delete_memory_unit(
self,
unit_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Delete a specific memory unit.
Args:
unit_id: The memory unit ID.
request_context: Request context for authentication.
Returns:
Deletion result.
"""
...
@abstractmethod
async def get_graph_data(
self,
bank_id: str,
*,
fact_type: str | None = None,
limit: int = 1000,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Get graph data for visualization.
Args:
bank_id: The memory bank ID.
fact_type: Filter by fact type.
limit: Maximum number of items to return (default: 1000).
request_context: Request context for authentication.
Returns:
Dict with nodes, edges, table_rows, total_units, limit.
"""
...
# =========================================================================
# Documents
# =========================================================================
@abstractmethod
async def list_documents(
self,
bank_id: str,
*,
search_query: str | None = None,
limit: int = 100,
offset: int = 0,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
List documents with pagination.
Args:
bank_id: The memory bank ID.
search_query: Search query.
limit: Maximum results.
offset: Pagination offset.
request_context: Request context for authentication.
Returns:
Dict with 'items', 'total', 'limit', 'offset'.
"""
...
@abstractmethod
async def get_document(
self,
document_id: str,
bank_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any] | None:
"""
Get a specific document.
Args:
document_id: The document ID.
bank_id: The memory bank ID.
request_context: Request context for authentication.
Returns:
Document dict or None if not found.
"""
...
@abstractmethod
async def delete_document(
self,
document_id: str,
bank_id: str,
*,
request_context: "RequestContext",
) -> dict[str, int]:
"""
Delete a document and its memory units.
Args:
document_id: The document ID.
bank_id: The memory bank ID.
request_context: Request context for authentication.
Returns:
Dict with deletion counts.
"""
...
@abstractmethod
async def get_chunk(
self,
chunk_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any] | None:
"""
Get a specific chunk.
Args:
chunk_id: The chunk ID.
request_context: Request context for authentication.
Returns:
Chunk dict or None if not found.
"""
...
# =========================================================================
# Entities
# =========================================================================
@abstractmethod
async def list_entities(
self,
bank_id: str,
*,
limit: int = 100,
request_context: "RequestContext",
) -> list[dict[str, Any]]:
"""
List entities for a bank.
Args:
bank_id: The memory bank ID.
limit: Maximum results.
request_context: Request context for authentication.
Returns:
List of entity dicts.
"""
...
@abstractmethod
async def get_entity_observations(
self,
bank_id: str,
entity_id: str,
*,
limit: int = 10,
request_context: "RequestContext",
) -> list[Any]:
"""
Get observations for an entity.
Args:
bank_id: The memory bank ID.
entity_id: The entity ID.
limit: Maximum observations.
request_context: Request context for authentication.
Returns:
List of EntityObservation objects.
"""
...
@abstractmethod
async def regenerate_entity_observations(
self,
bank_id: str,
entity_id: str,
entity_name: str,
*,
request_context: "RequestContext",
) -> None:
"""
Regenerate observations for an entity.
Args:
bank_id: The memory bank ID.
entity_id: The entity ID.
entity_name: The entity's canonical name.
request_context: Request context for authentication.
"""
...
# =========================================================================
# Statistics & Operations
# =========================================================================
@abstractmethod
async def get_bank_stats(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Get statistics about memory nodes and links for a bank.
Args:
bank_id: The memory bank ID.
request_context: Request context for authentication.
Returns:
Dict with node_counts, link_counts, link_counts_by_fact_type,
link_breakdown, and operations stats.
"""
...
@abstractmethod
async def get_entity(
self,
bank_id: str,
entity_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any] | None:
"""
Get entity details including metadata and observations.
Args:
bank_id: The memory bank ID.
entity_id: The entity ID.
request_context: Request context for authentication.
Returns:
Entity dict with id, canonical_name, mention_count, first_seen,
last_seen, metadata, and observations. None if not found.
"""
...
@abstractmethod
async def list_operations(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> list[dict[str, Any]]:
"""
List async operations for a bank.
Args:
bank_id: The memory bank ID.
request_context: Request context for authentication.
Returns:
List of operation dicts with id, task_type, status, etc.
"""
...
@abstractmethod
async def cancel_operation(
self,
bank_id: str,
operation_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Cancel a pending async operation.
Args:
bank_id: The memory bank ID.
operation_id: The operation ID to cancel.
request_context: Request context for authentication.
Returns:
Dict with success status and message.
Raises:
ValueError: If operation not found.
"""
...
@abstractmethod
async def update_bank(
self,
bank_id: str,
*,
name: str | None = None,
background: str | None = None,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Update bank name and/or background.
Args:
bank_id: The memory bank ID.
name: New bank name (optional).
background: New background text (optional, replaces existing).
request_context: Request context for authentication.
Returns:
Updated bank profile dict.
"""
...
@abstractmethod
async def submit_async_retain(
self,
bank_id: str,
contents: list[dict[str, Any]],
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Submit a batch retain operation to run asynchronously.
Args:
bank_id: The memory bank ID.
contents: List of content dicts to retain.
request_context: Request context for authentication.
Returns:
Dict with operation_id and items_count.
"""
...
+605 -116
View File
@@ -1,15 +1,30 @@
"""
LLM wrapper for unified configuration across providers.
"""
import os
import time
import asyncio
from typing import Optional, Any, Dict, List
from openai import AsyncOpenAI, RateLimitError, APIError, APIStatusError, APIConnectionError, LengthFinishReasonError
from google import genai
from google.genai import types as genai_types
from google.genai import errors as genai_errors
import json
import logging
import os
import re
import time
from typing import Any
import httpx
from google import genai
from google.genai import errors as genai_errors
from google.genai import types as genai_types
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
from ..config import (
DEFAULT_LLM_MAX_CONCURRENT,
DEFAULT_LLM_TIMEOUT,
ENV_LLM_GROQ_SERVICE_TIER,
ENV_LLM_MAX_CONCURRENT,
ENV_LLM_TIMEOUT,
)
from ..metrics import get_metrics_collector
from .response_models import TokenUsage
# Seed applied to every Groq request for deterministic behavior.
DEFAULT_LLM_SEED = 4242
@@ -20,7 +35,9 @@ logger = logging.getLogger(__name__)
logging.getLogger("httpx").setLevel(logging.WARNING)
# Global semaphore to limit concurrent LLM requests across all instances
_global_llm_semaphore = asyncio.Semaphore(32)
# Set HINDSIGHT_API_LLM_MAX_CONCURRENT=1 for local LLMs (LM Studio, Ollama)
_llm_max_concurrent = int(os.getenv(ENV_LLM_MAX_CONCURRENT, str(DEFAULT_LLM_MAX_CONCURRENT)))
_global_llm_semaphore = asyncio.Semaphore(_llm_max_concurrent)
class OutputTooLongError(Exception):
@@ -31,6 +48,7 @@ class OutputTooLongError(Exception):
to allow callers to handle output length issues without depending on
provider-specific implementations.
"""
pass
@@ -48,29 +66,31 @@ class LLMProvider:
base_url: str,
model: str,
reasoning_effort: str = "low",
groq_service_tier: str | None = None,
):
"""
Initialize LLM provider.
Args:
provider: Provider name ("openai", "groq", "ollama", "gemini").
provider: Provider name ("openai", "groq", "ollama", "gemini", "anthropic", "lmstudio").
api_key: API key.
base_url: Base URL for the API.
model: Model name.
reasoning_effort: Reasoning effort level for supported providers.
groq_service_tier: Groq service tier ("on_demand", "flex", "auto"). Default: None (uses Groq's default).
"""
self.provider = provider.lower()
self.api_key = api_key
self.base_url = base_url
self.model = model
self.reasoning_effort = reasoning_effort
# Default to 'auto' for best performance, users can override to 'on_demand' for free tier
self.groq_service_tier = groq_service_tier or os.getenv(ENV_LLM_GROQ_SERVICE_TIER, "auto")
# Validate provider
valid_providers = ["openai", "groq", "ollama", "gemini"]
valid_providers = ["openai", "groq", "ollama", "gemini", "anthropic", "lmstudio"]
if self.provider not in valid_providers:
raise ValueError(
f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}"
)
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
# Set default base URLs
if not self.base_url:
@@ -78,25 +98,48 @@ class LLMProvider:
self.base_url = "https://api.groq.com/openai/v1"
elif self.provider == "ollama":
self.base_url = "http://localhost:11434/v1"
elif self.provider == "lmstudio":
self.base_url = "http://localhost:1234/v1"
# Validate API key (not needed for ollama)
if self.provider != "ollama" and not self.api_key:
# Validate API key (not needed for ollama or lmstudio)
if self.provider not in ("ollama", "lmstudio") and not self.api_key:
raise ValueError(f"API key not found for {self.provider}")
# Get timeout config (set HINDSIGHT_API_LLM_TIMEOUT for local LLMs that need longer timeouts)
self.timeout = float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT)))
# Create client based on provider
self._client = None
self._gemini_client = None
self._anthropic_client = None
if self.provider == "gemini":
self._gemini_client = genai.Client(api_key=self.api_key)
self._client = None
elif self.provider == "ollama":
self._client = AsyncOpenAI(api_key="ollama", base_url=self.base_url, max_retries=0)
self._gemini_client = None
elif self.provider == "anthropic":
from anthropic import AsyncAnthropic
# Only pass base_url if it's set (Anthropic uses default URL otherwise)
anthropic_kwargs = {"api_key": self.api_key}
if self.base_url:
anthropic_kwargs["base_url"] = self.base_url
if self.timeout:
anthropic_kwargs["timeout"] = self.timeout
self._anthropic_client = AsyncAnthropic(**anthropic_kwargs)
elif self.provider in ("ollama", "lmstudio"):
# Use dummy key if not provided for local
api_key = self.api_key or "local"
client_kwargs = {"api_key": api_key, "base_url": self.base_url, "max_retries": 0}
if self.timeout:
client_kwargs["timeout"] = self.timeout
self._client = AsyncOpenAI(**client_kwargs)
else:
# Only pass base_url if it's set (OpenAI uses default URL otherwise)
client_kwargs = {"api_key": self.api_key, "max_retries": 0}
if self.base_url:
client_kwargs["base_url"] = self.base_url
if self.timeout:
client_kwargs["timeout"] = self.timeout
self._client = AsyncOpenAI(**client_kwargs)
self._gemini_client = None
async def verify_connection(self) -> None:
"""
@@ -106,10 +149,12 @@ class LLMProvider:
RuntimeError: If the connection test fails.
"""
try:
logger.info(f"Verifying LLM: provider={self.provider}, model={self.model}, base_url={self.base_url or 'default'}...")
logger.info(
f"Verifying LLM: provider={self.provider}, model={self.model}, base_url={self.base_url or 'default'}..."
)
await self.call(
messages=[{"role": "user", "content": "Say 'ok'"}],
max_completion_tokens=10,
max_completion_tokens=100,
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
@@ -117,21 +162,21 @@ class LLMProvider:
# If we get here without exception, the connection is working
logger.info(f"LLM verified: {self.provider}/{self.model}")
except Exception as e:
raise RuntimeError(
f"LLM connection verification failed for {self.provider}/{self.model}: {e}"
) from e
raise RuntimeError(f"LLM connection verification failed for {self.provider}/{self.model}: {e}") from e
async def call(
self,
messages: List[Dict[str, str]],
response_format: Optional[Any] = None,
max_completion_tokens: Optional[int] = None,
temperature: Optional[float] = None,
messages: list[dict[str, str]],
response_format: Any | None = None,
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
"""
Make an LLM API call with retry logic.
@@ -146,9 +191,12 @@ class LLMProvider:
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Use strict JSON schema enforcement (OpenAI only). Guarantees all required fields.
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
Parsed response if response_format is provided, otherwise text content.
If return_usage=False: Parsed response if response_format is provided, otherwise text content.
If return_usage=True: Tuple of (result, TokenUsage) with token counts from the LLM call.
Raises:
OutputTooLongError: If output exceeds token limits.
@@ -156,13 +204,47 @@ class LLMProvider:
"""
async with _global_llm_semaphore:
start_time = time.time()
import json
# Handle Gemini provider separately
if self.provider == "gemini":
return await self._call_gemini(
messages, response_format, max_retries, initial_backoff,
max_backoff, skip_validation, start_time
messages,
response_format,
max_retries,
initial_backoff,
max_backoff,
skip_validation,
start_time,
return_usage,
)
# Handle Anthropic provider separately
if self.provider == "anthropic":
return await self._call_anthropic(
messages,
response_format,
max_completion_tokens,
max_retries,
initial_backoff,
max_backoff,
skip_validation,
start_time,
return_usage,
)
# Handle Ollama with native API for structured output (better schema enforcement)
if self.provider == "ollama" and response_format is not None:
return await self._call_ollama_native(
messages,
response_format,
max_completion_tokens,
temperature,
max_retries,
initial_backoff,
max_backoff,
skip_validation,
start_time,
return_usage,
)
call_params = {
@@ -172,7 +254,7 @@ class LLMProvider:
# Check if model supports reasoning parameter (o1, o3, gpt-5 families)
model_lower = self.model.lower()
is_reasoning_model = any(x in model_lower for x in ["gpt-5", "o1", "o3"])
is_reasoning_model = any(x in model_lower for x in ["gpt-5", "o1", "o3", "deepseek"])
# For GPT-4 and GPT-4.1 models, cap max_completion_tokens to 32000
# For GPT-4o models, cap to 16384
@@ -194,39 +276,114 @@ class LLMProvider:
call_params["temperature"] = temperature
# Set reasoning_effort for reasoning models (OpenAI gpt-5, o1, o3)
if is_reasoning_model and self.provider == "openai":
if is_reasoning_model:
call_params["reasoning_effort"] = self.reasoning_effort
# Provider-specific parameters
if self.provider == "groq":
call_params["seed"] = DEFAULT_LLM_SEED
extra_body = {"service_tier": "auto"}
# Only add reasoning parameters for reasoning models
extra_body: dict[str, Any] = {}
# Add service_tier if configured (requires paid plan for flex/auto)
if self.groq_service_tier:
extra_body["service_tier"] = self.groq_service_tier
# Add reasoning parameters for reasoning models
if is_reasoning_model:
extra_body["reasoning_effort"] = self.reasoning_effort
extra_body["include_reasoning"] = False
call_params["extra_body"] = extra_body
if extra_body:
call_params["extra_body"] = extra_body
last_exception = None
for attempt in range(max_retries + 1):
try:
if response_format is not None:
# Add schema to system message for JSON mode
if hasattr(response_format, 'model_json_schema'):
schema = None
if hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
if call_params['messages'] and call_params['messages'][0].get('role') == 'system':
call_params['messages'][0]['content'] += schema_msg
elif call_params['messages']:
call_params['messages'][0]['content'] = schema_msg + "\n\n" + call_params['messages'][0]['content']
if strict_schema and schema is not None:
# Use OpenAI's strict JSON schema enforcement
# This guarantees all required fields are returned
call_params["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "response",
"strict": True,
"schema": schema,
},
}
else:
# Soft enforcement: add schema to prompt and use json_object mode
if schema is not None:
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
call_params['response_format'] = {"type": "json_object"}
if call_params["messages"] and call_params["messages"][0].get("role") == "system":
call_params["messages"][0]["content"] += schema_msg
elif call_params["messages"]:
call_params["messages"][0]["content"] = (
schema_msg + "\n\n" + call_params["messages"][0]["content"]
)
if self.provider not in ("lmstudio", "ollama"):
# LM Studio and Ollama don't support json_object response format reliably
# We rely on the schema in the system message instead
call_params["response_format"] = {"type": "json_object"}
logger.debug(f"Sending request to {self.provider}/{self.model} (timeout={self.timeout})")
response = await self._client.chat.completions.create(**call_params)
logger.debug(f"Received response from {self.provider}/{self.model}")
content = response.choices[0].message.content
json_data = json.loads(content)
# Strip reasoning model thinking tags
# Supports: <think>, <thinking>, <reasoning>, |startthink|/|endthink|
# for reasoning models that embed thinking in their output (e.g., Qwen3, DeepSeek)
if content:
original_len = len(content)
content = re.sub(r"<think>.*?</think>", "", content, flags=re.DOTALL)
content = re.sub(r"<thinking>.*?</thinking>", "", content, flags=re.DOTALL)
content = re.sub(r"<reasoning>.*?</reasoning>", "", content, flags=re.DOTALL)
content = re.sub(r"\|startthink\|.*?\|endthink\|", "", content, flags=re.DOTALL)
content = content.strip()
if len(content) < original_len:
logger.debug(f"Stripped {original_len - len(content)} chars of reasoning tokens")
# For local models, they may wrap JSON in markdown code blocks
if self.provider in ("lmstudio", "ollama"):
clean_content = content
if "```json" in content:
clean_content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
clean_content = content.split("```")[1].split("```")[0].strip()
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError:
# Fallback to parsing raw content
json_data = json.loads(content)
else:
# Log raw LLM response for debugging JSON parse issues
try:
json_data = json.loads(content)
except json.JSONDecodeError as json_err:
# Truncate content for logging (first 500 and last 200 chars)
content_preview = content[:500] if content else "<empty>"
if content and len(content) > 700:
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
logger.warning(
f"JSON parse error from LLM response (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
f" Model: {self.provider}/{self.model}\n"
f" Content length: {len(content) if content else 0} chars\n"
f" Content preview: {content_preview!r}\n"
f" Finish reason: {response.choices[0].finish_reason if response.choices else 'unknown'}"
)
# Retry on JSON parse errors - LLM may return valid JSON on next attempt
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = json_err
continue
else:
logger.error(f"JSON parse error after {max_retries + 1} attempts, giving up")
raise
if skip_validation:
result = json_data
@@ -236,35 +393,59 @@ class LLMProvider:
response = await self._client.chat.completions.create(**call_params)
result = response.choices[0].message.content
# Log slow calls
# Record token usage metrics
duration = time.time() - start_time
usage = response.usage
if duration > 10.0:
ratio = max(1, usage.completion_tokens) / usage.prompt_tokens
input_tokens = usage.prompt_tokens or 0 if usage else 0
output_tokens = usage.completion_tokens or 0 if usage else 0
total_tokens = usage.total_tokens or 0 if usage else 0
if usage:
get_metrics_collector().record_tokens(
operation=scope,
bank_id="llm",
input_tokens=input_tokens,
output_tokens=output_tokens,
)
# Log slow calls
if duration > 10.0 and usage:
ratio = max(1, output_tokens) / max(1, input_tokens)
cached_tokens = 0
if hasattr(usage, 'prompt_tokens_details') and usage.prompt_tokens_details:
cached_tokens = getattr(usage.prompt_tokens_details, 'cached_tokens', 0) or 0
if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details:
cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0
cache_info = f", cached_tokens={cached_tokens}" if cached_tokens > 0 else ""
logger.info(
f"slow llm call: model={self.provider}/{self.model}, "
f"input_tokens={usage.prompt_tokens}, output_tokens={usage.completion_tokens}, "
f"total_tokens={usage.total_tokens}{cache_info}, time={duration:.3f}s, ratio out/in={ratio:.2f}"
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
f"total_tokens={total_tokens}{cache_info}, time={duration:.3f}s, ratio out/in={ratio:.2f}"
)
if return_usage:
token_usage = TokenUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
)
return result, token_usage
return result
except LengthFinishReasonError as e:
logger.warning(f"LLM output exceeded token limits: {str(e)}")
raise OutputTooLongError(
f"LLM output exceeded token limits. Input may need to be split into smaller chunks."
"LLM output exceeded token limits. Input may need to be split into smaller chunks."
) from e
except APIConnectionError as e:
last_exception = e
if attempt < max_retries:
status_code = getattr(e, 'status_code', None) or getattr(getattr(e, 'response', None), 'status_code', None)
logger.warning(f"Connection error, retrying... (attempt {attempt + 1}/{max_retries + 1}) - status_code={status_code}, message={e}")
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
status_code = getattr(e, "status_code", None) or getattr(
getattr(e, "response", None), "status_code", None
)
logger.warning(
f"Connection error, retrying... (attempt {attempt + 1}/{max_retries + 1}) - status_code={status_code}, message={e}"
)
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
@@ -279,7 +460,7 @@ class LLMProvider:
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
backoff = min(initial_backoff * (2**attempt), max_backoff)
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
sleep_time = backoff + jitter
await asyncio.sleep(sleep_time)
@@ -293,47 +474,343 @@ class LLMProvider:
if last_exception:
raise last_exception
raise RuntimeError(f"LLM call failed after all retries with no exception captured")
raise RuntimeError("LLM call failed after all retries with no exception captured")
async def _call_gemini(
async def _call_anthropic(
self,
messages: List[Dict[str, str]],
response_format: Optional[Any],
messages: list[dict[str, str]],
response_format: Any | None,
max_completion_tokens: int | None,
max_retries: int,
initial_backoff: float,
max_backoff: float,
skip_validation: bool,
start_time: float,
return_usage: bool = False,
) -> Any:
"""Handle Anthropic-specific API calls."""
from anthropic import APIConnectionError, APIStatusError, RateLimitError
# Convert OpenAI-style messages to Anthropic format
system_prompt = None
anthropic_messages = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
if system_prompt:
system_prompt += "\n\n" + content
else:
system_prompt = content
else:
anthropic_messages.append({"role": role, "content": content})
# Add JSON schema instruction if response_format is provided
if response_format is not None and hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
if system_prompt:
system_prompt += schema_msg
else:
system_prompt = schema_msg
# Prepare parameters
call_params = {
"model": self.model,
"messages": anthropic_messages,
"max_tokens": max_completion_tokens if max_completion_tokens is not None else 4096,
}
if system_prompt:
call_params["system"] = system_prompt
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._anthropic_client.messages.create(**call_params)
# Anthropic response content is a list of blocks
content = ""
for block in response.content:
if block.type == "text":
content += block.text
if response_format is not None:
# Models may wrap JSON in markdown code blocks
clean_content = content
if "```json" in content:
clean_content = content.split("```json")[1].split("```")[0].strip()
elif "```" in content:
clean_content = content.split("```")[1].split("```")[0].strip()
try:
json_data = json.loads(clean_content)
except json.JSONDecodeError:
# Fallback to parsing raw content if markdown stripping failed
json_data = json.loads(content)
if skip_validation:
result = json_data
else:
result = response_format.model_validate(json_data)
else:
result = content
# Record token usage metrics
duration = time.time() - start_time
input_tokens = response.usage.input_tokens or 0 if response.usage else 0
output_tokens = response.usage.output_tokens or 0 if response.usage else 0
total_tokens = input_tokens + output_tokens
if response.usage:
get_metrics_collector().record_tokens(
operation="memory",
bank_id="llm",
input_tokens=input_tokens,
output_tokens=output_tokens,
)
# Log slow calls
if duration > 10.0 and response.usage:
logger.info(
f"slow llm call: model={self.provider}/{self.model}, "
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
f"time={duration:.3f}s"
)
if return_usage:
token_usage = TokenUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
)
return result, token_usage
return result
except json.JSONDecodeError as e:
last_exception = e
if attempt < max_retries:
logger.warning("Anthropic returned invalid JSON, retrying...")
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Anthropic returned invalid JSON after {max_retries + 1} attempts")
raise
except (APIConnectionError, RateLimitError, APIStatusError) as e:
# Fast fail on 401/403
if isinstance(e, APIStatusError) and e.status_code in (401, 403):
logger.error(f"Anthropic auth error (HTTP {e.status_code}), not retrying: {str(e)}")
raise
last_exception = e
if attempt < max_retries:
# Check if it's a rate limit or server error
should_retry = isinstance(e, (APIConnectionError, RateLimitError)) or (
isinstance(e, APIStatusError) and e.status_code >= 500
)
if should_retry:
backoff = min(initial_backoff * (2**attempt), max_backoff)
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
await asyncio.sleep(backoff + jitter)
continue
logger.error(f"Anthropic API error after {max_retries + 1} attempts: {str(e)}")
raise
except Exception as e:
logger.error(f"Unexpected error during Anthropic call: {type(e).__name__}: {str(e)}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Anthropic call failed after all retries")
async def _call_ollama_native(
self,
messages: list[dict[str, str]],
response_format: Any,
max_completion_tokens: int | None,
temperature: float | None,
max_retries: int,
initial_backoff: float,
max_backoff: float,
skip_validation: bool,
start_time: float,
return_usage: bool = False,
) -> Any:
"""
Call Ollama using native API with JSON schema enforcement.
Ollama's native API supports passing a full JSON schema in the 'format' parameter,
which provides better structured output control than the OpenAI-compatible API.
"""
# Get the JSON schema from the Pydantic model
schema = response_format.model_json_schema() if hasattr(response_format, "model_json_schema") else None
# Build the base URL for Ollama's native API
# Default OpenAI-compatible URL is http://localhost:11434/v1
# Native API is at http://localhost:11434/api/chat
base_url = self.base_url or "http://localhost:11434/v1"
if base_url.endswith("/v1"):
native_url = base_url[:-3] + "/api/chat"
else:
native_url = base_url.rstrip("/") + "/api/chat"
# Build request payload
payload = {
"model": self.model,
"messages": messages,
"stream": False,
}
# Add schema as format parameter for structured output
if schema:
payload["format"] = schema
# Add optional parameters with optimized defaults for Ollama
# Benchmarking shows num_ctx=16384 + num_batch=512 is optimal
options = {
"num_ctx": 16384, # 16k context window for larger prompts
"num_batch": 512, # Optimal batch size for prompt processing
}
if max_completion_tokens:
options["num_predict"] = max_completion_tokens
if temperature is not None:
options["temperature"] = temperature
payload["options"] = options
last_exception = None
async with httpx.AsyncClient(timeout=300.0) as client:
for attempt in range(max_retries + 1):
try:
response = await client.post(native_url, json=payload)
response.raise_for_status()
result = response.json()
content = result.get("message", {}).get("content", "")
# Parse JSON response
try:
json_data = json.loads(content)
except json.JSONDecodeError as json_err:
content_preview = content[:500] if content else "<empty>"
if content and len(content) > 700:
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
logger.warning(
f"Ollama JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
f" Model: ollama/{self.model}\n"
f" Content length: {len(content) if content else 0} chars\n"
f" Content preview: {content_preview!r}"
)
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = json_err
continue
else:
raise
# Extract token usage from Ollama response
# Ollama returns prompt_eval_count (input) and eval_count (output)
input_tokens = result.get("prompt_eval_count", 0) or 0
output_tokens = result.get("eval_count", 0) or 0
total_tokens = input_tokens + output_tokens
# Record to metrics
if input_tokens > 0 or output_tokens > 0:
get_metrics_collector().record_tokens(
operation="memory",
bank_id="llm",
input_tokens=input_tokens,
output_tokens=output_tokens,
)
# Validate against Pydantic model or return raw JSON
if skip_validation:
validated_result = json_data
else:
validated_result = response_format.model_validate(json_data)
if return_usage:
token_usage = TokenUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=total_tokens,
)
return validated_result, token_usage
return validated_result
except httpx.HTTPStatusError as e:
last_exception = e
if attempt < max_retries:
logger.warning(
f"Ollama HTTP error (attempt {attempt + 1}/{max_retries + 1}): {e.response.status_code}"
)
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Ollama HTTP error after {max_retries + 1} attempts: {e}")
raise
except httpx.RequestError as e:
last_exception = e
if attempt < max_retries:
logger.warning(f"Ollama connection error (attempt {attempt + 1}/{max_retries + 1}): {e}")
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Ollama connection error after {max_retries + 1} attempts: {e}")
raise
except Exception as e:
logger.error(f"Unexpected error during Ollama call: {type(e).__name__}: {e}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Ollama call failed after all retries")
async def _call_gemini(
self,
messages: list[dict[str, str]],
response_format: Any | None,
max_retries: int,
initial_backoff: float,
max_backoff: float,
skip_validation: bool,
start_time: float,
return_usage: bool = False,
) -> Any:
"""Handle Gemini-specific API calls."""
import json
# Convert OpenAI-style messages to Gemini format
system_instruction = None
gemini_contents = []
for msg in messages:
role = msg.get('role', 'user')
content = msg.get('content', '')
role = msg.get("role", "user")
content = msg.get("content", "")
if role == 'system':
if role == "system":
if system_instruction:
system_instruction += "\n\n" + content
else:
system_instruction = content
elif role == 'assistant':
gemini_contents.append(genai_types.Content(
role="model",
parts=[genai_types.Part(text=content)]
))
elif role == "assistant":
gemini_contents.append(genai_types.Content(role="model", parts=[genai_types.Part(text=content)]))
else:
gemini_contents.append(genai_types.Content(
role="user",
parts=[genai_types.Part(text=content)]
))
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
# Add JSON schema instruction if response_format is provided
if response_format is not None and hasattr(response_format, 'model_json_schema'):
if response_format is not None and hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
if system_instruction:
@@ -344,10 +821,10 @@ class LLMProvider:
# Build generation config
config_kwargs = {}
if system_instruction:
config_kwargs['system_instruction'] = system_instruction
config_kwargs["system_instruction"] = system_instruction
if response_format is not None:
config_kwargs['response_mime_type'] = 'application/json'
config_kwargs['response_schema'] = response_format
config_kwargs["response_mime_type"] = "application/json"
config_kwargs["response_schema"] = response_format
generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
@@ -366,14 +843,14 @@ class LLMProvider:
# Handle empty response
if content is None:
block_reason = None
if hasattr(response, 'candidates') and response.candidates:
if hasattr(response, "candidates") and response.candidates:
candidate = response.candidates[0]
if hasattr(candidate, 'finish_reason'):
if hasattr(candidate, "finish_reason"):
block_reason = candidate.finish_reason
if attempt < max_retries:
logger.warning(f"Gemini returned empty response (reason: {block_reason}), retrying...")
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
@@ -388,23 +865,43 @@ class LLMProvider:
else:
result = content
# Log slow calls
# Record token usage metrics
duration = time.time() - start_time
if duration > 10.0 and hasattr(response, 'usage_metadata') and response.usage_metadata:
input_tokens = 0
output_tokens = 0
if hasattr(response, "usage_metadata") and response.usage_metadata:
usage = response.usage_metadata
logger.info(
f"slow llm call: model={self.provider}/{self.model}, "
f"input_tokens={usage.prompt_token_count}, output_tokens={usage.candidates_token_count}, "
f"time={duration:.3f}s"
input_tokens = usage.prompt_token_count or 0
output_tokens = usage.candidates_token_count or 0
get_metrics_collector().record_tokens(
operation="memory",
bank_id="llm",
input_tokens=input_tokens,
output_tokens=output_tokens,
)
# Log slow calls
if duration > 10.0:
logger.info(
f"slow llm call: model={self.provider}/{self.model}, "
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
f"time={duration:.3f}s"
)
if return_usage:
token_usage = TokenUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=input_tokens + output_tokens,
)
return result, token_usage
return result
except json.JSONDecodeError as e:
last_exception = e
if attempt < max_retries:
logger.warning(f"Gemini returned invalid JSON, retrying...")
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
logger.warning("Gemini returned invalid JSON, retrying...")
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
@@ -421,7 +918,7 @@ class LLMProvider:
if e.code in (400, 429, 500, 502, 503, 504) or (e.code and e.code >= 500):
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
backoff = min(initial_backoff * (2**attempt), max_backoff)
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
await asyncio.sleep(backoff + jitter)
else:
@@ -437,55 +934,47 @@ class LLMProvider:
if last_exception:
raise last_exception
raise RuntimeError(f"Gemini call failed after all retries")
raise RuntimeError("Gemini call failed after all retries")
@classmethod
def for_memory(cls) -> "LLMProvider":
"""Create provider for memory operations from environment variables."""
provider = os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq")
api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY")
if not api_key:
raise ValueError("HINDSIGHT_API_LLM_API_KEY environment variable is required")
base_url = os.getenv("HINDSIGHT_API_LLM_BASE_URL", "")
model = os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b")
return cls(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort="low"
)
return cls(provider=provider, api_key=api_key, base_url=base_url, model=model, reasoning_effort="low")
@classmethod
def for_answer_generation(cls) -> "LLMProvider":
"""Create provider for answer generation. Falls back to memory config if not set."""
provider = os.getenv("HINDSIGHT_API_ANSWER_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
api_key = os.getenv("HINDSIGHT_API_ANSWER_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY"))
if not api_key:
raise ValueError(
"HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_ANSWER_LLM_API_KEY environment variable is required"
)
base_url = os.getenv("HINDSIGHT_API_ANSWER_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
model = os.getenv("HINDSIGHT_API_ANSWER_LLM_MODEL", os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"))
return cls(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort="high"
)
return cls(provider=provider, api_key=api_key, base_url=base_url, model=model, reasoning_effort="high")
@classmethod
def for_judge(cls) -> "LLMProvider":
"""Create provider for judge/evaluator operations. Falls back to memory config if not set."""
provider = os.getenv("HINDSIGHT_API_JUDGE_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
api_key = os.getenv("HINDSIGHT_API_JUDGE_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY"))
if not api_key:
raise ValueError(
"HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_JUDGE_LLM_API_KEY environment variable is required"
)
base_url = os.getenv("HINDSIGHT_API_JUDGE_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
model = os.getenv("HINDSIGHT_API_JUDGE_LLM_MODEL", os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"))
return cls(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort="high"
)
return cls(provider=provider, api_key=api_key, base_url=base_url, model=model, reasoning_effort="high")
# Backwards compatibility alias
File diff suppressed because it is too large Load Diff
@@ -4,11 +4,12 @@ Query analysis abstraction for the memory system.
Provides an interface for analyzing natural language queries to extract
structured information like temporal constraints.
"""
from abc import ABC, abstractmethod
from typing import Optional
from datetime import datetime, timedelta
import logging
import re
from abc import ABC, abstractmethod
from datetime import datetime, timedelta
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
@@ -20,6 +21,7 @@ class TemporalConstraint(BaseModel):
Represents a time range with start and end dates.
"""
start_date: datetime = Field(description="Start of the time range (inclusive)")
end_date: datetime = Field(description="End of the time range (inclusive)")
@@ -33,9 +35,9 @@ class QueryAnalysis(BaseModel):
Contains extracted structured information like temporal constraints.
"""
temporal_constraint: Optional[TemporalConstraint] = Field(
default=None,
description="Extracted temporal constraint, if any"
temporal_constraint: TemporalConstraint | None = Field(
default=None, description="Extracted temporal constraint, if any"
)
@@ -58,9 +60,7 @@ class QueryAnalyzer(ABC):
pass
@abstractmethod
def analyze(
self, query: str, reference_date: Optional[datetime] = None
) -> QueryAnalysis:
def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAnalysis:
"""
Analyze a natural language query.
@@ -95,11 +95,10 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
"""Load dateparser (lazy import)."""
if self._search_dates is None:
from dateparser.search import search_dates
self._search_dates = search_dates
def analyze(
self, query: str, reference_date: Optional[datetime] = None
) -> QueryAnalysis:
def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAnalysis:
"""
Analyze query using dateparser.
@@ -126,9 +125,9 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
# Use dateparser's search_dates to find temporal expressions
settings = {
'RELATIVE_BASE': reference_date,
'PREFER_DATES_FROM': 'past',
'RETURN_AS_TIMEZONE_AWARE': False,
"RELATIVE_BASE": reference_date,
"PREFER_DATES_FROM": "past",
"RETURN_AS_TIMEZONE_AWARE": False,
}
results = self._search_dates(query, settings=settings)
@@ -137,11 +136,8 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
return QueryAnalysis(temporal_constraint=None)
# Filter out false positives (common words parsed as dates)
false_positives = {'do', 'may', 'march', 'will', 'can', 'sat', 'sun', 'mon', 'tue', 'wed', 'thu', 'fri'}
valid_results = [
(text, date) for text, date in results
if text.lower() not in false_positives or len(text) > 3
]
false_positives = {"do", "may", "march", "will", "can", "sat", "sun", "mon", "tue", "wed", "thu", "fri"}
valid_results = [(text, date) for text, date in results if text.lower() not in false_positives or len(text) > 3]
if not valid_results:
return QueryAnalysis(temporal_constraint=None)
@@ -153,84 +149,94 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
start_date = parsed_date.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = parsed_date.replace(hour=23, minute=59, second=59, microsecond=999999)
return QueryAnalysis(
temporal_constraint=TemporalConstraint(
start_date=start_date,
end_date=end_date
)
)
return QueryAnalysis(temporal_constraint=TemporalConstraint(start_date=start_date, end_date=end_date))
def _extract_period(
self, query: str, reference_date: datetime
) -> Optional[TemporalConstraint]:
def _extract_period(self, query: str, reference_date: datetime) -> TemporalConstraint | None:
"""
Extract period-based temporal expressions (week, month, year, weekend).
These need special handling as they represent date ranges, not single dates.
Supports multiple languages.
"""
def constraint(start: datetime, end: datetime) -> TemporalConstraint:
return TemporalConstraint(
start_date=start.replace(hour=0, minute=0, second=0, microsecond=0),
end_date=end.replace(hour=23, minute=59, second=59, microsecond=999999)
end_date=end.replace(hour=23, minute=59, second=59, microsecond=999999),
)
# Yesterday patterns (English, Spanish, Italian, French, German)
if re.search(r'\b(yesterday|ayer|ieri|hier|gestern)\b', query, re.IGNORECASE):
if re.search(r"\b(yesterday|ayer|ieri|hier|gestern)\b", query, re.IGNORECASE):
d = reference_date - timedelta(days=1)
return constraint(d, d)
# Today patterns
if re.search(r'\b(today|hoy|oggi|aujourd\'?hui|heute)\b', query, re.IGNORECASE):
if re.search(r"\b(today|hoy|oggi|aujourd\'?hui|heute)\b", query, re.IGNORECASE):
return constraint(reference_date, reference_date)
# "a couple of days ago" / "a few days ago" patterns
# These are imprecise so we create a range
if re.search(r'\b(a\s+)?couple\s+(of\s+)?days?\s+ago\b', query, re.IGNORECASE):
if re.search(r"\b(a\s+)?couple\s+(of\s+)?days?\s+ago\b", query, re.IGNORECASE):
# "a couple of days" = approximately 2 days, give range of 1-3 days
return constraint(reference_date - timedelta(days=3), reference_date - timedelta(days=1))
if re.search(r'\b(a\s+)?few\s+days?\s+ago\b', query, re.IGNORECASE):
if re.search(r"\b(a\s+)?few\s+days?\s+ago\b", query, re.IGNORECASE):
# "a few days" = approximately 3-4 days, give range of 2-5 days
return constraint(reference_date - timedelta(days=5), reference_date - timedelta(days=2))
# "a couple of weeks ago" / "a few weeks ago" patterns
if re.search(r'\b(a\s+)?couple\s+(of\s+)?weeks?\s+ago\b', query, re.IGNORECASE):
if re.search(r"\b(a\s+)?couple\s+(of\s+)?weeks?\s+ago\b", query, re.IGNORECASE):
# "a couple of weeks" = approximately 2 weeks, give range of 1-3 weeks
return constraint(reference_date - timedelta(weeks=3), reference_date - timedelta(weeks=1))
if re.search(r'\b(a\s+)?few\s+weeks?\s+ago\b', query, re.IGNORECASE):
if re.search(r"\b(a\s+)?few\s+weeks?\s+ago\b", query, re.IGNORECASE):
# "a few weeks" = approximately 3-4 weeks, give range of 2-5 weeks
return constraint(reference_date - timedelta(weeks=5), reference_date - timedelta(weeks=2))
# "a couple of months ago" / "a few months ago" patterns
if re.search(r'\b(a\s+)?couple\s+(of\s+)?months?\s+ago\b', query, re.IGNORECASE):
if re.search(r"\b(a\s+)?couple\s+(of\s+)?months?\s+ago\b", query, re.IGNORECASE):
# "a couple of months" = approximately 2 months, give range of 1-3 months
return constraint(reference_date - timedelta(days=90), reference_date - timedelta(days=30))
if re.search(r'\b(a\s+)?few\s+months?\s+ago\b', query, re.IGNORECASE):
if re.search(r"\b(a\s+)?few\s+months?\s+ago\b", query, re.IGNORECASE):
# "a few months" = approximately 3-4 months, give range of 2-5 months
return constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60))
# Last week patterns (English, Spanish, Italian, French, German)
if re.search(r'\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche)\b', query, re.IGNORECASE):
if re.search(
r"\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche)\b",
query,
re.IGNORECASE,
):
start = reference_date - timedelta(days=reference_date.weekday() + 7)
return constraint(start, start + timedelta(days=6))
# Last month patterns
if re.search(r'\b(last\s+month|el\s+mes\s+pasado|il\s+mese\s+scorso|le\s+mois\s+dernier|letzten?\s+monat)\b', query, re.IGNORECASE):
if re.search(
r"\b(last\s+month|el\s+mes\s+pasado|il\s+mese\s+scorso|le\s+mois\s+dernier|letzten?\s+monat)\b",
query,
re.IGNORECASE,
):
first = reference_date.replace(day=1)
end = first - timedelta(days=1)
start = end.replace(day=1)
return constraint(start, end)
# Last year patterns
if re.search(r'\b(last\s+year|el\s+a[ñn]o\s+pasado|l\'anno\s+scorso|l\'ann[ée]e\s+derni[eè]re|letztes?\s+jahr)\b', query, re.IGNORECASE):
if re.search(
r"\b(last\s+year|el\s+a[ñn]o\s+pasado|l\'anno\s+scorso|l\'ann[ée]e\s+derni[eè]re|letztes?\s+jahr)\b",
query,
re.IGNORECASE,
):
year = reference_date.year - 1
return constraint(datetime(year, 1, 1), datetime(year, 12, 31))
# Last weekend patterns
if re.search(r'\b(last\s+weekend|el\s+fin\s+de\s+semana\s+pasado|lo\s+scorso\s+fine\s+settimana|le\s+week-?end\s+dernier|letztes?\s+wochenende)\b', query, re.IGNORECASE):
if re.search(
r"\b(last\s+weekend|el\s+fin\s+de\s+semana\s+pasado|lo\s+scorso\s+fine\s+settimana|le\s+week-?end\s+dernier|letztes?\s+wochenende)\b",
query,
re.IGNORECASE,
):
days_since_sat = (reference_date.weekday() + 2) % 7
if days_since_sat == 0:
days_since_sat = 7
@@ -239,22 +245,22 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
# Month + Year patterns (e.g., "June 2024", "junio 2024", "giugno 2024")
month_patterns = {
'january|enero|gennaio|janvier|januar': 1,
'february|febrero|febbraio|f[ée]vrier|februar': 2,
'march|marzo|mars|m[äa]rz': 3,
'april|abril|aprile|avril': 4,
'may|mayo|maggio|mai': 5,
'june|junio|giugno|juin|juni': 6,
'july|julio|luglio|juillet|juli': 7,
'august|agosto|ao[uû]t': 8,
'september|septiembre|settembre|septembre': 9,
'october|octubre|ottobre|octobre|oktober': 10,
'november|noviembre|novembre': 11,
'december|diciembre|dicembre|d[ée]cembre|dezember': 12,
"january|enero|gennaio|janvier|januar": 1,
"february|febrero|febbraio|f[ée]vrier|februar": 2,
"march|marzo|mars|m[äa]rz": 3,
"april|abril|aprile|avril": 4,
"may|mayo|maggio|mai": 5,
"june|junio|giugno|juin|juni": 6,
"july|julio|luglio|juillet|juli": 7,
"august|agosto|ao[uû]t": 8,
"september|septiembre|settembre|septembre": 9,
"october|octubre|ottobre|octobre|oktober": 10,
"november|noviembre|novembre": 11,
"december|diciembre|dicembre|d[ée]cembre|dezember": 12,
}
for pattern, month_num in month_patterns.items():
match = re.search(rf'\b({pattern})\s+(\d{{4}})\b', query, re.IGNORECASE)
match = re.search(rf"\b({pattern})\s+(\d{{4}})\b", query, re.IGNORECASE)
if match:
year = int(match.group(2))
start = datetime(year, month_num, 1)
@@ -279,11 +285,7 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
- Model size: ~80M params (~300MB download)
"""
def __init__(
self,
model_name: str = "google/flan-t5-small",
device: str = "cpu"
):
def __init__(self, model_name: str = "google/flan-t5-small", device: str = "cpu"):
"""
Initialize T5 query analyzer.
@@ -304,11 +306,10 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
return
try:
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
except ImportError:
raise ImportError(
"transformers is required for TransformerQueryAnalyzer. "
"Install it with: pip install transformers"
"transformers is required for TransformerQueryAnalyzer. Install it with: pip install transformers"
)
logger.info(f"Loading query analyzer model: {self.model_name}...")
@@ -322,9 +323,7 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
"""Lazy load the T5 model for temporal extraction (calls load())."""
self.load()
def _extract_with_rules(
self, query: str, reference_date: datetime
) -> Optional[TemporalConstraint]:
def _extract_with_rules(self, query: str, reference_date: datetime) -> TemporalConstraint | None:
"""
Extract temporal expressions using rule-based patterns.
@@ -332,6 +331,7 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
patterns that need model-based extraction.
"""
import re
query_lower = query.lower()
def get_last_weekday(weekday: int) -> datetime:
@@ -343,50 +343,60 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
def constraint(start: datetime, end: datetime) -> TemporalConstraint:
return TemporalConstraint(
start_date=start.replace(hour=0, minute=0, second=0, microsecond=0),
end_date=end.replace(hour=23, minute=59, second=59, microsecond=999999)
end_date=end.replace(hour=23, minute=59, second=59, microsecond=999999),
)
# Yesterday
if re.search(r'\byesterday\b', query_lower):
if re.search(r"\byesterday\b", query_lower):
d = reference_date - timedelta(days=1)
return constraint(d, d)
# Last week
if re.search(r'\blast\s+week\b', query_lower):
if re.search(r"\blast\s+week\b", query_lower):
start = reference_date - timedelta(days=reference_date.weekday() + 7)
return constraint(start, start + timedelta(days=6))
# Last month
if re.search(r'\blast\s+month\b', query_lower):
if re.search(r"\blast\s+month\b", query_lower):
first = reference_date.replace(day=1)
end = first - timedelta(days=1)
start = end.replace(day=1)
return constraint(start, end)
# Last year
if re.search(r'\blast\s+year\b', query_lower):
if re.search(r"\blast\s+year\b", query_lower):
y = reference_date.year - 1
return constraint(datetime(y, 1, 1), datetime(y, 12, 31))
# Last weekend
if re.search(r'\blast\s+weekend\b', query_lower):
if re.search(r"\blast\s+weekend\b", query_lower):
sat = get_last_weekday(5)
return constraint(sat, sat + timedelta(days=1))
# Last <weekday>
weekdays = {'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3,
'friday': 4, 'saturday': 5, 'sunday': 6}
weekdays = {"monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6}
for name, num in weekdays.items():
if re.search(rf'\blast\s+{name}\b', query_lower):
if re.search(rf"\blast\s+{name}\b", query_lower):
d = get_last_weekday(num)
return constraint(d, d)
# Month + Year: "June 2024", "in March 2023"
months = {'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5,
'june': 6, 'july': 7, 'august': 8, 'september': 9, 'october': 10,
'november': 11, 'december': 12}
months = {
"january": 1,
"february": 2,
"march": 3,
"april": 4,
"may": 5,
"june": 6,
"july": 7,
"august": 8,
"september": 9,
"october": 10,
"november": 11,
"december": 12,
}
for name, num in months.items():
match = re.search(rf'\b{name}\s+(\d{{4}})\b', query_lower)
match = re.search(rf"\b{name}\s+(\d{{4}})\b", query_lower)
if match:
year = int(match.group(1))
if num == 12:
@@ -397,9 +407,7 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
return None
def analyze(
self, query: str, reference_date: Optional[datetime] = None
) -> QueryAnalysis:
def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAnalysis:
"""
Analyze query for temporal expressions.
@@ -435,11 +443,11 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
last_saturday = get_last_weekday(5)
# Build prompt for T5
prompt = f"""Today is {reference_date.strftime('%Y-%m-%d')}. Extract date range or "none".
prompt = f"""Today is {reference_date.strftime("%Y-%m-%d")}. Extract date range or "none".
June 2024 = 2024-06-01 to 2024-06-30
yesterday = {yesterday.strftime('%Y-%m-%d')} to {yesterday.strftime('%Y-%m-%d')}
last Saturday = {last_saturday.strftime('%Y-%m-%d')} to {last_saturday.strftime('%Y-%m-%d')}
yesterday = {yesterday.strftime("%Y-%m-%d")} to {yesterday.strftime("%Y-%m-%d")}
last Saturday = {last_saturday.strftime("%Y-%m-%d")} to {last_saturday.strftime("%Y-%m-%d")}
what is the weather = none
{query} ="""
@@ -448,13 +456,7 @@ what is the weather = none
inputs = {k: v.to(self.device) for k, v in inputs.items()}
with self._no_grad():
outputs = self._model.generate(
**inputs,
max_new_tokens=30,
num_beams=3,
do_sample=False,
temperature=1.0
)
outputs = self._model.generate(**inputs, max_new_tokens=30, num_beams=3, do_sample=False, temperature=1.0)
result = self._tokenizer.decode(outputs[0], skip_special_tokens=True).strip()
@@ -466,14 +468,14 @@ what is the weather = none
"""Get torch.no_grad context manager."""
try:
import torch
return torch.no_grad()
except ImportError:
from contextlib import nullcontext
return nullcontext()
def _parse_generated_output(
self, result: str, reference_date: datetime
) -> Optional[TemporalConstraint]:
def _parse_generated_output(self, result: str, reference_date: datetime) -> TemporalConstraint | None:
"""
Parse T5 generated output into TemporalConstraint.
@@ -492,7 +494,8 @@ what is the weather = none
try:
# Parse "YYYY-MM-DD to YYYY-MM-DD"
import re
pattern = r'(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})'
pattern = r"(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})"
match = re.search(pattern, result, re.IGNORECASE)
if match:
@@ -513,7 +516,7 @@ what is the weather = none
return TemporalConstraint(start_date=start_date, end_date=end_date)
except (ValueError, AttributeError) as e:
except (ValueError, AttributeError):
return None
return None
@@ -6,14 +6,45 @@ API response models should be kept separate and convert from these core models t
API stability even if internal models change.
"""
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field, ConfigDict
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
# Valid fact types for recall operations (excludes 'observation' which is internal)
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "opinion"])
class TokenUsage(BaseModel):
"""
Token usage metrics for LLM calls.
Tracks input/output tokens for a single request to enable
per-request cost tracking and monitoring.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"input_tokens": 1500,
"output_tokens": 500,
"total_tokens": 2000,
}
}
)
input_tokens: int = Field(default=0, description="Number of input/prompt tokens consumed")
output_tokens: int = Field(default=0, description="Number of output/completion tokens generated")
total_tokens: int = Field(default=0, description="Total tokens (input + output)")
def __add__(self, other: "TokenUsage") -> "TokenUsage":
"""Allow aggregating token usage from multiple calls."""
return TokenUsage(
input_tokens=self.input_tokens + other.input_tokens,
output_tokens=self.output_tokens + other.output_tokens,
total_tokens=self.total_tokens + other.total_tokens,
)
class DispositionTraits(BaseModel):
"""
Disposition traits for a memory bank.
@@ -23,17 +54,12 @@ class DispositionTraits(BaseModel):
- literalism: 1=flexible interpretation, 5=literal interpretation (how strictly to interpret information)
- empathy: 1=detached, 5=empathetic (how much to consider emotional context)
"""
skepticism: int = Field(ge=1, le=5, description="How skeptical vs trusting (1=trusting, 5=skeptical)")
literalism: int = Field(ge=1, le=5, description="How literally to interpret information (1=flexible, 5=literal)")
empathy: int = Field(ge=1, le=5, description="How much to consider emotional context (1=detached, 5=empathetic)")
model_config = ConfigDict(json_schema_extra={
"example": {
"skepticism": 3,
"literalism": 3,
"empathy": 3
}
})
model_config = ConfigDict(json_schema_extra={"example": {"skepticism": 3, "literalism": 3, "empathy": 3}})
class MemoryFact(BaseModel):
@@ -43,38 +69,44 @@ class MemoryFact(BaseModel):
This represents a unit of information stored in the memory system,
including both the content and metadata.
"""
model_config = ConfigDict(json_schema_extra={
"example": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"text": "Alice works at Google on the AI team",
"fact_type": "world",
"entities": ["Alice", "Google"],
"context": "work info",
"occurred_start": "2024-01-15T10:30:00Z",
"occurred_end": "2024-01-15T10:30:00Z",
"mentioned_at": "2024-01-15T10:30:00Z",
"document_id": "session_abc123",
"metadata": {"source": "slack"},
"chunk_id": "bank123_session_abc123_0",
"activation": 0.95
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"text": "Alice works at Google on the AI team",
"fact_type": "world",
"entities": ["Alice", "Google"],
"context": "work info",
"occurred_start": "2024-01-15T10:30:00Z",
"occurred_end": "2024-01-15T10:30:00Z",
"mentioned_at": "2024-01-15T10:30:00Z",
"document_id": "session_abc123",
"metadata": {"source": "slack"},
"chunk_id": "bank123_session_abc123_0",
"activation": 0.95,
}
}
})
)
id: str = Field(description="Unique identifier for the memory fact")
text: str = Field(description="The actual text content of the memory")
fact_type: str = Field(description="Type of fact: 'world', 'experience', 'opinion', or 'observation'")
entities: Optional[List[str]] = Field(None, description="Entity names mentioned in this fact")
context: Optional[str] = Field(None, description="Additional context for the memory")
occurred_start: Optional[str] = Field(None, description="ISO format date when the event started occurring")
occurred_end: Optional[str] = Field(None, description="ISO format date when the event ended occurring")
mentioned_at: Optional[str] = Field(None, description="ISO format date when the fact was mentioned/learned")
document_id: Optional[str] = Field(None, description="ID of the document this memory belongs to")
metadata: Optional[Dict[str, str]] = Field(None, description="User-defined metadata")
chunk_id: Optional[str] = Field(None, description="ID of the chunk this fact was extracted from (format: bank_id_document_id_chunk_index)")
entities: list[str] | None = Field(None, description="Entity names mentioned in this fact")
context: str | None = Field(None, description="Additional context for the memory")
occurred_start: str | None = Field(None, description="ISO format date when the event started occurring")
occurred_end: str | None = Field(None, description="ISO format date when the event ended occurring")
mentioned_at: str | None = Field(None, description="ISO format date when the fact was mentioned/learned")
document_id: str | None = Field(None, description="ID of the document this memory belongs to")
metadata: dict[str, str] | None = Field(None, description="User-defined metadata")
chunk_id: str | None = Field(
None, description="ID of the chunk this fact was extracted from (format: bank_id_document_id_chunk_index)"
)
class ChunkInfo(BaseModel):
"""Information about a chunk."""
chunk_text: str = Field(description="The raw chunk text")
chunk_index: int = Field(description="Index of the chunk within the document")
truncated: bool = Field(default=False, description="Whether the chunk was truncated due to token limits")
@@ -87,35 +119,33 @@ class RecallResult(BaseModel):
Contains a list of matching memory facts and optional trace information
for debugging and transparency.
"""
model_config = ConfigDict(json_schema_extra={
"example": {
"results": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"text": "Alice works at Google on the AI team",
"fact_type": "world",
"context": "work info",
"occurred_start": "2024-01-15T10:30:00Z",
"occurred_end": "2024-01-15T10:30:00Z",
"activation": 0.95
}
],
"trace": {
"query": "What did Alice say about machine learning?",
"num_results": 1
model_config = ConfigDict(
json_schema_extra={
"example": {
"results": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"text": "Alice works at Google on the AI team",
"fact_type": "world",
"context": "work info",
"occurred_start": "2024-01-15T10:30:00Z",
"occurred_end": "2024-01-15T10:30:00Z",
"activation": 0.95,
}
],
"trace": {"query": "What did Alice say about machine learning?", "num_results": 1},
}
}
})
results: List[MemoryFact] = Field(description="List of memory facts matching the query")
trace: Optional[Dict[str, Any]] = Field(None, description="Trace information for debugging")
entities: Optional[Dict[str, "EntityState"]] = Field(
None,
description="Entity states for entities mentioned in results (keyed by canonical name)"
)
chunks: Optional[Dict[str, ChunkInfo]] = Field(
None,
description="Chunks for facts, keyed by '{document_id}_{chunk_index}'"
results: list[MemoryFact] = Field(description="List of memory facts matching the query")
trace: dict[str, Any] | None = Field(None, description="Trace information for debugging")
entities: dict[str, "EntityState"] | None = Field(
None, description="Entity states for entities mentioned in results (keyed by canonical name)"
)
chunks: dict[str, ChunkInfo] | None = Field(
None, description="Chunks for facts, keyed by '{document_id}_{chunk_index}'"
)
@@ -124,38 +154,47 @@ class ReflectResult(BaseModel):
Result from a reflect operation.
Contains the formulated answer, the facts it was based on (organized by type),
and any new opinions that were formed during the reflection process.
any new opinions that were formed during the reflection process, and optionally
structured output if a response schema was provided.
"""
model_config = ConfigDict(json_schema_extra={
"example": {
"text": "Based on my knowledge, machine learning is being actively used in healthcare...",
"based_on": {
"world": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"text": "Machine learning is used in medical diagnosis",
"fact_type": "world",
"context": "healthcare",
"occurred_start": "2024-01-15T10:30:00Z",
"occurred_end": "2024-01-15T10:30:00Z"
}
],
"experience": [],
"opinion": []
},
"new_opinions": [
"Machine learning has great potential in healthcare"
]
model_config = ConfigDict(
json_schema_extra={
"example": {
"text": "Based on my knowledge, machine learning is being actively used in healthcare...",
"based_on": {
"world": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"text": "Machine learning is used in medical diagnosis",
"fact_type": "world",
"context": "healthcare",
"occurred_start": "2024-01-15T10:30:00Z",
"occurred_end": "2024-01-15T10:30:00Z",
}
],
"experience": [],
"opinion": [],
},
"new_opinions": ["Machine learning has great potential in healthcare"],
"structured_output": {"summary": "ML in healthcare", "confidence": 0.9},
"usage": {"input_tokens": 1500, "output_tokens": 500, "total_tokens": 2000},
}
}
})
)
text: str = Field(description="The formulated answer text")
based_on: Dict[str, List[MemoryFact]] = Field(
based_on: dict[str, list[MemoryFact]] = Field(
description="Facts used to formulate the answer, organized by type (world, experience, opinion)"
)
new_opinions: List[str] = Field(
default_factory=list,
description="List of newly formed opinions during reflection"
new_opinions: list[str] = Field(default_factory=list, description="List of newly formed opinions during reflection")
structured_output: dict[str, Any] | None = Field(
default=None,
description="Structured output parsed according to the provided response schema. Only present when response_schema was provided.",
)
usage: TokenUsage | None = Field(
default=None,
description="Token usage metrics for the LLM calls made during this reflect operation.",
)
@@ -166,12 +205,12 @@ class Opinion(BaseModel):
Opinions represent the bank's formed perspectives on topics,
with a confidence level indicating strength of belief.
"""
model_config = ConfigDict(json_schema_extra={
"example": {
"text": "Machine learning has great potential in healthcare",
"confidence": 0.85
model_config = ConfigDict(
json_schema_extra={
"example": {"text": "Machine learning has great potential in healthcare", "confidence": 0.85}
}
})
)
text: str = Field(description="The opinion text")
confidence: float = Field(description="Confidence score between 0.0 and 1.0")
@@ -184,15 +223,15 @@ class EntityObservation(BaseModel):
Observations are objective facts synthesized from multiple memory facts
about an entity, without personality influence.
"""
model_config = ConfigDict(json_schema_extra={
"example": {
"text": "John is detail-oriented and works at Google",
"mentioned_at": "2024-01-15T10:30:00Z"
model_config = ConfigDict(
json_schema_extra={
"example": {"text": "John is detail-oriented and works at Google", "mentioned_at": "2024-01-15T10:30:00Z"}
}
})
)
text: str = Field(description="The observation text")
mentioned_at: Optional[str] = Field(None, description="ISO format date when this observation was created")
mentioned_at: str | None = Field(None, description="ISO format date when this observation was created")
class EntityState(BaseModel):
@@ -201,20 +240,22 @@ class EntityState(BaseModel):
Contains observations synthesized from facts about the entity.
"""
model_config = ConfigDict(json_schema_extra={
"example": {
"entity_id": "123e4567-e89b-12d3-a456-426614174000",
"canonical_name": "John",
"observations": [
{"text": "John is detail-oriented", "mentioned_at": "2024-01-15T10:30:00Z"},
{"text": "John works at Google on the AI team", "mentioned_at": "2024-01-14T09:00:00Z"}
]
model_config = ConfigDict(
json_schema_extra={
"example": {
"entity_id": "123e4567-e89b-12d3-a456-426614174000",
"canonical_name": "John",
"observations": [
{"text": "John is detail-oriented", "mentioned_at": "2024-01-15T10:30:00Z"},
{"text": "John works at Google on the AI team", "mentioned_at": "2024-01-14T09:00:00Z"},
],
}
}
})
)
entity_id: str = Field(description="Unique identifier for the entity")
canonical_name: str = Field(description="Canonical name of the entity")
observations: List[EntityObservation] = Field(
default_factory=list,
description="List of observations about this entity"
observations: list[EntityObservation] = Field(
default_factory=list, description="List of observations about this entity"
)
@@ -12,23 +12,16 @@ This package contains modular components for the retain operation:
- fact_storage: Handle fact insertion into database
"""
from .types import (
RetainContent,
ExtractedFact,
ProcessedFact,
ChunkMetadata,
EntityRef,
CausalRelation,
RetainBatch
from . import (
chunk_storage,
deduplication,
embedding_processing,
entity_processing,
fact_extraction,
fact_storage,
link_creation,
)
from . import fact_extraction
from . import embedding_processing
from . import deduplication
from . import entity_processing
from . import link_creation
from . import chunk_storage
from . import fact_storage
from .types import CausalRelation, ChunkMetadata, EntityRef, ExtractedFact, ProcessedFact, RetainBatch, RetainContent
__all__ = [
# Types
@@ -5,9 +5,12 @@ bank profile utilities for disposition and background management.
import json
import logging
import re
from typing import Dict, Optional, TypedDict
from typing import TypedDict
from pydantic import BaseModel, Field
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from ..response_models import DispositionTraits
logger = logging.getLogger(__name__)
@@ -21,6 +24,7 @@ DEFAULT_DISPOSITION = {
class BankProfile(TypedDict):
"""Type for bank profile data."""
name: str
disposition: DispositionTraits
background: str
@@ -28,6 +32,7 @@ class BankProfile(TypedDict):
class BackgroundMergeResponse(BaseModel):
"""LLM response for background merge with disposition inference."""
background: str = Field(description="Merged background in first person perspective")
disposition: DispositionTraits = Field(description="Inferred disposition traits (skepticism, literalism, empathy)")
@@ -47,11 +52,11 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
async with acquire_with_retry(pool) as conn:
# Try to get existing bank
row = await conn.fetchrow(
"""
f"""
SELECT name, disposition, background
FROM banks WHERE bank_id = $1
FROM {fq_table("banks")} WHERE bank_id = $1
""",
bank_id
bank_id,
)
if row:
@@ -61,36 +66,26 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
disposition_data = json.loads(disposition_data)
return BankProfile(
name=row["name"],
disposition=DispositionTraits(**disposition_data),
background=row["background"]
name=row["name"], disposition=DispositionTraits(**disposition_data), background=row["background"]
)
# Bank doesn't exist, create with defaults
await conn.execute(
"""
INSERT INTO banks (bank_id, name, disposition, background)
f"""
INSERT INTO {fq_table("banks")} (bank_id, name, disposition, background)
VALUES ($1, $2, $3::jsonb, $4)
ON CONFLICT (bank_id) DO NOTHING
""",
bank_id,
bank_id, # Default name is the bank_id
json.dumps(DEFAULT_DISPOSITION),
""
"",
)
return BankProfile(
name=bank_id,
disposition=DispositionTraits(**DEFAULT_DISPOSITION),
background=""
)
return BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), background="")
async def update_bank_disposition(
pool,
bank_id: str,
disposition: Dict[str, int]
) -> None:
async def update_bank_disposition(pool, bank_id: str, disposition: dict[str, int]) -> None:
"""
Update bank disposition traits.
@@ -104,24 +99,18 @@ async def update_bank_disposition(
async with acquire_with_retry(pool) as conn:
await conn.execute(
"""
UPDATE banks
f"""
UPDATE {fq_table("banks")}
SET disposition = $2::jsonb,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
json.dumps(disposition)
json.dumps(disposition),
)
async def merge_bank_background(
pool,
llm_config,
bank_id: str,
new_info: str,
update_disposition: bool = True
) -> dict:
async def merge_bank_background(pool, llm_config, bank_id: str, new_info: str, update_disposition: bool = True) -> dict:
"""
Merge new background information with existing background using LLM.
Normalizes to first person ("I") and resolves conflicts.
@@ -142,12 +131,7 @@ async def merge_bank_background(
current_background = profile["background"]
# Use LLM to merge backgrounds and optionally infer disposition
result = await _llm_merge_background(
llm_config,
current_background,
new_info,
infer_disposition=update_disposition
)
result = await _llm_merge_background(llm_config, current_background, new_info, infer_disposition=update_disposition)
merged_background = result["background"]
inferred_disposition = result.get("disposition")
@@ -157,8 +141,8 @@ async def merge_bank_background(
if inferred_disposition:
# Update both background and disposition
await conn.execute(
"""
UPDATE banks
f"""
UPDATE {fq_table("banks")}
SET background = $2,
disposition = $3::jsonb,
updated_at = NOW()
@@ -166,19 +150,19 @@ async def merge_bank_background(
""",
bank_id,
merged_background,
json.dumps(inferred_disposition)
json.dumps(inferred_disposition),
)
else:
# Update only background
await conn.execute(
"""
UPDATE banks
f"""
UPDATE {fq_table("banks")}
SET background = $2,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
merged_background
merged_background,
)
response = {"background": merged_background}
@@ -188,12 +172,7 @@ async def merge_bank_background(
return response
async def _llm_merge_background(
llm_config,
current: str,
new_info: str,
infer_disposition: bool = False
) -> dict:
async def _llm_merge_background(llm_config, current: str, new_info: str, infer_disposition: bool = False) -> dict:
"""
Use LLM to intelligently merge background information.
Optionally infer Big Five disposition traits from the merged background.
@@ -273,25 +252,19 @@ Merged background:"""
response_format=BackgroundMergeResponse,
scope="bank_background",
temperature=0.3,
max_completion_tokens=8192
max_completion_tokens=8192,
)
logger.info(f"Successfully got structured response: background={parsed.background[:100]}")
# Convert Pydantic model to dict format
return {
"background": parsed.background,
"disposition": parsed.disposition.model_dump()
}
return {"background": parsed.background, "disposition": parsed.disposition.model_dump()}
except Exception as e:
logger.warning(f"Structured output failed, falling back to manual parsing: {e}")
# Fall through to manual parsing below
# Manual parsing fallback or non-disposition merge
content = await llm_config.call(
messages=messages,
scope="bank_background",
temperature=0.3,
max_completion_tokens=8192
messages=messages, scope="bank_background", temperature=0.3, max_completion_tokens=8192
)
logger.info(f"LLM response for background merge (first 500 chars): {content[:500]}")
@@ -310,7 +283,7 @@ Merged background:"""
# Method 2: Extract from markdown code blocks
if result is None:
# Remove markdown code blocks
code_block_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', content, re.DOTALL)
code_block_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", content, re.DOTALL)
if code_block_match:
try:
result = json.loads(code_block_match.group(1))
@@ -321,7 +294,9 @@ Merged background:"""
# Method 3: Find nested JSON structure
if result is None:
# Look for JSON object with nested structure
json_match = re.search(r'\{[^{}]*"background"[^{}]*"disposition"[^{}]*\{[^{}]*\}[^{}]*\}', content, re.DOTALL)
json_match = re.search(
r'\{[^{}]*"background"[^{}]*"disposition"[^{}]*\{[^{}]*\}[^{}]*\}', content, re.DOTALL
)
if json_match:
try:
result = json.loads(json_match.group())
@@ -335,7 +310,7 @@ Merged background:"""
# Fallback: use new_info as background with default disposition
return {
"background": new_info if new_info else current if current else "",
"disposition": DEFAULT_DISPOSITION.copy()
"disposition": DEFAULT_DISPOSITION.copy(),
}
# Validate disposition values
@@ -387,9 +362,9 @@ async def list_banks(pool) -> list:
"""
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
"""
f"""
SELECT bank_id, name, disposition, background, created_at, updated_at
FROM banks
FROM {fq_table("banks")}
ORDER BY updated_at DESC
"""
)
@@ -401,13 +376,15 @@ async def list_banks(pool) -> list:
if isinstance(disposition_data, str):
disposition_data = json.loads(disposition_data)
result.append({
"bank_id": row["bank_id"],
"name": row["name"],
"disposition": disposition_data,
"background": row["background"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
})
result.append(
{
"bank_id": row["bank_id"],
"name": row["name"],
"disposition": disposition_data,
"background": row["background"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
}
)
return result
@@ -3,20 +3,16 @@ Chunk storage for retain pipeline.
Handles storage of document chunks in the database.
"""
import logging
from typing import List, Dict, Optional
import logging
from ..memory_engine import fq_table
from .types import ChunkMetadata
logger = logging.getLogger(__name__)
async def store_chunks_batch(
conn,
bank_id: str,
document_id: str,
chunks: List[ChunkMetadata]
) -> Dict[int, str]:
async def store_chunks_batch(conn, bank_id: str, document_id: str, chunks: list[ChunkMetadata]) -> dict[int, str]:
"""
Store document chunks in the database.
@@ -47,24 +43,21 @@ async def store_chunks_batch(
# Batch insert all chunks
await conn.execute(
"""
INSERT INTO chunks (chunk_id, document_id, bank_id, chunk_text, chunk_index)
f"""
INSERT INTO {fq_table("chunks")} (chunk_id, document_id, bank_id, chunk_text, chunk_index)
SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::text[], $5::integer[])
""",
chunk_ids,
[document_id] * len(chunk_texts),
[bank_id] * len(chunk_texts),
chunk_texts,
chunk_indices
chunk_indices,
)
return chunk_id_map
def map_facts_to_chunks(
facts_chunk_indices: List[int],
chunk_id_map: Dict[int, str]
) -> List[Optional[str]]:
def map_facts_to_chunks(facts_chunk_indices: list[int], chunk_id_map: dict[int, str]) -> list[str | None]:
"""
Map fact chunk indices to chunk IDs.
@@ -3,22 +3,17 @@ Deduplication logic for retain pipeline.
Checks for duplicate facts using semantic similarity and temporal proximity.
"""
import logging
from datetime import datetime
from typing import List
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]:
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.
@@ -47,16 +42,12 @@ async def check_duplicates_batch(
# Defensive: if both are None (shouldn't happen), use now()
if fact_date is None:
from datetime import datetime, timezone
fact_date = datetime.now(timezone.utc)
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
)
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
@@ -68,14 +59,7 @@ async def check_duplicates_batch(
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
)
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):
@@ -84,10 +68,7 @@ async def check_duplicates_batch(
return all_is_duplicate
def filter_duplicates(
facts: List[ProcessedFact],
is_duplicate_flags: List[bool]
) -> List[ProcessedFact]:
def filter_duplicates(facts: list[ProcessedFact], is_duplicate_flags: list[bool]) -> list[ProcessedFact]:
"""
Filter out duplicate facts based on duplicate flags.
@@ -3,9 +3,8 @@ Embedding processing for retain pipeline.
Handles augmenting fact texts with temporal information and generating embeddings.
"""
import logging
from typing import List
from datetime import datetime
from . import embedding_utils
from .types import ExtractedFact
@@ -13,7 +12,7 @@ from .types import ExtractedFact
logger = logging.getLogger(__name__)
def augment_texts_with_dates(facts: List[ExtractedFact], format_date_fn) -> List[str]:
def augment_texts_with_dates(facts: list[ExtractedFact], format_date_fn) -> list[str]:
"""
Augment fact texts with readable dates for better temporal matching.
@@ -37,10 +36,7 @@ def augment_texts_with_dates(facts: List[ExtractedFact], format_date_fn) -> List
return augmented_texts
async def generate_embeddings_batch(
embeddings_model,
texts: List[str]
) -> List[List[float]]:
async def generate_embeddings_batch(embeddings_model, texts: list[str]) -> list[list[float]]:
"""
Generate embeddings for a batch of texts.
@@ -54,9 +50,6 @@ async def generate_embeddings_batch(
if not texts:
return []
embeddings = await embedding_utils.generate_embeddings_batch(
embeddings_model,
texts
)
embeddings = await embedding_utils.generate_embeddings_batch(embeddings_model, texts)
return embeddings
@@ -4,12 +4,11 @@ Embedding generation utilities for memory units.
import asyncio
import logging
from typing import List
logger = logging.getLogger(__name__)
def generate_embedding(embeddings_backend, text: str) -> List[float]:
def generate_embedding(embeddings_backend, text: str) -> list[float]:
"""
Generate embedding for text using the provided embeddings backend.
@@ -27,7 +26,7 @@ def generate_embedding(embeddings_backend, text: str) -> List[float]:
raise Exception(f"Failed to generate embedding: {str(e)}")
async def generate_embeddings_batch(embeddings_backend, texts: List[str]) -> List[List[float]]:
async def generate_embeddings_batch(embeddings_backend, texts: list[str]) -> list[list[float]]:
"""
Generate embeddings for multiple texts using the provided embeddings backend.
@@ -47,7 +46,7 @@ async def generate_embeddings_batch(embeddings_backend, texts: List[str]) -> Lis
embeddings = await loop.run_in_executor(
None, # Use default thread pool
embeddings_backend.encode,
texts
texts,
)
return embeddings
except Exception as e:
@@ -3,12 +3,11 @@ Entity processing for retain pipeline.
Handles entity extraction, resolution, and link creation for stored facts.
"""
import logging
from typing import List, Tuple, Dict, Any
from uuid import UUID
from .types import ProcessedFact, EntityRef, EntityLink
import logging
from . import link_utils
from .types import EntityLink, ProcessedFact
logger = logging.getLogger(__name__)
@@ -17,18 +16,20 @@ async def process_entities_batch(
entity_resolver,
conn,
bank_id: str,
unit_ids: List[str],
facts: List[ProcessedFact],
log_buffer: List[str] = None
) -> List[EntityLink]:
unit_ids: list[str],
facts: list[ProcessedFact],
log_buffer: list[str] = None,
user_entities_per_content: dict[int, list[dict]] = None,
) -> list[EntityLink]:
"""
Process entities for all facts and create entity links.
This function:
1. Extracts entity mentions from fact texts
2. Resolves entity names to canonical entities
3. Creates entity records in the database
4. Returns entity links ready for insertion
2. Merges user-provided entities with LLM-extracted entities
3. Resolves entity names to canonical entities
4. Creates entity records in the database
5. Returns entity links ready for insertion
Args:
entity_resolver: EntityResolver instance for entity resolution
@@ -37,6 +38,7 @@ async def process_entities_batch(
unit_ids: List of unit IDs (same length as facts)
facts: List of ProcessedFact objects
log_buffer: Optional buffer for detailed logging
user_entities_per_content: Dict mapping content_index to list of user-provided entities
Returns:
List of EntityLink objects for batch insertion
@@ -47,15 +49,35 @@ async def process_entities_batch(
if len(unit_ids) != len(facts):
raise ValueError(f"Mismatch between unit_ids ({len(unit_ids)}) and facts ({len(facts)})")
user_entities_per_content = user_entities_per_content or {}
# Extract data for link_utils function
fact_texts = [fact.fact_text for fact in facts]
# Use occurred_start if available, otherwise use mentioned_at for entity timestamps
fact_dates = [fact.occurred_start if fact.occurred_start is not None else fact.mentioned_at for fact in facts]
# Convert EntityRef objects to dict format expected by link_utils
entities_per_fact = [
[{'text': entity.name, 'type': 'CONCEPT'} for entity in (fact.entities or [])]
for fact in facts
]
# Convert EntityRef objects to dict format and merge with user-provided entities
entities_per_fact = []
for fact in facts:
# Start with LLM-extracted entities
llm_entities = [{"text": entity.name, "type": "CONCEPT"} for entity in (fact.entities or [])]
# Get user entities for this content (use content_index from fact)
user_entities = user_entities_per_content.get(fact.content_index, [])
# Merge with case-insensitive deduplication
seen_texts = {e["text"].lower() for e in llm_entities}
for user_entity in user_entities:
if user_entity["text"].lower() not in seen_texts:
llm_entities.append(
{
"text": user_entity["text"],
"type": user_entity.get("type", "CONCEPT"),
}
)
seen_texts.add(user_entity["text"].lower())
entities_per_fact.append(llm_entities)
# Use existing link_utils function for entity processing
entity_links = await link_utils.extract_entities_batch_optimized(
@@ -67,16 +89,13 @@ async def process_entities_batch(
"", # context (not used in current implementation)
fact_dates,
entities_per_fact,
log_buffer # Pass log_buffer for detailed logging
log_buffer, # Pass log_buffer for detailed logging
)
return entity_links
async def insert_entity_links_batch(
conn,
entity_links: List[EntityLink]
) -> None:
async def insert_entity_links_batch(conn, entity_links: list[EntityLink]) -> None:
"""
Insert entity links in batch.
File diff suppressed because it is too large Load Diff
@@ -3,22 +3,19 @@ Fact storage for retain pipeline.
Handles insertion of facts into the database.
"""
import logging
import json
from typing import List, Optional
from uuid import UUID
import json
import logging
from ..memory_engine import fq_table
from .types import ProcessedFact
logger = logging.getLogger(__name__)
async def insert_facts_batch(
conn,
bank_id: str,
facts: List[ProcessedFact],
document_id: Optional[str] = None
) -> List[str]:
conn, bank_id: str, facts: list[ProcessedFact], document_id: str | None = None
) -> list[str]:
"""
Insert facts into the database in batch.
@@ -62,7 +59,7 @@ async def insert_facts_batch(
contexts.append(fact.context)
fact_types.append(fact.fact_type)
# confidence_score is only for opinion facts
confidence_scores.append(1.0 if fact.fact_type == 'opinion' else None)
confidence_scores.append(1.0 if fact.fact_type == "opinion" else None)
access_counts.append(0) # Initial access count
metadata_jsons.append(json.dumps(fact.metadata))
chunk_ids.append(fact.chunk_id)
@@ -71,8 +68,8 @@ async def insert_facts_batch(
# Batch insert all facts
results = await conn.fetch(
"""
INSERT INTO memory_units (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
f"""
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id)
SELECT $1, * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
@@ -93,10 +90,10 @@ async def insert_facts_batch(
access_counts,
metadata_jsons,
chunk_ids,
document_ids
document_ids,
)
unit_ids = [str(row['id']) for row in results]
unit_ids = [str(row["id"]) for row in results]
return unit_ids
@@ -111,25 +108,20 @@ async def ensure_bank_exists(conn, bank_id: str) -> None:
bank_id: Bank identifier
"""
await conn.execute(
"""
INSERT INTO banks (bank_id, disposition, background)
f"""
INSERT INTO {fq_table("banks")} (bank_id, disposition, background)
VALUES ($1, $2::jsonb, $3)
ON CONFLICT (bank_id) DO UPDATE
SET updated_at = NOW()
""",
bank_id,
'{"skepticism": 3, "literalism": 3, "empathy": 3}',
""
"",
)
async def handle_document_tracking(
conn,
bank_id: str,
document_id: str,
combined_content: str,
is_first_batch: bool,
retain_params: Optional[dict] = None
conn, bank_id: str, document_id: str, combined_content: str, is_first_batch: bool, retain_params: dict | None = None
) -> None:
"""
Handle document tracking in the database.
@@ -151,14 +143,13 @@ async def handle_document_tracking(
# Only delete on the first batch to avoid deleting data we just inserted
if is_first_batch:
await conn.fetchval(
"DELETE FROM documents WHERE id = $1 AND bank_id = $2 RETURNING id",
document_id, bank_id
f"DELETE FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 RETURNING id", document_id, bank_id
)
# Insert document (or update if exists from concurrent operations)
await conn.execute(
"""
INSERT INTO documents (id, bank_id, original_text, content_hash, metadata, retain_params)
f"""
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, metadata, retain_params)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id, bank_id) DO UPDATE
SET original_text = EXCLUDED.original_text,
@@ -172,5 +163,5 @@ async def handle_document_tracking(
combined_content,
content_hash,
json.dumps({}), # Empty metadata dict
json.dumps(retain_params) if retain_params else None
json.dumps(retain_params) if retain_params else None,
)
@@ -3,20 +3,16 @@ Link creation for retain pipeline.
Handles creation of temporal, semantic, and causal links between facts.
"""
import logging
from typing import List
from .types import ProcessedFact, CausalRelation
import logging
from . import link_utils
from .types import ProcessedFact
logger = logging.getLogger(__name__)
async def create_temporal_links_batch(
conn,
bank_id: str,
unit_ids: List[str]
) -> int:
async def create_temporal_links_batch(conn, bank_id: str, unit_ids: list[str]) -> int:
"""
Create temporal links between facts.
@@ -33,20 +29,10 @@ async def create_temporal_links_batch(
if not unit_ids:
return 0
return await link_utils.create_temporal_links_batch_per_fact(
conn,
bank_id,
unit_ids,
log_buffer=[]
)
return await link_utils.create_temporal_links_batch_per_fact(conn, bank_id, unit_ids, log_buffer=[])
async def create_semantic_links_batch(
conn,
bank_id: str,
unit_ids: List[str],
embeddings: List[List[float]]
) -> int:
async def create_semantic_links_batch(conn, bank_id: str, unit_ids: list[str], embeddings: list[list[float]]) -> int:
"""
Create semantic links between facts.
@@ -67,20 +53,10 @@ async def create_semantic_links_batch(
if len(unit_ids) != len(embeddings):
raise ValueError(f"Mismatch between unit_ids ({len(unit_ids)}) and embeddings ({len(embeddings)})")
return await link_utils.create_semantic_links_batch(
conn,
bank_id,
unit_ids,
embeddings,
log_buffer=[]
)
return await link_utils.create_semantic_links_batch(conn, bank_id, unit_ids, embeddings, log_buffer=[])
async def create_causal_links_batch(
conn,
unit_ids: List[str],
facts: List[ProcessedFact]
) -> int:
async def create_causal_links_batch(conn, unit_ids: list[str], facts: list[ProcessedFact]) -> int:
"""
Create causal links between facts.
@@ -108,9 +84,9 @@ async def create_causal_links_batch(
# Convert CausalRelation objects to dicts
relations_dicts = [
{
'relation_type': rel.relation_type,
'target_fact_index': rel.target_fact_index,
'strength': rel.strength
"relation_type": rel.relation_type,
"target_fact_index": rel.target_fact_index,
"strength": rel.strength,
}
for rel in fact.causal_relations
]
@@ -118,10 +94,6 @@ async def create_causal_links_batch(
else:
causal_relations_per_fact.append([])
link_count = await link_utils.create_causal_links_batch(
conn,
unit_ids,
causal_relations_per_fact
)
link_count = await link_utils.create_causal_links_batch(conn, unit_ids, causal_relations_per_fact)
return link_count
@@ -2,12 +2,12 @@
Link creation utilities for temporal, semantic, and entity links.
"""
import time
import logging
from typing import List
from datetime import timedelta, datetime, timezone
import time
from datetime import UTC, datetime, timedelta
from uuid import UUID
from ..memory_engine import fq_table
from .types import EntityLink
logger = logging.getLogger(__name__)
@@ -19,7 +19,7 @@ def _normalize_datetime(dt):
return None
if dt.tzinfo is None:
# Naive datetime - assume UTC
return dt.replace(tzinfo=timezone.utc)
return dt.replace(tzinfo=UTC)
return dt
@@ -54,24 +54,26 @@ def compute_temporal_links(
try:
time_lower = unit_event_date_norm - timedelta(hours=time_window_hours)
except OverflowError:
time_lower = datetime.min.replace(tzinfo=timezone.utc)
time_lower = datetime.min.replace(tzinfo=UTC)
try:
time_upper = unit_event_date_norm + timedelta(hours=time_window_hours)
except OverflowError:
time_upper = datetime.max.replace(tzinfo=timezone.utc)
time_upper = datetime.max.replace(tzinfo=UTC)
# Filter candidates within this unit's time window
matching_neighbors = [
(row['id'], row['event_date'])
(row["id"], row["event_date"])
for row in candidates
if time_lower <= _normalize_datetime(row['event_date']) <= time_upper
if time_lower <= _normalize_datetime(row["event_date"]) <= time_upper
][:10] # Limit to top 10
for recent_id, recent_event_date in matching_neighbors:
# Calculate temporal proximity weight
time_diff_hours = abs((unit_event_date_norm - _normalize_datetime(recent_event_date)).total_seconds() / 3600)
time_diff_hours = abs(
(unit_event_date_norm - _normalize_datetime(recent_event_date)).total_seconds() / 3600
)
weight = max(0.3, 1.0 - (time_diff_hours / time_window_hours))
links.append((unit_id, str(recent_id), 'temporal', weight, None))
links.append((unit_id, str(recent_id), "temporal", weight, None))
return links
@@ -99,17 +101,17 @@ def compute_temporal_query_bounds(
try:
min_date = min(all_dates) - timedelta(hours=time_window_hours)
except OverflowError:
min_date = datetime.min.replace(tzinfo=timezone.utc)
min_date = datetime.min.replace(tzinfo=UTC)
try:
max_date = max(all_dates) + timedelta(hours=time_window_hours)
except OverflowError:
max_date = datetime.max.replace(tzinfo=timezone.utc)
max_date = datetime.max.replace(tzinfo=UTC)
return min_date, max_date
def _log(log_buffer, message, level='info'):
def _log(log_buffer, message, level="info"):
"""Helper to log to buffer if available, otherwise use logger.
Args:
@@ -117,7 +119,7 @@ def _log(log_buffer, message, level='info'):
message: The log message
level: 'info', 'debug', 'warning', or 'error'. Debug messages are not added to buffer.
"""
if level == 'debug':
if level == "debug":
# Debug messages only go to logger, not to buffer
logger.debug(message)
return
@@ -125,23 +127,23 @@ def _log(log_buffer, message, level='info'):
if log_buffer is not None:
log_buffer.append(message)
else:
if level == 'info':
if level == "info":
logger.info(message)
else:
logger.log(logging.WARNING if level == 'warning' else logging.ERROR, message)
logger.log(logging.WARNING if level == "warning" else logging.ERROR, message)
async def extract_entities_batch_optimized(
entity_resolver,
conn,
bank_id: str,
unit_ids: List[str],
sentences: List[str],
unit_ids: list[str],
sentences: list[str],
context: str,
fact_dates: List,
llm_entities: List[List[dict]],
log_buffer: List[str] = None,
) -> List[tuple]:
fact_dates: list,
llm_entities: list[list[dict]],
log_buffer: list[str] = None,
) -> list[tuple]:
"""
Process LLM-extracted entities for ALL facts in batch.
@@ -171,15 +173,19 @@ async def extract_entities_batch_optimized(
formatted_entities = []
for ent in entity_list:
# Handle both Entity objects and dicts
if hasattr(ent, 'text'):
if hasattr(ent, "text"):
# Entity objects only have 'text', default type to 'CONCEPT'
formatted_entities.append({'text': ent.text, 'type': 'CONCEPT'})
formatted_entities.append({"text": ent.text, "type": "CONCEPT"})
elif isinstance(ent, dict):
formatted_entities.append({'text': ent.get('text', ''), 'type': ent.get('type', 'CONCEPT')})
formatted_entities.append({"text": ent.get("text", ""), "type": ent.get("type", "CONCEPT")})
all_entities.append(formatted_entities)
total_entities = sum(len(ents) for ents in all_entities)
_log(log_buffer, f" [6.1] Process LLM entities: {total_entities} entities from {len(sentences)} facts in {time.time() - substep_start:.3f}s", level='debug')
_log(
log_buffer,
f" [6.1] Process LLM entities: {total_entities} entities from {len(sentences)} facts in {time.time() - substep_start:.3f}s",
level="debug",
)
# Step 2: Resolve entities in BATCH (much faster!)
substep_start = time.time()
@@ -195,13 +201,19 @@ async def extract_entities_batch_optimized(
continue
for local_idx, entity in enumerate(entities):
all_entities_flat.append({
'text': entity['text'],
'type': entity['type'],
'nearby_entities': entities,
})
all_entities_flat.append(
{
"text": entity["text"],
"type": entity["type"],
"nearby_entities": entities,
}
)
entity_to_unit.append((unit_id, local_idx, fact_date))
_log(log_buffer, f" [6.2.1] Prepare entities: {len(all_entities_flat)} entities in {time.time() - substep_6_2_1_start:.3f}s", level='debug')
_log(
log_buffer,
f" [6.2.1] Prepare entities: {len(all_entities_flat)} entities in {time.time() - substep_6_2_1_start:.3f}s",
level="debug",
)
# Resolve ALL entities in one batch call
if all_entities_flat:
@@ -210,7 +222,7 @@ async def extract_entities_batch_optimized(
# Add per-entity dates to entity data for batch resolution
for idx, (unit_id, local_idx, fact_date) in enumerate(entity_to_unit):
all_entities_flat[idx]['event_date'] = fact_date
all_entities_flat[idx]["event_date"] = fact_date
# Resolve ALL entities in ONE batch call (much faster than sequential buckets)
# INSERT ... ON CONFLICT handles any race conditions at the DB level
@@ -219,10 +231,14 @@ async def extract_entities_batch_optimized(
entities_data=all_entities_flat,
context=context,
unit_event_date=None, # Not used when per-entity dates provided
conn=conn # Use main transaction connection
conn=conn, # Use main transaction connection
)
_log(log_buffer, f" [6.2.2] Resolve entities: {len(all_entities_flat)} entities in single batch in {time.time() - substep_6_2_2_start:.3f}s", level='debug')
_log(
log_buffer,
f" [6.2.2] Resolve entities: {len(all_entities_flat)} entities in single batch in {time.time() - substep_6_2_2_start:.3f}s",
level="debug",
)
# [6.2.3] Create unit-entity links in BATCH
substep_6_2_3_start = time.time()
@@ -239,12 +255,24 @@ async def extract_entities_batch_optimized(
# Batch insert all unit-entity links (MUCH faster!)
await entity_resolver.link_units_to_entities_batch(unit_entity_pairs, conn=conn)
_log(log_buffer, f" [6.2.3] Create unit-entity links (batched): {len(unit_entity_pairs)} links in {time.time() - substep_6_2_3_start:.3f}s", level='debug')
_log(
log_buffer,
f" [6.2.3] Create unit-entity links (batched): {len(unit_entity_pairs)} links in {time.time() - substep_6_2_3_start:.3f}s",
level="debug",
)
_log(log_buffer, f" [6.2] Entity resolution (batched): {len(all_entities_flat)} entities resolved in {time.time() - step_6_2_start:.3f}s", level='debug')
_log(
log_buffer,
f" [6.2] Entity resolution (batched): {len(all_entities_flat)} entities resolved in {time.time() - step_6_2_start:.3f}s",
level="debug",
)
else:
unit_to_entity_ids = {}
_log(log_buffer, f" [6.2] Entity resolution (batched): 0 entities in {time.time() - step_6_2_start:.3f}s", level='debug')
_log(
log_buffer,
f" [6.2] Entity resolution (batched): 0 entities in {time.time() - step_6_2_start:.3f}s",
level="debug",
)
# Step 3: Create entity links between units that share entities
substep_start = time.time()
@@ -253,39 +281,44 @@ async def extract_entities_batch_optimized(
for entity_ids in unit_to_entity_ids.values():
all_entity_ids.update(entity_ids)
_log(log_buffer, f" [6.3] Creating entity links for {len(all_entity_ids)} unique entities...", level='debug')
_log(log_buffer, f" [6.3] Creating entity links for {len(all_entity_ids)} unique entities...", level="debug")
# Find all units that reference these entities (ONE batched query)
entity_to_units = {}
if all_entity_ids:
query_start = time.time()
import uuid
entity_id_list = [uuid.UUID(eid) if isinstance(eid, str) else eid for eid in all_entity_ids]
rows = await conn.fetch(
"""
f"""
SELECT entity_id, unit_id
FROM unit_entities
FROM {fq_table("unit_entities")}
WHERE entity_id = ANY($1::uuid[])
""",
entity_id_list
entity_id_list,
)
_log(
log_buffer,
f" [6.3.1] Query unit_entities: {len(rows)} rows in {time.time() - query_start:.3f}s",
level="debug",
)
_log(log_buffer, f" [6.3.1] Query unit_entities: {len(rows)} rows in {time.time() - query_start:.3f}s", level='debug')
# Group by entity_id
group_start = time.time()
for row in rows:
entity_id = row['entity_id']
entity_id = row["entity_id"]
if entity_id not in entity_to_units:
entity_to_units[entity_id] = []
entity_to_units[entity_id].append(row['unit_id'])
_log(log_buffer, f" [6.3.2] Group by entity_id: {time.time() - group_start:.3f}s", level='debug')
entity_to_units[entity_id].append(row["unit_id"])
_log(log_buffer, f" [6.3.2] Group by entity_id: {time.time() - group_start:.3f}s", level="debug")
# Create bidirectional links between units that share entities
# OPTIMIZATION: Limit links per entity to avoid N² explosion
# Only link each new unit to the most recent MAX_LINKS_PER_ENTITY units
MAX_LINKS_PER_ENTITY = 50 # Limit to prevent explosion when entity appears in many facts
link_gen_start = time.time()
links: List[EntityLink] = []
links: list[EntityLink] = []
new_unit_set = set(unit_ids) # Units from this batch
def to_uuid(val) -> UUID:
@@ -299,27 +332,52 @@ async def extract_entities_batch_optimized(
# Link new units to each other (within batch) - also limited
# For very common entities, limit within-batch links too
new_units_to_link = new_units[-MAX_LINKS_PER_ENTITY:] if len(new_units) > MAX_LINKS_PER_ENTITY else new_units
new_units_to_link = (
new_units[-MAX_LINKS_PER_ENTITY:] if len(new_units) > MAX_LINKS_PER_ENTITY else new_units
)
for i, unit_id_1 in enumerate(new_units_to_link):
for unit_id_2 in new_units_to_link[i+1:]:
links.append(EntityLink(from_unit_id=to_uuid(unit_id_1), to_unit_id=to_uuid(unit_id_2), entity_id=entity_uuid))
links.append(EntityLink(from_unit_id=to_uuid(unit_id_2), to_unit_id=to_uuid(unit_id_1), entity_id=entity_uuid))
for unit_id_2 in new_units_to_link[i + 1 :]:
links.append(
EntityLink(
from_unit_id=to_uuid(unit_id_1), to_unit_id=to_uuid(unit_id_2), entity_id=entity_uuid
)
)
links.append(
EntityLink(
from_unit_id=to_uuid(unit_id_2), to_unit_id=to_uuid(unit_id_1), entity_id=entity_uuid
)
)
# Link new units to LIMITED existing units (most recent)
existing_to_link = existing_units[-MAX_LINKS_PER_ENTITY:] # Take most recent
for new_unit in new_units:
for existing_unit in existing_to_link:
links.append(EntityLink(from_unit_id=to_uuid(new_unit), to_unit_id=to_uuid(existing_unit), entity_id=entity_uuid))
links.append(EntityLink(from_unit_id=to_uuid(existing_unit), to_unit_id=to_uuid(new_unit), entity_id=entity_uuid))
links.append(
EntityLink(
from_unit_id=to_uuid(new_unit), to_unit_id=to_uuid(existing_unit), entity_id=entity_uuid
)
)
links.append(
EntityLink(
from_unit_id=to_uuid(existing_unit), to_unit_id=to_uuid(new_unit), entity_id=entity_uuid
)
)
_log(log_buffer, f" [6.3.3] Generate {len(links)} links: {time.time() - link_gen_start:.3f}s", level='debug')
_log(log_buffer, f" [6.3] Entity link creation: {len(links)} links for {len(all_entity_ids)} unique entities in {time.time() - substep_start:.3f}s", level='debug')
_log(
log_buffer, f" [6.3.3] Generate {len(links)} links: {time.time() - link_gen_start:.3f}s", level="debug"
)
_log(
log_buffer,
f" [6.3] Entity link creation: {len(links)} links for {len(all_entity_ids)} unique entities in {time.time() - substep_start:.3f}s",
level="debug",
)
return links
except Exception as e:
logger.error(f"Failed to extract entities in batch: {str(e)}")
import traceback
traceback.print_exc()
raise
@@ -327,9 +385,9 @@ async def extract_entities_batch_optimized(
async def create_temporal_links_batch_per_fact(
conn,
bank_id: str,
unit_ids: List[str],
unit_ids: list[str],
time_window_hours: int = 24,
log_buffer: List[str] = None,
log_buffer: list[str] = None,
) -> int:
"""
Create temporal links for multiple units, each with their own event_date.
@@ -356,15 +414,18 @@ async def create_temporal_links_batch_per_fact(
# Get the event_date for each new unit
fetch_dates_start = time_mod.time()
rows = await conn.fetch(
"""
f"""
SELECT id, event_date
FROM memory_units
FROM {fq_table("memory_units")}
WHERE id::text = ANY($1)
""",
unit_ids
unit_ids,
)
new_units = {str(row["id"]): row["event_date"] for row in rows}
_log(
log_buffer,
f" [7.1] Fetch event_dates for {len(unit_ids)} units: {time_mod.time() - fetch_dates_start:.3f}s",
)
new_units = {str(row['id']): row['event_date'] for row in rows}
_log(log_buffer, f" [7.1] Fetch event_dates for {len(unit_ids)} units: {time_mod.time() - fetch_dates_start:.3f}s")
# Fetch ALL potential temporal neighbors in ONE query (much faster!)
# Get time range across all units with overflow protection
@@ -372,9 +433,9 @@ async def create_temporal_links_batch_per_fact(
fetch_neighbors_start = time_mod.time()
all_candidates = await conn.fetch(
"""
f"""
SELECT id, event_date
FROM memory_units
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND event_date BETWEEN $2 AND $3
AND id::text != ALL($4)
@@ -383,9 +444,12 @@ async def create_temporal_links_batch_per_fact(
bank_id,
min_date,
max_date,
unit_ids
unit_ids,
)
_log(
log_buffer,
f" [7.2] Fetch {len(all_candidates)} candidate neighbors (1 query): {time_mod.time() - fetch_neighbors_start:.3f}s",
)
_log(log_buffer, f" [7.2] Fetch {len(all_candidates)} candidate neighbors (1 query): {time_mod.time() - fetch_neighbors_start:.3f}s")
# Filter and create links in memory (much faster than N queries)
link_gen_start = time_mod.time()
@@ -408,20 +472,20 @@ async def create_temporal_links_batch_per_fact(
if time_diff_hours <= time_window_hours:
weight = max(0.3, 1.0 - (time_diff_hours / time_window_hours))
# Create bidirectional links
links.append((unit_id, other_id, 'temporal', weight, None))
links.append((other_id, unit_id, 'temporal', weight, None))
links.append((unit_id, other_id, "temporal", weight, None))
links.append((other_id, unit_id, "temporal", weight, None))
_log(log_buffer, f" [7.3] Generate {len(links)} temporal links: {time_mod.time() - link_gen_start:.3f}s")
if links:
insert_start = time_mod.time()
await conn.executemany(
"""
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
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
""",
links
links,
)
_log(log_buffer, f" [7.4] Insert {len(links)} temporal links: {time_mod.time() - insert_start:.3f}s")
@@ -430,6 +494,7 @@ async def create_temporal_links_batch_per_fact(
except Exception as e:
logger.error(f"Failed to create temporal links: {str(e)}")
import traceback
traceback.print_exc()
raise
@@ -437,11 +502,11 @@ async def create_temporal_links_batch_per_fact(
async def create_semantic_links_batch(
conn,
bank_id: str,
unit_ids: List[str],
embeddings: List[List[float]],
unit_ids: list[str],
embeddings: list[list[float]],
top_k: int = 5,
threshold: float = 0.7,
log_buffer: List[str] = None,
log_buffer: list[str] = None,
) -> int:
"""
Create semantic links for multiple units efficiently.
@@ -465,22 +530,26 @@ async def create_semantic_links_batch(
try:
import time as time_mod
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 memory_units
FROM {fq_table("memory_units")}
WHERE bank_id = $1
AND embedding IS NOT NULL
AND id::text != ALL($2)
""",
bank_id,
unit_ids
unit_ids,
)
_log(
log_buffer,
f" [8.1] Fetch {len(all_existing)} existing embeddings (1 query): {time_mod.time() - fetch_start:.3f}s",
)
_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()
@@ -488,15 +557,16 @@ async def create_semantic_links_batch(
if all_existing:
# Convert existing embeddings to numpy array
existing_ids = [str(row['id']) for row in all_existing]
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']
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)
@@ -537,7 +607,7 @@ async def create_semantic_links_batch(
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))
all_links.append((unit_id, similar_id, "semantic", similarity, None))
# 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
@@ -565,32 +635,38 @@ async def create_semantic_links_batch(
other_id = unit_ids[other_idx]
# Clamp to [0, 1] to handle floating point precision issues
similarity = float(min(1.0, max(0.0, similarities[local_idx])))
all_links.append((unit_id, other_id, 'semantic', similarity, None))
all_links.append((unit_id, other_id, "semantic", similarity, None))
_log(log_buffer, f" [8.2] Compute similarities & generate {len(all_links)} semantic links: {time_mod.time() - compute_start:.3f}s")
_log(
log_buffer,
f" [8.2] Compute similarities & generate {len(all_links)} semantic links: {time_mod.time() - compute_start:.3f}s",
)
if all_links:
insert_start = time_mod.time()
await conn.executemany(
"""
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
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
""",
all_links
all_links,
)
_log(
log_buffer, f" [8.3] Insert {len(all_links)} semantic links: {time_mod.time() - insert_start:.3f}s"
)
_log(log_buffer, f" [8.3] Insert {len(all_links)} semantic links: {time_mod.time() - insert_start:.3f}s")
return len(all_links)
except Exception as e:
logger.error(f"Failed to create semantic links: {str(e)}")
import traceback
traceback.print_exc()
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 = 50000):
"""
Insert all entity links using COPY to temp table + INSERT for maximum speed.
@@ -606,7 +682,6 @@ async def insert_entity_links_batch(conn, links: List[EntityLink], chunk_size: i
if not links:
return
import uuid as uuid_mod
import time as time_mod
total_start = time_mod.time()
@@ -633,28 +708,22 @@ async def insert_entity_links_batch(conn, links: List[EntityLink], chunk_size: i
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.append((link.from_unit_id, link.to_unit_id, link.link_type, link.weight, link.entity_id))
logger.debug(f" [9.3] Convert {len(records)} records: {time_mod.time() - convert_start:.3f}s")
# Bulk load using COPY (fastest method)
copy_start = time_mod.time()
await conn.copy_records_to_table(
'_temp_entity_links',
"_temp_entity_links",
records=records,
columns=['from_unit_id', 'to_unit_id', 'link_type', 'weight', 'entity_id']
columns=["from_unit_id", "to_unit_id", "link_type", "weight", "entity_id"],
)
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_start = time_mod.time()
await conn.execute("""
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
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
@@ -665,8 +734,8 @@ async def insert_entity_links_batch(conn, links: List[EntityLink], chunk_size: i
async def create_causal_links_batch(
conn,
unit_ids: List[str],
causal_relations_per_fact: List[List[dict]],
unit_ids: list[str],
causal_relations_per_fact: list[list[dict]],
) -> int:
"""
Create causal links between facts based on LLM-extracted causal relationships.
@@ -694,6 +763,7 @@ async def create_causal_links_batch(
try:
import time as time_mod
create_start = time_mod.time()
# Build links list
@@ -705,12 +775,12 @@ async def create_causal_links_batch(
from_unit_id = unit_ids[fact_idx]
for relation in causal_relations:
target_idx = relation['target_fact_index']
relation_type = relation['relation_type']
strength = relation.get('strength', 1.0)
target_idx = relation["target_fact_index"]
relation_type = relation["relation_type"]
strength = relation.get("strength", 1.0)
# Validate relation_type - must match database constraint
valid_types = {'causes', 'caused_by', 'enables', 'prevents'}
valid_types = {"causes", "caused_by", "enables", "prevents"}
if relation_type not in valid_types:
logger.error(
f"Invalid relation_type '{relation_type}' (type: {type(relation_type).__name__}) "
@@ -735,24 +805,25 @@ async def create_causal_links_batch(
# weight is the strength of the relationship
links.append((from_unit_id, to_unit_id, relation_type, strength, None))
if links:
insert_start = time_mod.time()
try:
await conn.executemany(
"""
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
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
""",
links
links,
)
except Exception as db_error:
# Log the actual data being inserted for debugging
logger.error(f"Database insert failed for causal links. Error: {db_error}")
logger.error(f"Attempted to insert {len(links)} links. First few:")
for i, link in enumerate(links[:3]):
logger.error(f" Link {i}: from={link[0]}, to={link[1]}, type='{link[2]}' (repr={repr(link[2])}), weight={link[3]}, entity={link[4]}")
logger.error(
f" Link {i}: from={link[0]}, to={link[1]}, type='{link[2]}' (repr={repr(link[2])}), weight={link[3]}, entity={link[4]}"
)
raise
return len(links)
@@ -760,5 +831,6 @@ async def create_causal_links_batch(
except Exception as e:
logger.error(f"Failed to create causal links: {str(e)}")
import traceback
traceback.print_exc()
raise
@@ -3,15 +3,16 @@ Observation regeneration for retain pipeline.
Regenerates entity observations as part of the retain transaction.
"""
import logging
import time
import uuid
from datetime import datetime, timezone
from typing import List, Dict, Optional
from datetime import UTC, datetime
from ...config import get_config
from ..memory_engine import fq_table
from ..search import observation_utils
from . import embedding_utils
from ..db_utils import acquire_with_retry
from .types import EntityLink
logger = logging.getLogger(__name__)
@@ -19,12 +20,12 @@ logger = logging.getLogger(__name__)
def utcnow():
"""Get current UTC time."""
return datetime.now(timezone.utc)
return datetime.now(UTC)
# Simple dataclass-like container for facts (avoid importing from memory_engine)
class MemoryFactForObservation:
def __init__(self, id: str, text: str, fact_type: str, context: str, occurred_start: Optional[str]):
def __init__(self, id: str, text: str, fact_type: str, context: str, occurred_start: str | None):
self.id = id
self.text = text
self.fact_type = fact_type
@@ -33,12 +34,7 @@ class MemoryFactForObservation:
async def regenerate_observations_batch(
conn,
embeddings_model,
llm_config,
bank_id: str,
entity_links: List[EntityLink],
log_buffer: List[str] = None
conn, embeddings_model, llm_config, bank_id: str, entity_links: list[EntityLink], log_buffer: list[str] = None
) -> None:
"""
Regenerate observations for top entities in this batch.
@@ -54,14 +50,15 @@ async def regenerate_observations_batch(
entity_links: Entity links from this batch
log_buffer: Optional log buffer for timing
"""
TOP_N_ENTITIES = 5
MIN_FACTS_THRESHOLD = 5
config = get_config()
TOP_N_ENTITIES = config.observation_top_entities
MIN_FACTS_THRESHOLD = config.observation_min_facts
if not entity_links:
return
# Count mentions per entity in this batch
entity_mention_counts: Dict[str, int] = {}
entity_mention_counts: dict[str, int] = {}
for link in entity_links:
if link.entity_id:
entity_id = str(link.entity_id)
@@ -71,11 +68,7 @@ async def regenerate_observations_batch(
return
# Sort by mention count descending and take top N
sorted_entities = sorted(
entity_mention_counts.items(),
key=lambda x: x[1],
reverse=True
)
sorted_entities = sorted(entity_mention_counts.items(), key=lambda x: x[1], reverse=True)
entities_to_process = [e[0] for e in sorted_entities[:TOP_N_ENTITIES]]
obs_start = time.time()
@@ -85,26 +78,28 @@ async def regenerate_observations_batch(
# Batch query for entity names
entity_rows = await conn.fetch(
"""
SELECT id, canonical_name FROM entities
f"""
SELECT id, canonical_name FROM {fq_table("entities")}
WHERE id = ANY($1) AND bank_id = $2
""",
entity_uuids, bank_id
entity_uuids,
bank_id,
)
entity_names = {row['id']: row['canonical_name'] for row in entity_rows}
entity_names = {row["id"]: row["canonical_name"] for row in entity_rows}
# Batch query for fact counts
fact_counts = await conn.fetch(
"""
f"""
SELECT ue.entity_id, COUNT(*) as cnt
FROM unit_entities ue
JOIN memory_units mu ON ue.unit_id = mu.id
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("memory_units")} mu ON ue.unit_id = mu.id
WHERE ue.entity_id = ANY($1) AND mu.bank_id = $2
GROUP BY ue.entity_id
""",
entity_uuids, bank_id
entity_uuids,
bank_id,
)
entity_fact_counts = {row['entity_id']: row['cnt'] for row in fact_counts}
entity_fact_counts = {row["entity_id"]: row["cnt"] for row in fact_counts}
# Filter entities that meet the threshold
entities_with_names = []
@@ -126,8 +121,7 @@ async def regenerate_observations_batch(
for entity_id, entity_name in entities_with_names:
try:
obs_ids = await _regenerate_entity_observations(
conn, embeddings_model, llm_config,
bank_id, entity_id, entity_name
conn, embeddings_model, llm_config, bank_id, entity_id, entity_name
)
total_observations += len(obs_ids)
except Exception as e:
@@ -135,17 +129,14 @@ async def regenerate_observations_batch(
obs_time = time.time() - obs_start
if log_buffer is not None:
log_buffer.append(f"[11] Observations: {total_observations} observations for {len(entities_with_names)} entities in {obs_time:.3f}s")
log_buffer.append(
f"[11] Observations: {total_observations} observations for {len(entities_with_names)} entities in {obs_time:.3f}s"
)
async def _regenerate_entity_observations(
conn,
embeddings_model,
llm_config,
bank_id: str,
entity_id: str,
entity_name: str
) -> List[str]:
conn, embeddings_model, llm_config, bank_id: str, entity_id: str, entity_name: str
) -> list[str]:
"""
Regenerate observations for a single entity.
@@ -166,17 +157,18 @@ async def _regenerate_entity_observations(
# Get all facts mentioning this entity (exclude observations themselves)
rows = await conn.fetch(
"""
f"""
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.fact_type
FROM memory_units mu
JOIN unit_entities ue ON mu.id = ue.unit_id
FROM {fq_table("memory_units")} mu
JOIN {fq_table("unit_entities")} ue ON mu.id = ue.unit_id
WHERE mu.bank_id = $1
AND ue.entity_id = $2
AND mu.fact_type IN ('world', 'experience')
ORDER BY mu.occurred_start DESC
LIMIT 50
""",
bank_id, entity_uuid
bank_id,
entity_uuid,
)
if not rows:
@@ -185,45 +177,42 @@ async def _regenerate_entity_observations(
# Convert to fact objects for observation extraction
facts = []
for row in rows:
occurred_start = row['occurred_start'].isoformat() if row['occurred_start'] else None
facts.append(MemoryFactForObservation(
id=str(row['id']),
text=row['text'],
fact_type=row['fact_type'],
context=row['context'],
occurred_start=occurred_start
))
occurred_start = row["occurred_start"].isoformat() if row["occurred_start"] else None
facts.append(
MemoryFactForObservation(
id=str(row["id"]),
text=row["text"],
fact_type=row["fact_type"],
context=row["context"],
occurred_start=occurred_start,
)
)
# Extract observations using LLM
observations = await observation_utils.extract_observations_from_facts(
llm_config,
entity_name,
facts
)
observations = await observation_utils.extract_observations_from_facts(llm_config, entity_name, facts)
if not observations:
return []
# Delete old observations for this entity
await conn.execute(
"""
DELETE FROM memory_units
f"""
DELETE FROM {fq_table("memory_units")}
WHERE id IN (
SELECT mu.id
FROM memory_units mu
JOIN unit_entities ue ON mu.id = ue.unit_id
FROM {fq_table("memory_units")} mu
JOIN {fq_table("unit_entities")} ue ON mu.id = ue.unit_id
WHERE mu.bank_id = $1
AND mu.fact_type = 'observation'
AND ue.entity_id = $2
)
""",
bank_id, entity_uuid
bank_id,
entity_uuid,
)
# Generate embeddings for new observations
embeddings = await embedding_utils.generate_embeddings_batch(
embeddings_model, observations
)
embeddings = await embedding_utils.generate_embeddings_batch(embeddings_model, observations)
# Insert new observations
current_time = utcnow()
@@ -231,8 +220,8 @@ async def _regenerate_entity_observations(
for obs_text, embedding in zip(observations, embeddings):
result = await conn.fetchrow(
"""
INSERT INTO memory_units (
f"""
INSERT INTO {fq_table("memory_units")} (
bank_id, text, embedding, context, event_date,
occurred_start, occurred_end, mentioned_at,
fact_type, access_count
@@ -247,18 +236,19 @@ async def _regenerate_entity_observations(
current_time,
current_time,
current_time,
current_time
current_time,
)
obs_id = str(result['id'])
obs_id = str(result["id"])
created_ids.append(obs_id)
# Link observation to entity
await conn.execute(
"""
INSERT INTO unit_entities (unit_id, entity_id)
f"""
INSERT INTO {fq_table("unit_entities")} (unit_id, entity_id)
VALUES ($1, $2)
""",
uuid.UUID(obs_id), entity_uuid
uuid.UUID(obs_id),
entity_uuid,
)
return created_ids
@@ -3,31 +3,33 @@ Main orchestrator for the retain pipeline.
Coordinates all retain pipeline modules to store memories efficiently.
"""
import logging
import time
import uuid
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional
from datetime import UTC, datetime
from . import bank_utils
from ..db_utils import acquire_with_retry
from . import bank_utils
def utcnow():
"""Get current UTC time."""
return datetime.now(timezone.utc)
return datetime.now(UTC)
from .types import RetainContent, ExtractedFact, ProcessedFact, EntityLink
from ..response_models import TokenUsage
from . import (
fact_extraction,
embedding_processing,
deduplication,
chunk_storage,
fact_storage,
deduplication,
embedding_processing,
entity_processing,
fact_extraction,
fact_storage,
link_creation,
observation_regeneration
observation_regeneration,
)
from .types import ExtractedFact, ProcessedFact, RetainContent, RetainContentDict
logger = logging.getLogger(__name__)
@@ -41,12 +43,12 @@ async def retain_batch(
format_date_fn,
duplicate_checker_fn,
bank_id: str,
contents_dicts: List[Dict[str, Any]],
document_id: Optional[str] = None,
contents_dicts: list[RetainContentDict],
document_id: str | None = None,
is_first_batch: bool = True,
fact_type_override: Optional[str] = None,
confidence_score: Optional[float] = None,
) -> List[List[str]]:
fact_type_override: str | None = None,
confidence_score: float | None = None,
) -> tuple[list[list[str]], TokenUsage]:
"""
Process a batch of content through the retain pipeline.
@@ -66,17 +68,17 @@ async def retain_batch(
confidence_score: Confidence score for opinions
Returns:
List of unit ID lists (one list per content item)
Tuple of (unit ID lists, token usage for fact extraction)
"""
start_time = time.time()
total_chars = sum(len(item.get("content", "")) for item in contents_dicts)
# Buffer all logs
log_buffer = []
log_buffer.append(f"{'='*60}")
log_buffer.append(f"{'=' * 60}")
log_buffer.append(f"RETAIN_BATCH START: {bank_id}")
log_buffer.append(f"Batch size: {len(contents_dicts)} content items, {total_chars:,} chars")
log_buffer.append(f"{'='*60}")
log_buffer.append(f"{'=' * 60}")
# Get bank profile
profile = await bank_utils.get_bank_profile(pool, bank_id)
@@ -89,24 +91,81 @@ async def retain_batch(
content=item["content"],
context=item.get("context", ""),
event_date=item.get("event_date") or utcnow(),
metadata=item.get("metadata", {})
metadata=item.get("metadata", {}),
entities=item.get("entities", []),
)
contents.append(content)
# Step 1: Extract facts from all contents
step_start = time.time()
extract_opinions = (fact_type_override == 'opinion')
extract_opinions = fact_type_override == "opinion"
extracted_facts, chunks = await fact_extraction.extract_facts_from_contents(
contents,
llm_config,
agent_name,
extract_opinions
extracted_facts, chunks, usage = await fact_extraction.extract_facts_from_contents(
contents, llm_config, agent_name, extract_opinions
)
log_buffer.append(
f"[1] Extract facts: {len(extracted_facts)} facts, {len(chunks)} chunks from {len(contents)} contents in {time.time() - step_start:.3f}s"
)
log_buffer.append(f"[1] Extract facts: {len(extracted_facts)} facts, {len(chunks)} chunks from {len(contents)} contents in {time.time() - step_start:.3f}s")
if not extracted_facts:
return [[] for _ in contents]
# Still need to create document if document_id was provided
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
await fact_storage.ensure_bank_exists(conn, bank_id)
# Handle document tracking even with no facts
if document_id:
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
retain_params = {}
if contents_dicts:
first_item = contents_dicts[0]
if first_item.get("context"):
retain_params["context"] = first_item["context"]
if first_item.get("event_date"):
retain_params["event_date"] = (
first_item["event_date"].isoformat()
if hasattr(first_item["event_date"], "isoformat")
else str(first_item["event_date"])
)
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn, bank_id, document_id, combined_content, is_first_batch, retain_params
)
else:
# Check for per-item document_ids
from collections import defaultdict
contents_by_doc = defaultdict(list)
for idx, content_dict in enumerate(contents_dicts):
doc_id = content_dict.get("document_id")
if doc_id:
contents_by_doc[doc_id].append((idx, content_dict))
for doc_id, doc_contents in contents_by_doc.items():
combined_content = "\n".join([c.get("content", "") for _, c in doc_contents])
retain_params = {}
if doc_contents:
first_item = doc_contents[0][1]
if first_item.get("context"):
retain_params["context"] = first_item["context"]
if first_item.get("event_date"):
retain_params["event_date"] = (
first_item["event_date"].isoformat()
if hasattr(first_item["event_date"], "isoformat")
else str(first_item["event_date"])
)
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn, bank_id, doc_id, combined_content, is_first_batch, retain_params
)
total_time = time.time() - start_time
logger.info(
f"RETAIN_BATCH COMPLETE: 0 facts extracted from {len(contents)} contents in {total_time:.3f}s (document tracked, no facts)"
)
return [[] for _ in contents], usage
# Apply fact_type_override if provided
if fact_type_override:
@@ -130,6 +189,7 @@ async def retain_batch(
# Group contents by document_id for document tracking and chunk storage
from collections import defaultdict
contents_by_doc = defaultdict(list)
for idx, content_dict in enumerate(contents_dicts):
doc_id = content_dict.get("document_id")
@@ -155,7 +215,11 @@ async def retain_batch(
if first_item.get("context"):
retain_params["context"] = first_item["context"]
if first_item.get("event_date"):
retain_params["event_date"] = first_item["event_date"].isoformat() if hasattr(first_item["event_date"], "isoformat") else str(first_item["event_date"])
retain_params["event_date"] = (
first_item["event_date"].isoformat()
if hasattr(first_item["event_date"], "isoformat")
else str(first_item["event_date"])
)
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
@@ -195,7 +259,11 @@ async def retain_batch(
if first_item.get("context"):
retain_params["context"] = first_item["context"]
if first_item.get("event_date"):
retain_params["event_date"] = first_item["event_date"].isoformat() if hasattr(first_item["event_date"], "isoformat") else str(first_item["event_date"])
retain_params["event_date"] = (
first_item["event_date"].isoformat()
if hasattr(first_item["event_date"], "isoformat")
else str(first_item["event_date"])
)
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
@@ -205,7 +273,9 @@ async def retain_batch(
document_ids_added.append(actual_doc_id)
if document_ids_added:
log_buffer.append(f"[2.5] Document tracking: {len(document_ids_added)} documents in {time.time() - step_start:.3f}s")
log_buffer.append(
f"[2.5] Document tracking: {len(document_ids_added)} documents in {time.time() - step_start:.3f}s"
)
# Store chunks and map to facts for all documents
step_start = time.time()
@@ -230,7 +300,9 @@ async def retain_batch(
for chunk_idx, chunk_id in chunk_id_map.items():
chunk_id_map_by_doc[(doc_id, chunk_idx)] = chunk_id
log_buffer.append(f"[3] Store chunks: {len(chunks)} chunks for {len(chunks_by_doc)} documents in {time.time() - step_start:.3f}s")
log_buffer.append(
f"[3] Store chunks: {len(chunks)} chunks for {len(chunks_by_doc)} documents in {time.time() - step_start:.3f}s"
)
# Map chunk_ids and document_ids to facts
for fact, processed_fact in zip(extracted_facts, processed_facts):
@@ -265,13 +337,15 @@ async def retain_batch(
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")
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]
return [[] for _ in contents], usage
# Insert facts (document_id is now stored per-fact)
step_start = time.time()
@@ -280,8 +354,18 @@ async def retain_batch(
# Process entities
step_start = time.time()
# Build map of content_index -> user entities for merging
user_entities_per_content = {
idx: content.entities for idx, content in enumerate(contents) if content.entities
}
entity_links = await entity_processing.process_entities_batch(
entity_resolver, conn, bank_id, unit_ids, non_duplicate_facts, log_buffer
entity_resolver,
conn,
bank_id,
unit_ids,
non_duplicate_facts,
log_buffer,
user_entities_per_content=user_entities_per_content,
)
log_buffer.append(f"[6] Process entities: {len(entity_links)} links in {time.time() - step_start:.3f}s")
@@ -293,14 +377,18 @@ async def retain_batch(
# Create semantic links
step_start = time.time()
embeddings_for_links = [fact.embedding for fact in non_duplicate_facts]
semantic_link_count = await link_creation.create_semantic_links_batch(conn, bank_id, unit_ids, embeddings_for_links)
semantic_link_count = await link_creation.create_semantic_links_batch(
conn, bank_id, unit_ids, embeddings_for_links
)
log_buffer.append(f"[8] Semantic links: {semantic_link_count} links in {time.time() - step_start:.3f}s")
# Insert entity links
step_start = time.time()
if entity_links:
await entity_processing.insert_entity_links_batch(conn, entity_links)
log_buffer.append(f"[9] Entity links: {len(entity_links) if entity_links else 0} links in {time.time() - step_start:.3f}s")
log_buffer.append(
f"[9] Entity links: {len(entity_links) if entity_links else 0} links in {time.time() - step_start:.3f}s"
)
# Create causal links
step_start = time.time()
@@ -309,46 +397,34 @@ async def retain_batch(
# Regenerate observations INSIDE transaction for atomicity
await observation_regeneration.regenerate_observations_batch(
conn,
embeddings_model,
llm_config,
bank_id,
entity_links,
log_buffer
conn, embeddings_model, llm_config, bank_id, entity_links, log_buffer
)
# 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, is_duplicate_flags, unit_ids)
# Trigger background tasks AFTER transaction commits (opinion reinforcement only)
await _trigger_background_tasks(
task_backend,
bank_id,
unit_ids,
non_duplicate_facts
)
await _trigger_background_tasks(task_backend, bank_id, unit_ids, non_duplicate_facts)
# Log final summary
total_time = time.time() - start_time
log_buffer.append(f"{'='*60}")
log_buffer.append(f"{'=' * 60}")
log_buffer.append(f"RETAIN_BATCH COMPLETE: {len(unit_ids)} units in {total_time:.3f}s")
if document_ids_added:
log_buffer.append(f"Documents: {', '.join(document_ids_added)}")
log_buffer.append(f"{'='*60}")
log_buffer.append(f"{'=' * 60}")
logger.info("\n" + "\n".join(log_buffer) + "\n")
return result_unit_ids
return result_unit_ids, usage
def _map_results_to_contents(
contents: List[RetainContent],
extracted_facts: List[ExtractedFact],
is_duplicate_flags: List[bool],
unit_ids: List[str]
) -> List[List[str]]:
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.
@@ -376,17 +452,19 @@ def _map_results_to_contents(
async def _trigger_background_tasks(
task_backend,
bank_id: str,
unit_ids: List[str],
facts: List[ProcessedFact],
unit_ids: list[str],
facts: list[ProcessedFact],
) -> None:
"""Trigger opinion reinforcement as background task (after transaction commits)."""
# Trigger opinion reinforcement if there are entities
fact_entities = [[e.name for e in fact.entities] for fact in facts]
if any(fact_entities):
await task_backend.submit_task({
'type': 'reinforce_opinion',
'bank_id': bank_id,
'created_unit_ids': unit_ids,
'unit_texts': [fact.fact_text for fact in facts],
'unit_entities': fact_entities
})
await task_backend.submit_task(
{
"type": "reinforce_opinion",
"bank_id": bank_id,
"created_unit_ids": unit_ids,
"unit_texts": [fact.fact_text for fact in facts],
"unit_entities": fact_entities,
}
)
@@ -6,11 +6,36 @@ from content input to fact storage.
"""
from dataclasses import dataclass, field
from typing import List, Optional, Dict, Any
from datetime import datetime
from datetime import UTC, datetime
from typing import TypedDict
from uuid import UUID
class RetainContentDict(TypedDict, total=False):
"""Type definition for content items in retain_batch_async.
Fields:
content: Text content to store (required)
context: Context about the content (optional)
event_date: When the content occurred (optional, defaults to now)
metadata: Custom key-value metadata (optional)
document_id: Document ID for this content item (optional)
entities: User-provided entities to merge with extracted entities (optional)
"""
content: str # Required
context: str
event_date: datetime
metadata: dict[str, str]
document_id: str
entities: list[dict[str, str]] # [{"text": "...", "type": "..."}]
def _now_utc() -> datetime:
"""Factory function for default event_date."""
return datetime.now(UTC)
@dataclass
class RetainContent:
"""
@@ -18,16 +43,12 @@ class RetainContent:
Represents a single piece of content to extract facts from.
"""
content: str
context: str = ""
event_date: Optional[datetime] = None
metadata: Dict[str, str] = field(default_factory=dict)
def __post_init__(self):
"""Ensure event_date is set."""
if self.event_date is None:
from datetime import datetime, timezone
self.event_date = datetime.now(timezone.utc)
event_date: datetime = field(default_factory=_now_utc)
metadata: dict[str, str] = field(default_factory=dict)
entities: list[dict[str, str]] = field(default_factory=list) # User-provided entities
@dataclass
@@ -37,6 +58,7 @@ class ChunkMetadata:
Used to track which facts were extracted from which chunks.
"""
chunk_text: str
fact_count: int
content_index: int # Index of the source content
@@ -50,9 +72,10 @@ class EntityRef:
Entities are extracted by the LLM during fact extraction.
"""
name: str
canonical_name: Optional[str] = None # Resolved canonical name
entity_id: Optional[UUID] = None # Resolved entity ID
canonical_name: str | None = None # Resolved canonical name
entity_id: UUID | None = None # Resolved entity ID
@dataclass
@@ -62,6 +85,7 @@ class CausalRelation:
Represents how one fact causes, enables, or prevents another.
"""
relation_type: str # "causes", "enables", "prevents", "caused_by"
target_fact_index: int # Index of the target fact in the batch
strength: float = 1.0 # Strength of the causal relationship
@@ -74,20 +98,21 @@ class ExtractedFact:
This is the raw output from fact extraction before processing.
"""
fact_text: str
fact_type: str # "world", "experience", "opinion", "observation"
entities: List[str] = field(default_factory=list)
occurred_start: Optional[datetime] = None
occurred_end: Optional[datetime] = None
where: Optional[str] = None # WHERE the fact occurred or is about
causal_relations: List[CausalRelation] = field(default_factory=list)
entities: list[str] = field(default_factory=list)
occurred_start: datetime | None = None
occurred_end: datetime | None = None
where: str | None = None # WHERE the fact occurred or is about
causal_relations: list[CausalRelation] = field(default_factory=list)
# Context from the content item
content_index: int = 0 # Which content this fact came from
chunk_index: int = 0 # Which chunk this fact came from
context: str = ""
mentioned_at: Optional[datetime] = None
metadata: Dict[str, str] = field(default_factory=dict)
mentioned_at: datetime | None = None
metadata: dict[str, str] = field(default_factory=dict)
@dataclass
@@ -97,37 +122,41 @@ class ProcessedFact:
Includes resolved entities, embeddings, and all necessary fields.
"""
# Core fact data
fact_text: str
fact_type: str
embedding: List[float]
embedding: list[float]
# Temporal data
occurred_start: Optional[datetime]
occurred_end: Optional[datetime]
occurred_start: datetime | None
occurred_end: datetime | None
mentioned_at: datetime
# Context and metadata
context: str
metadata: Dict[str, str]
metadata: dict[str, str]
# Location data
where: Optional[str] = None
where: str | None = None
# Entities
entities: List[EntityRef] = field(default_factory=list)
entities: list[EntityRef] = field(default_factory=list)
# Causal relations
causal_relations: List[CausalRelation] = field(default_factory=list)
causal_relations: list[CausalRelation] = field(default_factory=list)
# Chunk reference
chunk_id: Optional[str] = None
chunk_id: str | None = None
# Document reference (denormalized for query performance)
document_id: Optional[str] = None
document_id: str | None = None
# DB fields (set after insertion)
unit_id: Optional[UUID] = None
unit_id: UUID | None = None
# Track which content this fact came from (for user entity merging)
content_index: int = 0
@property
def is_duplicate(self) -> bool:
@@ -136,10 +165,8 @@ class ProcessedFact:
@staticmethod
def from_extracted_fact(
extracted_fact: 'ExtractedFact',
embedding: List[float],
chunk_id: Optional[str] = None
) -> 'ProcessedFact':
extracted_fact: "ExtractedFact", embedding: list[float], chunk_id: str | None = None
) -> "ProcessedFact":
"""
Create ProcessedFact from ExtractedFact.
@@ -151,12 +178,12 @@ class ProcessedFact:
Returns:
ProcessedFact ready for storage
"""
from datetime import datetime, timezone
from datetime import datetime
# Use occurred dates only if explicitly provided by LLM
occurred_start = extracted_fact.occurred_start
occurred_end = extracted_fact.occurred_end
mentioned_at = extracted_fact.mentioned_at or datetime.now(timezone.utc)
mentioned_at = extracted_fact.mentioned_at or datetime.now(UTC)
# Convert entity strings to EntityRef objects
entities = [EntityRef(name=name) for name in extracted_fact.entities]
@@ -172,7 +199,8 @@ class ProcessedFact:
metadata=extracted_fact.metadata,
entities=entities,
causal_relations=extracted_fact.causal_relations,
chunk_id=chunk_id
chunk_id=chunk_id,
content_index=extracted_fact.content_index,
)
@@ -183,10 +211,11 @@ class EntityLink:
Used for entity-based graph connections in the memory graph.
"""
from_unit_id: UUID
to_unit_id: UUID
entity_id: UUID
link_type: str = 'entity'
link_type: str = "entity"
weight: float = 1.0
@@ -197,24 +226,25 @@ class RetainBatch:
Tracks all facts, chunks, and metadata for a batch operation.
"""
bank_id: str
contents: List[RetainContent]
document_id: Optional[str] = None
fact_type_override: Optional[str] = None
confidence_score: Optional[float] = None
contents: list[RetainContent]
document_id: str | None = None
fact_type_override: str | None = None
confidence_score: float | None = None
# Extracted data (populated during processing)
extracted_facts: List[ExtractedFact] = field(default_factory=list)
processed_facts: List[ProcessedFact] = field(default_factory=list)
chunks: List[ChunkMetadata] = field(default_factory=list)
extracted_facts: list[ExtractedFact] = field(default_factory=list)
processed_facts: list[ProcessedFact] = field(default_factory=list)
chunks: list[ChunkMetadata] = field(default_factory=list)
# Results (populated after storage)
unit_ids_by_content: List[List[str]] = field(default_factory=list)
unit_ids_by_content: list[list[str]] = field(default_factory=list)
def get_facts_for_content(self, content_index: int) -> List[ExtractedFact]:
def get_facts_for_content(self, content_index: int) -> list[ExtractedFact]:
"""Get all extracted facts for a specific content item."""
return [f for f in self.extracted_facts if f.content_index == content_index]
def get_chunks_for_content(self, content_index: int) -> List[ChunkMetadata]:
def get_chunks_for_content(self, content_index: int) -> list[ChunkMetadata]:
"""Get all chunks for a specific content item."""
return [c for c in self.chunks if c.content_index == content_index]
@@ -7,15 +7,15 @@ Provides modular search architecture:
- Reranking: Pluggable strategies (heuristic, cross-encoder)
"""
from .retrieval import (
retrieve_parallel,
get_default_graph_retriever,
set_default_graph_retriever,
ParallelRetrievalResult,
)
from .graph_retrieval import GraphRetriever, BFSGraphRetriever
from .graph_retrieval import BFSGraphRetriever, GraphRetriever
from .mpfp_retrieval import MPFPGraphRetriever
from .reranking import CrossEncoderReranker
from .retrieval import (
ParallelRetrievalResult,
get_default_graph_retriever,
retrieve_parallel,
set_default_graph_retriever,
)
__all__ = [
"retrieve_parallel",
@@ -2,15 +2,12 @@
Helper functions for hybrid search (semantic + BM25 + graph).
"""
from typing import List, Dict, Any, Tuple
import asyncio
from .types import RetrievalResult, MergedCandidate
from typing import Any
from .types import MergedCandidate, RetrievalResult
def reciprocal_rank_fusion(
result_lists: List[List[RetrievalResult]],
k: int = 60
) -> List[MergedCandidate]:
def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 60) -> list[MergedCandidate]:
"""
Merge multiple ranked result lists using Reciprocal Rank Fusion.
@@ -73,20 +70,14 @@ def reciprocal_rank_fusion(
sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True), start=1
):
merged_candidate = MergedCandidate(
retrieval=all_retrievals[doc_id],
rrf_score=rrf_score,
rrf_rank=rrf_rank,
source_ranks=source_ranks[doc_id]
retrieval=all_retrievals[doc_id], rrf_score=rrf_score, rrf_rank=rrf_rank, source_ranks=source_ranks[doc_id]
)
merged_results.append(merged_candidate)
return merged_results
def normalize_scores_on_deltas(
results: List[Dict[str, Any]],
score_keys: List[str]
) -> List[Dict[str, Any]]:
def normalize_scores_on_deltas(results: list[dict[str, Any]], score_keys: list[str]) -> list[dict[str, Any]]:
"""
Normalize scores based on deltas (min-max normalization within result set).
@@ -6,13 +6,12 @@ allowing different algorithms (BFS spreading activation, PPR, etc.) to be
swapped without changing the rest of the recall pipeline.
"""
from abc import ABC, abstractmethod
from typing import List, Optional
from datetime import datetime
import logging
from abc import ABC, abstractmethod
from .types import RetrievalResult
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from .types import RetrievalResult
logger = logging.getLogger(__name__)
@@ -40,10 +39,10 @@ class GraphRetriever(ABC):
bank_id: str,
fact_type: str,
budget: int,
query_text: Optional[str] = None,
semantic_seeds: Optional[List[RetrievalResult]] = None,
temporal_seeds: Optional[List[RetrievalResult]] = None,
) -> List[RetrievalResult]:
query_text: str | None = None,
semantic_seeds: list[RetrievalResult] | None = None,
temporal_seeds: list[RetrievalResult] | None = None,
) -> list[RetrievalResult]:
"""
Retrieve relevant facts via graph traversal.
@@ -109,10 +108,10 @@ class BFSGraphRetriever(GraphRetriever):
bank_id: str,
fact_type: str,
budget: int,
query_text: Optional[str] = None,
semantic_seeds: Optional[List[RetrievalResult]] = None,
temporal_seeds: Optional[List[RetrievalResult]] = None,
) -> List[RetrievalResult]:
query_text: str | None = None,
semantic_seeds: list[RetrievalResult] | None = None,
temporal_seeds: list[RetrievalResult] | None = None,
) -> list[RetrievalResult]:
"""
Retrieve facts using BFS spreading activation.
@@ -127,9 +126,7 @@ class BFSGraphRetriever(GraphRetriever):
for interface compatibility but not used.
"""
async with acquire_with_retry(pool) as conn:
return await self._retrieve_with_conn(
conn, query_embedding_str, bank_id, fact_type, budget
)
return await self._retrieve_with_conn(conn, query_embedding_str, bank_id, fact_type, budget)
async def _retrieve_with_conn(
self,
@@ -138,16 +135,16 @@ class BFSGraphRetriever(GraphRetriever):
bank_id: str,
fact_type: str,
budget: int,
) -> List[RetrievalResult]:
) -> list[RetrievalResult]:
"""Internal implementation with connection."""
# Step 1: Find entry points
entry_points = await conn.fetch(
"""
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
1 - (embedding <=> $1::vector) AS similarity
FROM memory_units
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND embedding IS NOT NULL
AND fact_type = $3
@@ -155,8 +152,11 @@ class BFSGraphRetriever(GraphRetriever):
ORDER BY embedding <=> $1::vector
LIMIT $5
""",
query_embedding_str, bank_id, fact_type,
self.entry_point_threshold, self.entry_point_limit
query_embedding_str,
bank_id,
fact_type,
self.entry_point_threshold,
self.entry_point_limit,
)
if not entry_points:
@@ -165,10 +165,7 @@ class BFSGraphRetriever(GraphRetriever):
# Step 2: BFS spreading activation
visited = set()
results = []
queue = [
(RetrievalResult.from_db_row(dict(r)), r["similarity"])
for r in entry_points
]
queue = [(RetrievalResult.from_db_row(dict(r)), r["similarity"]) for r in entry_points]
budget_remaining = budget
while queue and budget_remaining > 0:
@@ -192,20 +189,23 @@ class BFSGraphRetriever(GraphRetriever):
if batch_nodes and budget_remaining > 0:
max_neighbors = len(batch_nodes) * 20
neighbors = await conn.fetch(
"""
f"""
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.occurred_end,
mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type,
mu.document_id, mu.chunk_id,
ml.weight, ml.link_type, ml.from_unit_id
FROM memory_links ml
JOIN memory_units mu ON ml.to_unit_id = mu.id
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.weight >= $2
AND mu.fact_type = $3
ORDER BY ml.weight DESC
LIMIT $4
""",
batch_nodes, self.min_activation, fact_type, max_neighbors
batch_nodes,
self.min_activation,
fact_type,
max_neighbors,
)
for n in neighbors:
@@ -16,13 +16,13 @@ Key properties:
import asyncio
import logging
from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple
from collections import defaultdict
from dataclasses import dataclass, field
from .types import RetrievalResult
from .graph_retrieval import GraphRetriever
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from .graph_retrieval import GraphRetriever
from .types import RetrievalResult
logger = logging.getLogger(__name__)
@@ -31,9 +31,11 @@ logger = logging.getLogger(__name__)
# Data Classes
# -----------------------------------------------------------------------------
@dataclass
class EdgeTarget:
"""A neighbor node with its edge weight."""
node_id: str
weight: float
@@ -41,19 +43,15 @@ class EdgeTarget:
@dataclass
class TypedAdjacency:
"""Adjacency lists split by edge type."""
# edge_type -> from_node_id -> list of (to_node_id, weight)
graphs: Dict[str, Dict[str, List[EdgeTarget]]] = field(default_factory=dict)
def get_neighbors(self, edge_type: str, node_id: str) -> List[EdgeTarget]:
# edge_type -> from_node_id -> list of (to_node_id, weight)
graphs: dict[str, dict[str, list[EdgeTarget]]] = field(default_factory=dict)
def get_neighbors(self, edge_type: str, node_id: str) -> list[EdgeTarget]:
"""Get neighbors for a node via a specific edge type."""
return self.graphs.get(edge_type, {}).get(node_id, [])
def get_normalized_neighbors(
self,
edge_type: str,
node_id: str,
top_k: int
) -> List[EdgeTarget]:
def get_normalized_neighbors(self, edge_type: str, node_id: str, top_k: int) -> list[EdgeTarget]:
"""Get top-k neighbors with weights normalized to sum to 1."""
neighbors = self.get_neighbors(edge_type, node_id)[:top_k]
if not neighbors:
@@ -63,45 +61,49 @@ class TypedAdjacency:
if total == 0:
return []
return [
EdgeTarget(node_id=n.node_id, weight=n.weight / total)
for n in neighbors
]
return [EdgeTarget(node_id=n.node_id, weight=n.weight / total) for n in neighbors]
@dataclass
class PatternResult:
"""Result from a single pattern traversal."""
pattern: List[str]
scores: Dict[str, float] # node_id -> accumulated mass
pattern: list[str]
scores: dict[str, float] # node_id -> accumulated mass
@dataclass
class MPFPConfig:
"""Configuration for MPFP algorithm."""
alpha: float = 0.15 # teleport/keep probability
threshold: float = 1e-6 # mass pruning threshold (lower = explore more)
top_k_neighbors: int = 20 # fan-out limit per node
alpha: float = 0.15 # teleport/keep probability
threshold: float = 1e-6 # mass pruning threshold (lower = explore more)
top_k_neighbors: int = 20 # fan-out limit per node
# Patterns from semantic seeds
patterns_semantic: List[List[str]] = field(default_factory=lambda: [
['semantic', 'semantic'], # topic expansion
['entity', 'temporal'], # entity timeline
['semantic', 'causes'], # reasoning chains (forward)
['semantic', 'caused_by'], # reasoning chains (backward)
['entity', 'semantic'], # entity context
])
patterns_semantic: list[list[str]] = field(
default_factory=lambda: [
["semantic", "semantic"], # topic expansion
["entity", "temporal"], # entity timeline
["semantic", "causes"], # reasoning chains (forward)
["semantic", "caused_by"], # reasoning chains (backward)
["entity", "semantic"], # entity context
]
)
# Patterns from temporal seeds
patterns_temporal: List[List[str]] = field(default_factory=lambda: [
['temporal', 'semantic'], # what was happening then
['temporal', 'entity'], # who was involved then
])
patterns_temporal: list[list[str]] = field(
default_factory=lambda: [
["temporal", "semantic"], # what was happening then
["temporal", "entity"], # who was involved then
]
)
@dataclass
class SeedNode:
"""An entry point node with its initial score."""
node_id: str
score: float # initial mass (e.g., similarity score)
@@ -110,9 +112,10 @@ class SeedNode:
# Core Algorithm
# -----------------------------------------------------------------------------
def mpfp_traverse(
seeds: List[SeedNode],
pattern: List[str],
seeds: list[SeedNode],
pattern: list[str],
adjacency: TypedAdjacency,
config: MPFPConfig,
) -> PatternResult:
@@ -131,20 +134,18 @@ def mpfp_traverse(
if not seeds:
return PatternResult(pattern=pattern, scores={})
scores: Dict[str, float] = {}
scores: dict[str, float] = {}
# Initialize frontier with seed masses (normalized)
total_seed_score = sum(s.score for s in seeds)
if total_seed_score == 0:
total_seed_score = len(seeds) # fallback to uniform
frontier: Dict[str, float] = {
s.node_id: s.score / total_seed_score for s in seeds
}
frontier: dict[str, float] = {s.node_id: s.score / total_seed_score for s in seeds}
# Follow pattern hop by hop
for edge_type in pattern:
next_frontier: Dict[str, float] = {}
next_frontier: dict[str, float] = {}
for node_id, mass in frontier.items():
if mass < config.threshold:
@@ -155,15 +156,10 @@ def mpfp_traverse(
# Push (1-α) to neighbors
push_mass = (1 - config.alpha) * mass
neighbors = adjacency.get_normalized_neighbors(
edge_type, node_id, config.top_k_neighbors
)
neighbors = adjacency.get_normalized_neighbors(edge_type, node_id, config.top_k_neighbors)
for neighbor in neighbors:
next_frontier[neighbor.node_id] = (
next_frontier.get(neighbor.node_id, 0) +
push_mass * neighbor.weight
)
next_frontier[neighbor.node_id] = next_frontier.get(neighbor.node_id, 0) + push_mass * neighbor.weight
frontier = next_frontier
@@ -176,10 +172,10 @@ def mpfp_traverse(
def rrf_fusion(
results: List[PatternResult],
results: list[PatternResult],
k: int = 60,
top_k: int = 50,
) -> List[Tuple[str, float]]:
) -> list[tuple[str, float]]:
"""
Reciprocal Rank Fusion to combine pattern results.
@@ -191,28 +187,20 @@ def rrf_fusion(
Returns:
List of (node_id, fused_score) tuples, sorted by score descending
"""
fused: Dict[str, float] = {}
fused: dict[str, float] = {}
for result in results:
if not result.scores:
continue
# Rank nodes by their score in this pattern
ranked = sorted(
result.scores.keys(),
key=lambda n: result.scores[n],
reverse=True
)
ranked = sorted(result.scores.keys(), key=lambda n: result.scores[n], reverse=True)
for rank, node_id in enumerate(ranked):
fused[node_id] = fused.get(node_id, 0) + 1.0 / (k + rank + 1)
# Sort by fused score and return top-k
sorted_results = sorted(
fused.items(),
key=lambda x: x[1],
reverse=True
)
sorted_results = sorted(fused.items(), key=lambda x: x[1], reverse=True)
return sorted_results[:top_k]
@@ -221,6 +209,7 @@ def rrf_fusion(
# Database Loading
# -----------------------------------------------------------------------------
async def load_typed_adjacency(pool, bank_id: str) -> TypedAdjacency:
"""
Load all edges for a bank, split by edge type.
@@ -229,54 +218,50 @@ async def load_typed_adjacency(pool, bank_id: str) -> TypedAdjacency:
"""
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
"""
f"""
SELECT ml.from_unit_id, ml.to_unit_id, ml.link_type, ml.weight
FROM memory_links ml
JOIN memory_units mu ON ml.from_unit_id = mu.id
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
WHERE mu.bank_id = $1
AND ml.weight >= 0.1
ORDER BY ml.from_unit_id, ml.weight DESC
""",
bank_id
bank_id,
)
graphs: Dict[str, Dict[str, List[EdgeTarget]]] = defaultdict(
lambda: defaultdict(list)
)
graphs: dict[str, dict[str, list[EdgeTarget]]] = defaultdict(lambda: defaultdict(list))
for row in rows:
from_id = str(row['from_unit_id'])
to_id = str(row['to_unit_id'])
link_type = row['link_type']
weight = row['weight']
from_id = str(row["from_unit_id"])
to_id = str(row["to_unit_id"])
link_type = row["link_type"]
weight = row["weight"]
graphs[link_type][from_id].append(
EdgeTarget(node_id=to_id, weight=weight)
)
graphs[link_type][from_id].append(EdgeTarget(node_id=to_id, weight=weight))
return TypedAdjacency(graphs=dict(graphs))
async def fetch_memory_units_by_ids(
pool,
node_ids: List[str],
node_ids: list[str],
fact_type: str,
) -> List[RetrievalResult]:
) -> list[RetrievalResult]:
"""Fetch full memory unit details for a list of node IDs."""
if not node_ids:
return []
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
"""
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id
FROM memory_units
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
AND fact_type = $2
""",
node_ids,
fact_type
fact_type,
)
return [RetrievalResult.from_db_row(dict(r)) for r in rows]
@@ -286,6 +271,7 @@ async def fetch_memory_units_by_ids(
# Graph Retriever Implementation
# -----------------------------------------------------------------------------
class MPFPGraphRetriever(GraphRetriever):
"""
Graph retrieval using Meta-Path Forward Push.
@@ -294,7 +280,7 @@ class MPFPGraphRetriever(GraphRetriever):
then fuses results via RRF.
"""
def __init__(self, config: Optional[MPFPConfig] = None):
def __init__(self, config: MPFPConfig | None = None):
"""
Initialize MPFP retriever.
@@ -302,7 +288,7 @@ class MPFPGraphRetriever(GraphRetriever):
config: Algorithm configuration (uses defaults if None)
"""
self.config = config or MPFPConfig()
self._adjacency_cache: Dict[str, TypedAdjacency] = {}
self._adjacency_cache: dict[str, TypedAdjacency] = {}
@property
def name(self) -> str:
@@ -315,10 +301,10 @@ class MPFPGraphRetriever(GraphRetriever):
bank_id: str,
fact_type: str,
budget: int,
query_text: Optional[str] = None,
semantic_seeds: Optional[List[RetrievalResult]] = None,
temporal_seeds: Optional[List[RetrievalResult]] = None,
) -> List[RetrievalResult]:
query_text: str | None = None,
semantic_seeds: list[RetrievalResult] | None = None,
temporal_seeds: list[RetrievalResult] | None = None,
) -> list[RetrievalResult]:
"""
Retrieve facts using MPFP algorithm.
@@ -339,14 +325,12 @@ class MPFPGraphRetriever(GraphRetriever):
adjacency = await load_typed_adjacency(pool, bank_id)
# Convert seeds to SeedNode format
semantic_seed_nodes = self._convert_seeds(semantic_seeds, 'similarity')
temporal_seed_nodes = self._convert_seeds(temporal_seeds, 'temporal_score')
semantic_seed_nodes = self._convert_seeds(semantic_seeds, "similarity")
temporal_seed_nodes = self._convert_seeds(temporal_seeds, "temporal_score")
# If no semantic seeds provided, fall back to finding our own
if not semantic_seed_nodes:
semantic_seed_nodes = await self._find_semantic_seeds(
pool, query_embedding_str, bank_id, fact_type
)
semantic_seed_nodes = await self._find_semantic_seeds(pool, query_embedding_str, bank_id, fact_type)
# Run all patterns in parallel
tasks = []
@@ -407,9 +391,9 @@ class MPFPGraphRetriever(GraphRetriever):
def _convert_seeds(
self,
seeds: Optional[List[RetrievalResult]],
seeds: list[RetrievalResult] | None,
score_attr: str,
) -> List[SeedNode]:
) -> list[SeedNode]:
"""Convert RetrievalResult seeds to SeedNode format."""
if not seeds:
return []
@@ -431,13 +415,13 @@ class MPFPGraphRetriever(GraphRetriever):
fact_type: str,
limit: int = 20,
threshold: float = 0.3,
) -> List[SeedNode]:
) -> list[SeedNode]:
"""Fallback: find semantic seeds via embedding search."""
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
"""
f"""
SELECT id, 1 - (embedding <=> $1::vector) AS similarity
FROM memory_units
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND embedding IS NOT NULL
AND fact_type = $3
@@ -445,10 +429,11 @@ class MPFPGraphRetriever(GraphRetriever):
ORDER BY embedding <=> $1::vector
LIMIT $5
""",
query_embedding_str, bank_id, fact_type, threshold, limit
query_embedding_str,
bank_id,
fact_type,
threshold,
limit,
)
return [
SeedNode(node_id=str(r['id']), score=r['similarity'])
for r in rows
]
return [SeedNode(node_id=str(r["id"]), score=r["similarity"]) for r in rows]
@@ -6,7 +6,7 @@ about an entity, without personality influence.
"""
import logging
from typing import List, Dict, Any
from pydantic import BaseModel, Field
from ..response_models import MemoryFact
@@ -16,18 +16,17 @@ logger = logging.getLogger(__name__)
class Observation(BaseModel):
"""An observation about an entity."""
observation: str = Field(description="The observation text - a factual statement about the entity")
class ObservationExtractionResponse(BaseModel):
"""Response containing extracted observations."""
observations: List[Observation] = Field(
default_factory=list,
description="List of observations about the entity"
)
observations: list[Observation] = Field(default_factory=list, description="List of observations about the entity")
def format_facts_for_observation_prompt(facts: List[MemoryFact]) -> str:
def format_facts_for_observation_prompt(facts: list[MemoryFact]) -> str:
"""Format facts as text for observation extraction prompt."""
import json
@@ -35,9 +34,7 @@ def format_facts_for_observation_prompt(facts: List[MemoryFact]) -> str:
return "[]"
formatted = []
for fact in facts:
fact_obj = {
"text": fact.text
}
fact_obj = {"text": fact.text}
# Add context if available
if fact.context:
@@ -92,11 +89,7 @@ def get_observation_system_message() -> str:
return "You are an objective observer synthesizing facts about an entity. Generate clear, factual observations without opinions or personality influence. Be concise and accurate."
async def extract_observations_from_facts(
llm_config,
entity_name: str,
facts: List[MemoryFact]
) -> List[str]:
async def extract_observations_from_facts(llm_config, entity_name: str, facts: list[MemoryFact]) -> list[str]:
"""
Extract observations from facts about an entity using LLM.
@@ -118,10 +111,10 @@ async def extract_observations_from_facts(
result = await llm_config.call(
messages=[
{"role": "system", "content": get_observation_system_message()},
{"role": "user", "content": prompt}
{"role": "user", "content": prompt},
],
response_format=ObservationExtractionResponse,
scope="memory_extract_observation"
scope="memory_extract_observation",
)
observations = [op.observation for op in result.observations]
@@ -2,7 +2,6 @@
Cross-encoder neural reranking for search results.
"""
from typing import List
from .types import MergedCandidate, ScoredResult
@@ -24,14 +23,28 @@ class CrossEncoderReranker:
"""
if cross_encoder is None:
from hindsight_api.engine.cross_encoder import create_cross_encoder_from_env
cross_encoder = create_cross_encoder_from_env()
self.cross_encoder = cross_encoder
self._initialized = False
def rerank(
self,
query: str,
candidates: List[MergedCandidate]
) -> List[ScoredResult]:
async def ensure_initialized(self):
"""Ensure the cross-encoder model is initialized (for lazy initialization)."""
if self._initialized:
return
import asyncio
cross_encoder = self.cross_encoder
# For local providers, run in thread pool to avoid blocking event loop
if cross_encoder.provider_name == "local":
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: asyncio.run(cross_encoder.initialize()))
else:
await cross_encoder.initialize()
self._initialized = True
def rerank(self, query: str, candidates: list[MergedCandidate]) -> list[ScoredResult]:
"""
Rerank candidates using cross-encoder scores.
@@ -77,6 +90,7 @@ class CrossEncoderReranker:
# Normalize scores using sigmoid to [0, 1] range
# Cross-encoder returns logits which can be negative
import numpy as np
def sigmoid(x):
return 1 / (1 + np.exp(-x))
@@ -89,7 +103,7 @@ class CrossEncoderReranker:
candidate=candidate,
cross_encoder_score=float(raw_score),
cross_encoder_score_normalized=float(norm_score),
weight=float(norm_score) # Initial weight is just cross-encoder score
weight=float(norm_score), # Initial weight is just cross-encoder score
)
scored_results.append(scored_result)
@@ -8,16 +8,18 @@ Implements:
4. Temporal retrieval (time-aware search with spreading)
"""
from typing import List, Dict, Optional
from dataclasses import dataclass, field
from datetime import datetime
import asyncio
import logging
from ..db_utils import acquire_with_retry
from .types import RetrievalResult
from .graph_retrieval import GraphRetriever, BFSGraphRetriever
from .mpfp_retrieval import MPFPGraphRetriever
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Optional
from ...config import get_config
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from .graph_retrieval import BFSGraphRetriever, GraphRetriever
from .mpfp_retrieval import MPFPGraphRetriever
from .types import RetrievalResult
logger = logging.getLogger(__name__)
@@ -25,16 +27,17 @@ logger = logging.getLogger(__name__)
@dataclass
class ParallelRetrievalResult:
"""Result from parallel retrieval across all methods."""
semantic: List[RetrievalResult]
bm25: List[RetrievalResult]
graph: List[RetrievalResult]
temporal: Optional[List[RetrievalResult]]
timings: Dict[str, float] = field(default_factory=dict)
temporal_constraint: Optional[tuple] = None # (start_date, end_date)
semantic: list[RetrievalResult]
bm25: list[RetrievalResult]
graph: list[RetrievalResult]
temporal: list[RetrievalResult] | None
timings: dict[str, float] = field(default_factory=dict)
temporal_constraint: tuple | None = None # (start_date, end_date)
# Default graph retriever instance (can be overridden)
_default_graph_retriever: Optional[GraphRetriever] = None
_default_graph_retriever: GraphRetriever | None = None
def get_default_graph_retriever() -> GraphRetriever:
@@ -62,12 +65,8 @@ def set_default_graph_retriever(retriever: GraphRetriever) -> None:
async def retrieve_semantic(
conn,
query_emb_str: str,
bank_id: str,
fact_type: str,
limit: int
) -> List[RetrievalResult]:
conn, query_emb_str: str, bank_id: str, fact_type: str, limit: int
) -> list[RetrievalResult]:
"""
Semantic retrieval via vector similarity.
@@ -82,10 +81,10 @@ async def retrieve_semantic(
List of RetrievalResult objects
"""
results = await conn.fetch(
"""
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
1 - (embedding <=> $1::vector) AS similarity
FROM memory_units
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND embedding IS NOT NULL
AND fact_type = $3
@@ -93,18 +92,15 @@ async def retrieve_semantic(
ORDER BY embedding <=> $1::vector
LIMIT $4
""",
query_emb_str, bank_id, fact_type, limit
query_emb_str,
bank_id,
fact_type,
limit,
)
return [RetrievalResult.from_db_row(dict(r)) for r in results]
async def retrieve_bm25(
conn,
query_text: str,
bank_id: str,
fact_type: str,
limit: int
) -> List[RetrievalResult]:
async def retrieve_bm25(conn, query_text: str, bank_id: str, fact_type: str, limit: int) -> list[RetrievalResult]:
"""
BM25 keyword retrieval via full-text search.
@@ -122,7 +118,7 @@ async def retrieve_bm25(
# Sanitize query text: remove special characters that have meaning in tsquery
# Keep only alphanumeric characters and spaces
sanitized_text = re.sub(r'[^\w\s]', ' ', query_text.lower())
sanitized_text = re.sub(r"[^\w\s]", " ", query_text.lower())
# Split and filter empty strings
tokens = [token for token in sanitized_text.split() if token]
@@ -136,17 +132,20 @@ async def retrieve_bm25(
query_tsquery = " | ".join(tokens)
results = await conn.fetch(
"""
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
ts_rank_cd(search_vector, to_tsquery('english', $1)) AS bm25_score
FROM memory_units
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND fact_type = $3
AND search_vector @@ to_tsquery('english', $1)
ORDER BY bm25_score DESC
LIMIT $4
""",
query_tsquery, bank_id, fact_type, limit
query_tsquery,
bank_id,
fact_type,
limit,
)
return [RetrievalResult.from_db_row(dict(r)) for r in results]
@@ -159,8 +158,8 @@ async def retrieve_temporal(
start_date: datetime,
end_date: datetime,
budget: int,
semantic_threshold: float = 0.1
) -> List[RetrievalResult]:
semantic_threshold: float = 0.1,
) -> list[RetrievalResult]:
"""
Temporal retrieval with spreading activation.
@@ -182,19 +181,18 @@ async def retrieve_temporal(
Returns:
List of RetrievalResult objects with temporal scores
"""
from datetime import timezone
# Ensure start_date and end_date are timezone-aware (UTC) to match database datetimes
if start_date.tzinfo is None:
start_date = start_date.replace(tzinfo=timezone.utc)
start_date = start_date.replace(tzinfo=UTC)
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
end_date = end_date.replace(tzinfo=UTC)
entry_points = await conn.fetch(
"""
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
1 - (embedding <=> $1::vector) AS similarity
FROM memory_units
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND fact_type = $3
AND embedding IS NOT NULL
@@ -215,7 +213,12 @@ async def retrieve_temporal(
ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC, (embedding <=> $1::vector) ASC
LIMIT 10
""",
query_emb_str, bank_id, fact_type, start_date, end_date, semantic_threshold
query_emb_str,
bank_id,
fact_type,
start_date,
end_date,
semantic_threshold,
)
if not entry_points:
@@ -258,7 +261,9 @@ async def retrieve_temporal(
results.append(ep_result)
# Spread through temporal links
queue = [(RetrievalResult.from_db_row(dict(ep)), ep["similarity"], 1.0) for ep in entry_points] # (unit, semantic_sim, temporal_score)
queue = [
(RetrievalResult.from_db_row(dict(ep)), ep["similarity"], 1.0) for ep in entry_points
] # (unit, semantic_sim, temporal_score)
budget_remaining = budget - len(entry_points)
while queue and budget_remaining > 0:
@@ -268,12 +273,12 @@ async def retrieve_temporal(
# Get neighbors via temporal and causal links
if budget_remaining > 0:
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.access_count, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id,
ml.weight, ml.link_type,
1 - (mu.embedding <=> $1::vector) AS similarity
FROM memory_links ml
JOIN memory_units mu ON ml.to_unit_id = mu.id
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id = $2
AND ml.link_type IN ('temporal', 'causes', 'caused_by', 'enables', 'prevents')
AND ml.weight >= 0.1
@@ -283,7 +288,10 @@ async def retrieve_temporal(
ORDER BY ml.weight DESC
LIMIT 10
""",
query_emb_str, current.id, fact_type, semantic_threshold
query_emb_str,
current.id,
fact_type,
semantic_threshold,
)
for n in neighbors:
@@ -307,7 +315,9 @@ async def retrieve_temporal(
if neighbor_best_date:
days_from_mid = abs((neighbor_best_date - mid_date).total_seconds() / 86400)
neighbor_temporal_proximity = 1.0 - min(days_from_mid / (total_days / 2), 1.0) if total_days > 0 else 1.0
neighbor_temporal_proximity = (
1.0 - min(days_from_mid / (total_days / 2), 1.0) if total_days > 0 else 1.0
)
else:
neighbor_temporal_proximity = 0.3 # Lower score if no temporal data
@@ -349,9 +359,9 @@ async def retrieve_parallel(
bank_id: str,
fact_type: str,
thinking_budget: int,
question_date: Optional[datetime] = None,
question_date: datetime | None = None,
query_analyzer: Optional["QueryAnalyzer"] = None,
graph_retriever: Optional[GraphRetriever] = None,
graph_retriever: GraphRetriever | None = None,
) -> ParallelRetrievalResult:
"""
Run 3-way or 4-way parallel retrieval (adds temporal if detected).
@@ -372,29 +382,26 @@ async def retrieve_parallel(
"""
from .temporal_extraction import extract_temporal_constraint
temporal_constraint = extract_temporal_constraint(
query_text, reference_date=question_date, analyzer=query_analyzer
)
temporal_constraint = extract_temporal_constraint(query_text, reference_date=question_date, analyzer=query_analyzer)
retriever = graph_retriever or get_default_graph_retriever()
if retriever.name == "mpfp":
return await _retrieve_parallel_mpfp(
pool, query_text, query_embedding_str, bank_id, fact_type,
thinking_budget, temporal_constraint, retriever
pool, query_text, query_embedding_str, bank_id, fact_type, thinking_budget, temporal_constraint, retriever
)
else:
return await _retrieve_parallel_bfs(
pool, query_text, query_embedding_str, bank_id, fact_type,
thinking_budget, temporal_constraint, retriever
pool, query_text, query_embedding_str, bank_id, fact_type, thinking_budget, temporal_constraint, retriever
)
@dataclass
class _SemanticGraphResult:
"""Internal result from semantic→graph chain."""
semantic: List[RetrievalResult]
graph: List[RetrievalResult]
semantic: list[RetrievalResult]
graph: list[RetrievalResult]
semantic_time: float
graph_time: float
@@ -402,7 +409,8 @@ class _SemanticGraphResult:
@dataclass
class _TimedResult:
"""Internal result with timing."""
results: List[RetrievalResult]
results: list[RetrievalResult]
time: float
@@ -413,7 +421,7 @@ async def _retrieve_parallel_mpfp(
bank_id: str,
fact_type: str,
thinking_budget: int,
temporal_constraint: Optional[tuple],
temporal_constraint: tuple | None,
retriever: GraphRetriever,
) -> ParallelRetrievalResult:
"""
@@ -430,9 +438,7 @@ async def _retrieve_parallel_mpfp(
"""Chain: semantic retrieval → graph retrieval (using semantic as seeds)."""
start = time.time()
async with acquire_with_retry(pool) as conn:
semantic = await retrieve_semantic(
conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget
)
semantic = await retrieve_semantic(conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget)
semantic_time = time.time() - start
# Get temporal seeds if needed (quick query, part of this chain)
@@ -441,8 +447,7 @@ async def _retrieve_parallel_mpfp(
tc_start, tc_end = temporal_constraint
async with acquire_with_retry(pool) as conn:
temporal_seeds = await _get_temporal_entry_points(
conn, query_embedding_str, bank_id, fact_type,
tc_start, tc_end, limit=20
conn, query_embedding_str, bank_id, fact_type, tc_start, tc_end, limit=20
)
# Run graph with seeds
@@ -473,8 +478,14 @@ async def _retrieve_parallel_mpfp(
start = time.time()
async with acquire_with_retry(pool) as conn:
results = await retrieve_temporal(
conn, query_embedding_str, bank_id, fact_type,
tc_start, tc_end, budget=thinking_budget, semantic_threshold=0.1
conn,
query_embedding_str,
bank_id,
fact_type,
tc_start,
tc_end,
budget=thinking_budget,
semantic_threshold=0.1,
)
return _TimedResult(results, time.time() - start)
@@ -527,21 +538,20 @@ async def _get_temporal_entry_points(
end_date: datetime,
limit: int = 20,
semantic_threshold: float = 0.1,
) -> List[RetrievalResult]:
) -> list[RetrievalResult]:
"""Get temporal entry points (facts in date range with semantic relevance)."""
from datetime import timezone
if start_date.tzinfo is None:
start_date = start_date.replace(tzinfo=timezone.utc)
start_date = start_date.replace(tzinfo=UTC)
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
end_date = end_date.replace(tzinfo=UTC)
rows = await conn.fetch(
"""
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at,
access_count, embedding, fact_type, document_id, chunk_id,
1 - (embedding <=> $1::vector) AS similarity
FROM memory_units
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND fact_type = $3
AND embedding IS NOT NULL
@@ -557,7 +567,13 @@ async def _get_temporal_entry_points(
(embedding <=> $1::vector) ASC
LIMIT $7
""",
query_embedding_str, bank_id, fact_type, start_date, end_date, semantic_threshold, limit
query_embedding_str,
bank_id,
fact_type,
start_date,
end_date,
semantic_threshold,
limit,
)
results = []
@@ -597,7 +613,7 @@ async def _retrieve_parallel_bfs(
bank_id: str,
fact_type: str,
thinking_budget: int,
temporal_constraint: Optional[tuple],
temporal_constraint: tuple | None,
retriever: GraphRetriever,
) -> ParallelRetrievalResult:
"""BFS retrieval: all methods run in parallel (original behavior)."""
@@ -631,8 +647,14 @@ async def _retrieve_parallel_bfs(
start = time.time()
async with acquire_with_retry(pool) as conn:
results = await retrieve_temporal(
conn, query_embedding_str, bank_id, fact_type,
tc_start, tc_end, budget=thinking_budget, semantic_threshold=0.1
conn,
query_embedding_str,
bank_id,
fact_type,
tc_start,
tc_end,
budget=thinking_budget,
semantic_threshold=0.1,
)
return _TimedResult(results, time.time() - start)
@@ -4,11 +4,11 @@ Scoring functions for memory search and retrieval.
Includes recency weighting, frequency weighting, temporal proximity,
and similarity calculations used in memory activation and ranking.
"""
from datetime import datetime
from typing import List
def cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
"""
Calculate cosine similarity between two vectors.
@@ -58,6 +58,7 @@ def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -
Weight between 0 and 1
"""
import math
# Logarithmic decay: 1 / (1 + log(1 + days_since/half_life))
# This decays much slower than exponential, giving better long-term differentiation
normalized_age = days_since / half_life_days
@@ -79,6 +80,7 @@ def calculate_frequency_weight(access_count: int, max_boost: float = 2.0) -> flo
Weight between 1.0 and max_boost
"""
import math
if access_count <= 0:
return 1.0
@@ -116,11 +118,7 @@ def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime)
return midpoint
def calculate_temporal_proximity(
anchor_a: datetime,
anchor_b: datetime,
half_life_days: float = 30.0
) -> float:
def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float:
"""
Calculate temporal proximity between two temporal anchors.
@@ -4,16 +4,16 @@ Temporal extraction for time-aware search queries.
Handles natural language temporal expressions using transformer-based query analysis.
"""
from typing import Optional, Tuple
from datetime import datetime
import logging
from hindsight_api.engine.query_analyzer import QueryAnalyzer, DateparserQueryAnalyzer
from datetime import datetime
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer, QueryAnalyzer
logger = logging.getLogger(__name__)
# Global default analyzer instance
# Can be overridden by passing a custom analyzer to extract_temporal_constraint
_default_analyzer: Optional[QueryAnalyzer] = None
_default_analyzer: QueryAnalyzer | None = None
def get_default_analyzer() -> QueryAnalyzer:
@@ -33,9 +33,9 @@ def get_default_analyzer() -> QueryAnalyzer:
def extract_temporal_constraint(
query: str,
reference_date: Optional[datetime] = None,
analyzer: Optional[QueryAnalyzer] = None,
) -> Optional[Tuple[datetime, datetime]]:
reference_date: datetime | None = None,
analyzer: QueryAnalyzer | None = None,
) -> tuple[datetime, datetime] | None:
"""
Extract temporal constraint from query.
@@ -55,10 +55,7 @@ def extract_temporal_constraint(
analysis = analyzer.analyze(query, reference_date)
if analysis.temporal_constraint:
result = (
analysis.temporal_constraint.start_date,
analysis.temporal_constraint.end_date
)
result = (analysis.temporal_constraint.start_date, analysis.temporal_constraint.end_date)
return result
return None
@@ -2,41 +2,35 @@
Think operation utilities for formulating answers based on agent and world facts.
"""
import asyncio
import logging
import re
from datetime import datetime, timezone
from typing import Dict, List, Any
from datetime import datetime
from pydantic import BaseModel, Field
from ..response_models import ReflectResult, MemoryFact, DispositionTraits
from ..response_models import DispositionTraits, MemoryFact
logger = logging.getLogger(__name__)
class Opinion(BaseModel):
"""An opinion formed by the bank."""
opinion: str = Field(description="The opinion or perspective with reasoning included")
confidence: float = Field(description="Confidence score for this opinion (0.0 to 1.0, where 1.0 is very confident)")
class OpinionExtractionResponse(BaseModel):
"""Response containing extracted opinions."""
opinions: List[Opinion] = Field(
default_factory=list,
description="List of opinions formed with their supporting reasons and confidence scores"
opinions: list[Opinion] = Field(
default_factory=list, description="List of opinions formed with their supporting reasons and confidence scores"
)
def describe_trait_level(value: int) -> str:
"""Convert trait value (1-5) to descriptive text."""
levels = {
1: "very low",
2: "low",
3: "moderate",
4: "high",
5: "very high"
}
levels = {1: "very low", 2: "low", 3: "moderate", 4: "high", 5: "very high"}
return levels.get(value, "moderate")
@@ -47,7 +41,7 @@ def build_disposition_description(disposition: DispositionTraits) -> str:
2: "You tend to trust information but may question obvious inconsistencies.",
3: "You have a balanced approach to information, neither too trusting nor too skeptical.",
4: "You are somewhat skeptical and often question the reliability of information.",
5: "You are highly skeptical and critically examine all information for accuracy and hidden motives."
5: "You are highly skeptical and critically examine all information for accuracy and hidden motives.",
}
literalism_desc = {
@@ -55,7 +49,7 @@ def build_disposition_description(disposition: DispositionTraits) -> str:
2: "You tend to consider context and implied meaning alongside literal statements.",
3: "You balance literal interpretation with contextual understanding.",
4: "You prefer to interpret information more literally and precisely.",
5: "You interpret information very literally and focus on exact wording and commitments."
5: "You interpret information very literally and focus on exact wording and commitments.",
}
empathy_desc = {
@@ -63,7 +57,7 @@ def build_disposition_description(disposition: DispositionTraits) -> str:
2: "You consider facts first but acknowledge emotional factors exist.",
3: "You balance factual analysis with emotional understanding.",
4: "You give significant weight to emotional context and human factors.",
5: "You strongly consider the emotional state and circumstances of others when forming memories."
5: "You strongly consider the emotional state and circumstances of others when forming memories.",
}
return f"""Your disposition traits:
@@ -72,7 +66,7 @@ def build_disposition_description(disposition: DispositionTraits) -> str:
- Empathy ({describe_trait_level(disposition.empathy)}): {empathy_desc.get(disposition.empathy, empathy_desc[3])}"""
def format_facts_for_prompt(facts: List[MemoryFact]) -> str:
def format_facts_for_prompt(facts: list[MemoryFact]) -> str:
"""Format facts as JSON for LLM prompt."""
import json
@@ -80,9 +74,7 @@ def format_facts_for_prompt(facts: List[MemoryFact]) -> str:
return "[]"
formatted = []
for fact in facts:
fact_obj = {
"text": fact.text
}
fact_obj = {"text": fact.text}
# Add context if available
if fact.context:
@@ -94,7 +86,7 @@ def format_facts_for_prompt(facts: List[MemoryFact]) -> str:
if isinstance(occurred_start, str):
fact_obj["occurred_start"] = occurred_start
elif isinstance(occurred_start, datetime):
fact_obj["occurred_start"] = occurred_start.strftime('%Y-%m-%d %H:%M:%S')
fact_obj["occurred_start"] = occurred_start.strftime("%Y-%m-%d %H:%M:%S")
formatted.append(fact_obj)
@@ -109,7 +101,7 @@ def build_think_prompt(
name: str,
disposition: DispositionTraits,
background: str,
context: str = None,
context: str | None = None,
) -> str:
"""Build the think prompt for the LLM."""
disposition_desc = build_disposition_description(disposition)
@@ -176,16 +168,14 @@ def get_system_message(disposition: DispositionTraits) -> str:
elif disposition.empathy <= 2:
instructions.append("Focus on facts and outcomes rather than emotional context.")
disposition_instruction = " ".join(instructions) if instructions else "Balance your disposition traits when interpreting information."
disposition_instruction = (
" ".join(instructions) if instructions else "Balance your disposition traits when interpreting information."
)
return f"You are a person with your own thoughts, experiences, opinions, and disposition. When asked a question, respond naturally like a human would - share what you genuinely think based on what you know and have experienced. {disposition_instruction} Be direct, express your views confidently, and use 'I think', 'I believe', 'in my view', etc. Respond in plain text without markdown formatting."
async def extract_opinions_from_text(
llm_config,
text: str,
query: str
) -> List[Opinion]:
async def extract_opinions_from_text(llm_config, text: str, query: str) -> list[Opinion]:
"""
Extract opinions with reasons and confidence from text using LLM.
@@ -238,11 +228,14 @@ If no genuine opinions are expressed (e.g., the response just says "I don't know
try:
result = await llm_config.call(
messages=[
{"role": "system", "content": "You are converting opinions from text into first-person statements. Always use 'I think', 'I believe', 'I feel', etc. NEVER use third-person like 'The speaker' or 'They'."},
{"role": "user", "content": extraction_prompt}
{
"role": "system",
"content": "You are converting opinions from text into first-person statements. Always use 'I think', 'I believe', 'I feel', etc. NEVER use third-person like 'The speaker' or 'They'.",
},
{"role": "user", "content": extraction_prompt},
],
response_format=OpinionExtractionResponse,
scope="memory_extract_opinion"
scope="memory_extract_opinion",
)
# Format opinions with confidence score and convert to first-person
@@ -253,14 +246,18 @@ If no genuine opinions are expressed (e.g., the response just says "I don't know
# Replace common third-person patterns with first-person
def singularize_verb(verb):
if verb.endswith('es'):
if verb.endswith("es"):
return verb[:-1] # believes -> believe
elif verb.endswith('s'):
elif verb.endswith("s"):
return verb[:-1] # thinks -> think
return verb
# Pattern: "The speaker/user [verb]..." -> "I [verb]..."
match = re.match(r'^(The speaker|The user|They|It is believed) (believes?|thinks?|feels?|says|asserts?|considers?)(\s+that)?(.*)$', opinion_text, re.IGNORECASE)
match = re.match(
r"^(The speaker|The user|They|It is believed) (believes?|thinks?|feels?|says|asserts?|considers?)(\s+that)?(.*)$",
opinion_text,
re.IGNORECASE,
)
if match:
verb = singularize_verb(match.group(2))
that_part = match.group(3) or "" # Keep " that" if present
@@ -268,17 +265,96 @@ If no genuine opinions are expressed (e.g., the response just says "I don't know
opinion_text = f"I {verb}{that_part}{rest}"
# If still doesn't start with first-person, prepend "I believe that "
first_person_starters = ["I think", "I believe", "I feel", "In my view", "I've come to believe", "Previously I"]
first_person_starters = [
"I think",
"I believe",
"I feel",
"In my view",
"I've come to believe",
"Previously I",
]
if not any(opinion_text.startswith(starter) for starter in first_person_starters):
opinion_text = "I believe that " + opinion_text[0].lower() + opinion_text[1:]
formatted_opinions.append(Opinion(
opinion=opinion_text,
confidence=op.confidence
))
formatted_opinions.append(Opinion(opinion=opinion_text, confidence=op.confidence))
return formatted_opinions
except Exception as e:
logger.warning(f"Failed to extract opinions: {str(e)}")
return []
async def reflect(
llm_config,
query: str,
experience_facts: list[str] = None,
world_facts: list[str] = None,
opinion_facts: list[str] = None,
name: str = "Assistant",
disposition: DispositionTraits = None,
background: str = "",
context: str = None,
) -> str:
"""
Standalone reflect function for generating answers based on facts.
This is a static version of the reflect operation that can be called
without a MemoryEngine instance, useful for testing.
Args:
llm_config: LLM provider instance
query: Question to answer
experience_facts: List of experience/agent fact strings
world_facts: List of world fact strings
opinion_facts: List of opinion fact strings
name: Name of the agent/persona
disposition: Disposition traits (defaults to neutral)
background: Background information
context: Additional context for the prompt
Returns:
Generated answer text
"""
# Default disposition if not provided
if disposition is None:
disposition = DispositionTraits(skepticism=3, literalism=3, empathy=3)
# Convert string lists to MemoryFact format for formatting
def to_memory_facts(facts: list[str], fact_type: str) -> list[MemoryFact]:
if not facts:
return []
return [MemoryFact(id=f"test-{i}", text=f, fact_type=fact_type) for i, f in enumerate(facts)]
agent_results = to_memory_facts(experience_facts or [], "experience")
world_results = to_memory_facts(world_facts or [], "world")
opinion_results = to_memory_facts(opinion_facts or [], "opinion")
# Format facts for prompt
agent_facts_text = format_facts_for_prompt(agent_results)
world_facts_text = format_facts_for_prompt(world_results)
opinion_facts_text = format_facts_for_prompt(opinion_results)
# Build prompt
prompt = build_think_prompt(
agent_facts_text=agent_facts_text,
world_facts_text=world_facts_text,
opinion_facts_text=opinion_facts_text,
query=query,
name=name,
disposition=disposition,
background=background,
context=context,
)
system_message = get_system_message(disposition)
# Call LLM
answer_text = await llm_config.call(
messages=[{"role": "system", "content": system_message}, {"role": "user", "content": prompt}],
scope="memory_think",
temperature=0.9,
max_completion_tokens=1000,
)
return answer_text.strip()
@@ -4,15 +4,18 @@ Search trace models for debugging and visualization.
These Pydantic models define the structure of search traces, capturing
every step of the spreading activation search process for analysis.
"""
from datetime import datetime
from typing import List, Optional, Dict, Any, Literal
from typing import Any, Literal
from pydantic import BaseModel, Field
class QueryInfo(BaseModel):
"""Information about the search query."""
query_text: str = Field(description="Original query text")
query_embedding: List[float] = Field(description="Generated query embedding vector")
query_embedding: list[float] = Field(description="Generated query embedding vector")
timestamp: datetime = Field(description="When the query was executed")
budget: int = Field(description="Maximum nodes to explore")
max_tokens: int = Field(description="Maximum tokens to return in results")
@@ -20,6 +23,7 @@ class QueryInfo(BaseModel):
class EntryPoint(BaseModel):
"""An entry point node selected for search."""
node_id: str = Field(description="Memory unit ID")
text: str = Field(description="Memory unit text content")
similarity_score: float = Field(description="Cosine similarity to query", ge=0.0, le=1.0)
@@ -28,6 +32,7 @@ class EntryPoint(BaseModel):
class WeightComponents(BaseModel):
"""Breakdown of weight calculation components."""
activation: float = Field(description="Activation from spreading (can exceed 1.0 through accumulation)", ge=0.0)
semantic_similarity: float = Field(description="Semantic similarity to query", ge=0.0, le=1.0)
recency: float = Field(description="Recency weight", ge=0.0, le=1.0)
@@ -43,99 +48,120 @@ class WeightComponents(BaseModel):
class LinkInfo(BaseModel):
"""Information about a link to a neighbor."""
to_node_id: str = Field(description="Target node ID")
link_type: Literal["temporal", "semantic", "entity"] = Field(description="Type of link")
link_weight: float = Field(description="Weight of the link (can exceed 1.0 when aggregating multiple connections)", ge=0.0)
entity_id: Optional[str] = Field(default=None, description="Entity ID if link_type is 'entity'")
new_activation: Optional[float] = Field(default=None, description="Activation that would be passed to neighbor (None for supplementary links)")
link_weight: float = Field(
description="Weight of the link (can exceed 1.0 when aggregating multiple connections)", ge=0.0
)
entity_id: str | None = Field(default=None, description="Entity ID if link_type is 'entity'")
new_activation: float | None = Field(
default=None, description="Activation that would be passed to neighbor (None for supplementary links)"
)
followed: bool = Field(description="Whether this link was followed (or pruned)")
prune_reason: Optional[str] = Field(default=None, description="Why link was not followed (if not followed)")
is_supplementary: bool = Field(default=False, description="Whether this is a supplementary link (multiple connections to same node)")
prune_reason: str | None = Field(default=None, description="Why link was not followed (if not followed)")
is_supplementary: bool = Field(
default=False, description="Whether this is a supplementary link (multiple connections to same node)"
)
class NodeVisit(BaseModel):
"""Information about visiting a node during search."""
step: int = Field(description="Step number in search (1-based)")
node_id: str = Field(description="Memory unit ID")
text: str = Field(description="Memory unit text content")
context: str = Field(description="Memory unit context")
event_date: Optional[datetime] = Field(default=None, description="When the memory occurred")
event_date: datetime | None = Field(default=None, description="When the memory occurred")
access_count: int = Field(description="Number of times accessed before this search")
# How this node was reached
is_entry_point: bool = Field(description="Whether this is an entry point")
parent_node_id: Optional[str] = Field(default=None, description="Node that led to this one")
link_type: Optional[Literal["temporal", "semantic", "entity"]] = Field(default=None, description="Type of link from parent")
link_weight: Optional[float] = Field(default=None, description="Weight of link from parent")
parent_node_id: str | None = Field(default=None, description="Node that led to this one")
link_type: Literal["temporal", "semantic", "entity"] | None = Field(
default=None, description="Type of link from parent"
)
link_weight: float | None = Field(default=None, description="Weight of link from parent")
# Weights
weights: WeightComponents = Field(description="Weight calculation breakdown")
# Neighbors discovered from this node
neighbors_explored: List[LinkInfo] = Field(default_factory=list, description="Links explored from this node")
neighbors_explored: list[LinkInfo] = Field(default_factory=list, description="Links explored from this node")
# Ranking
final_rank: Optional[int] = Field(default=None, description="Final rank in results (1-based, None if not in top-k)")
final_rank: int | None = Field(default=None, description="Final rank in results (1-based, None if not in top-k)")
class PruningDecision(BaseModel):
"""Records when a node was considered but not visited."""
node_id: str = Field(description="Node that was pruned")
reason: Literal["already_visited", "activation_too_low", "budget_exhausted"] = Field(description="Why it was pruned")
reason: Literal["already_visited", "activation_too_low", "budget_exhausted"] = Field(
description="Why it was pruned"
)
activation: float = Field(description="Activation value when pruned")
would_have_been_step: int = Field(description="What step it would have been if visited")
class SearchPhaseMetrics(BaseModel):
"""Performance metrics for a search phase."""
phase_name: str = Field(description="Name of the phase")
duration_seconds: float = Field(description="Time taken in seconds")
details: Dict[str, Any] = Field(default_factory=dict, description="Additional phase-specific metrics")
details: dict[str, Any] = Field(default_factory=dict, description="Additional phase-specific metrics")
class RetrievalResult(BaseModel):
"""A single result from a retrieval method."""
rank: int = Field(description="Rank in this retrieval method (1-based)")
node_id: str = Field(description="Memory unit ID")
text: str = Field(description="Memory unit text content")
context: str = Field(default="", description="Memory unit context")
event_date: Optional[datetime] = Field(default=None, description="When the memory occurred")
fact_type: Optional[str] = Field(default=None, description="Fact type (world, experience, opinion)")
event_date: datetime | None = Field(default=None, description="When the memory occurred")
fact_type: str | None = Field(default=None, description="Fact type (world, experience, opinion)")
score: float = Field(description="Score from this retrieval method")
score_name: str = Field(description="Name of the score (e.g., 'similarity', 'bm25_score', 'activation')")
class RetrievalMethodResults(BaseModel):
"""Results from a single retrieval method."""
method_name: Literal["semantic", "bm25", "graph", "temporal"] = Field(description="Name of retrieval method")
fact_type: Optional[str] = Field(default=None, description="Fact type this retrieval was for (world, experience, opinion)")
results: List[RetrievalResult] = Field(description="Retrieved results with ranks")
fact_type: str | None = Field(
default=None, description="Fact type this retrieval was for (world, experience, opinion)"
)
results: list[RetrievalResult] = Field(description="Retrieved results with ranks")
duration_seconds: float = Field(description="Time taken for this retrieval")
metadata: Dict[str, Any] = Field(default_factory=dict, description="Method-specific metadata")
metadata: dict[str, Any] = Field(default_factory=dict, description="Method-specific metadata")
class RRFMergeResult(BaseModel):
"""A result after RRF merging."""
node_id: str = Field(description="Memory unit ID")
text: str = Field(description="Memory unit text content")
rrf_score: float = Field(description="Reciprocal Rank Fusion score")
source_ranks: Dict[str, int] = Field(description="Rank in each source that contributed (method_name -> rank)")
source_ranks: dict[str, int] = Field(description="Rank in each source that contributed (method_name -> rank)")
final_rrf_rank: int = Field(description="Rank after RRF merge (1-based)")
class RerankedResult(BaseModel):
"""A result after reranking."""
node_id: str = Field(description="Memory unit ID")
text: str = Field(description="Memory unit text content")
rerank_score: float = Field(description="Final reranking score")
rerank_rank: int = Field(description="Rank after reranking (1-based)")
rrf_rank: int = Field(description="Original RRF rank before reranking")
rank_change: int = Field(description="Change in rank (positive = moved up)")
score_components: Dict[str, float] = Field(default_factory=dict, description="Score breakdown")
score_components: dict[str, float] = Field(default_factory=dict, description="Score breakdown")
class SearchSummary(BaseModel):
"""Summary statistics about the search."""
total_nodes_visited: int = Field(description="Total nodes visited")
total_nodes_pruned: int = Field(description="Total nodes pruned")
entry_points_found: int = Field(description="Number of entry points")
@@ -150,33 +176,36 @@ class SearchSummary(BaseModel):
entity_links_followed: int = Field(default=0, description="Entity links followed")
# Phase timings
phase_metrics: List[SearchPhaseMetrics] = Field(default_factory=list, description="Metrics for each phase")
phase_metrics: list[SearchPhaseMetrics] = Field(default_factory=list, description="Metrics for each phase")
class SearchTrace(BaseModel):
"""Complete trace of a search operation."""
query: QueryInfo = Field(description="Query information")
# New 4-way retrieval architecture
retrieval_results: List[RetrievalMethodResults] = Field(default_factory=list, description="Results from each retrieval method")
rrf_merged: List[RRFMergeResult] = Field(default_factory=list, description="Results after RRF merging")
reranked: List[RerankedResult] = Field(default_factory=list, description="Results after reranking")
retrieval_results: list[RetrievalMethodResults] = Field(
default_factory=list, description="Results from each retrieval method"
)
rrf_merged: list[RRFMergeResult] = Field(default_factory=list, description="Results after RRF merging")
reranked: list[RerankedResult] = Field(default_factory=list, description="Results after reranking")
# Legacy fields (kept for backward compatibility with graph/temporal visualizations)
entry_points: List[EntryPoint] = Field(default_factory=list, description="Entry points selected for search (legacy)")
visits: List[NodeVisit] = Field(default_factory=list, description="All nodes visited during search (legacy, for graph viz)")
pruned: List[PruningDecision] = Field(default_factory=list, description="Nodes that were pruned (legacy)")
entry_points: list[EntryPoint] = Field(
default_factory=list, description="Entry points selected for search (legacy)"
)
visits: list[NodeVisit] = Field(
default_factory=list, description="All nodes visited during search (legacy, for graph viz)"
)
pruned: list[PruningDecision] = Field(default_factory=list, description="Nodes that were pruned (legacy)")
summary: SearchSummary = Field(description="Summary statistics")
# Final results (for comparison with visits)
final_results: List[Dict[str, Any]] = Field(description="Final ranked results returned to user")
final_results: list[dict[str, Any]] = Field(description="Final ranked results returned to user")
model_config = {
"json_encoders": {
datetime: lambda v: v.isoformat()
}
}
model_config = {"json_encoders": {datetime: lambda v: v.isoformat()}}
def to_json(self, **kwargs) -> str:
"""Export trace as JSON string."""
@@ -186,14 +215,14 @@ class SearchTrace(BaseModel):
"""Export trace as dictionary."""
return self.model_dump()
def get_visit_by_node_id(self, node_id: str) -> Optional[NodeVisit]:
def get_visit_by_node_id(self, node_id: str) -> NodeVisit | None:
"""Find a visit by node ID."""
for visit in self.visits:
if visit.node_id == node_id:
return visit
return None
def get_search_path_to_node(self, node_id: str) -> List[NodeVisit]:
def get_search_path_to_node(self, node_id: str) -> list[NodeVisit]:
"""Get the path from entry point to a specific node."""
path = []
current_visit = self.get_visit_by_node_id(node_id)
@@ -207,10 +236,10 @@ class SearchTrace(BaseModel):
return path
def get_nodes_by_link_type(self, link_type: Literal["temporal", "semantic", "entity"]) -> List[NodeVisit]:
def get_nodes_by_link_type(self, link_type: Literal["temporal", "semantic", "entity"]) -> list[NodeVisit]:
"""Get all nodes reached via a specific link type."""
return [v for v in self.visits if v.link_type == link_type]
def get_entry_point_nodes(self) -> List[NodeVisit]:
def get_entry_point_nodes(self) -> list[NodeVisit]:
"""Get all entry point visits."""
return [v for v in self.visits if v.is_entry_point]
@@ -4,24 +4,25 @@ Search tracer for collecting detailed search execution traces.
The SearchTracer collects comprehensive information about each step
of the spreading activation search process for debugging and visualization.
"""
import time
from datetime import datetime, timezone
from typing import List, Optional, Dict, Any, Literal
from datetime import UTC, datetime
from typing import Any, Literal
from .trace import (
SearchTrace,
QueryInfo,
EntryPoint,
NodeVisit,
WeightComponents,
LinkInfo,
NodeVisit,
PruningDecision,
SearchSummary,
SearchPhaseMetrics,
RetrievalResult,
RetrievalMethodResults,
RRFMergeResult,
QueryInfo,
RerankedResult,
RetrievalMethodResults,
RetrievalResult,
RRFMergeResult,
SearchPhaseMetrics,
SearchSummary,
SearchTrace,
WeightComponents,
)
@@ -58,17 +59,17 @@ class SearchTracer:
self.max_tokens = max_tokens
# Trace data
self.query_embedding: Optional[List[float]] = None
self.start_time: Optional[float] = None
self.entry_points: List[EntryPoint] = []
self.visits: List[NodeVisit] = []
self.pruned: List[PruningDecision] = []
self.phase_metrics: List[SearchPhaseMetrics] = []
self.query_embedding: list[float] | None = None
self.start_time: float | None = None
self.entry_points: list[EntryPoint] = []
self.visits: list[NodeVisit] = []
self.pruned: list[PruningDecision] = []
self.phase_metrics: list[SearchPhaseMetrics] = []
# New 4-way retrieval tracking
self.retrieval_results: List[RetrievalMethodResults] = []
self.rrf_merged: List[RRFMergeResult] = []
self.reranked: List[RerankedResult] = []
self.retrieval_results: list[RetrievalMethodResults] = []
self.rrf_merged: list[RRFMergeResult] = []
self.reranked: list[RerankedResult] = []
# Tracking state
self.current_step = 0
@@ -83,7 +84,7 @@ class SearchTracer:
"""Start timing the search."""
self.start_time = time.time()
def record_query_embedding(self, embedding: List[float]):
def record_query_embedding(self, embedding: list[float]):
"""Record the query embedding."""
self.query_embedding = embedding
@@ -114,12 +115,12 @@ class SearchTracer:
node_id: str,
text: str,
context: str,
event_date: datetime,
event_date: datetime | None,
access_count: int,
is_entry_point: bool,
parent_node_id: Optional[str],
link_type: Optional[Literal["temporal", "semantic", "entity"]],
link_weight: Optional[float],
parent_node_id: str | None,
link_type: Literal["temporal", "semantic", "entity"] | None,
link_weight: float | None,
activation: float,
semantic_similarity: float,
recency: float,
@@ -199,10 +200,10 @@ class SearchTracer:
to_node_id: str,
link_type: Literal["temporal", "semantic", "entity"],
link_weight: float,
entity_id: Optional[str],
new_activation: Optional[float],
entity_id: str | None,
new_activation: float | None,
followed: bool,
prune_reason: Optional[str] = None,
prune_reason: str | None = None,
is_supplementary: bool = False,
):
"""
@@ -266,7 +267,7 @@ class SearchTracer:
)
)
def add_phase_metric(self, phase_name: str, duration_seconds: float, details: Optional[Dict[str, Any]] = None):
def add_phase_metric(self, phase_name: str, duration_seconds: float, details: dict[str, Any] | None = None):
"""
Record metrics for a search phase.
@@ -286,11 +287,11 @@ class SearchTracer:
def add_retrieval_results(
self,
method_name: Literal["semantic", "bm25", "graph", "temporal"],
results: List[tuple], # List of (doc_id, data) tuples
results: list[tuple], # List of (doc_id, data) tuples
duration_seconds: float,
score_field: str, # e.g., "similarity", "bm25_score"
metadata: Optional[Dict[str, Any]] = None,
fact_type: Optional[str] = None
metadata: dict[str, Any] | None = None,
fact_type: str | None = None,
):
"""
Record results from a single retrieval method.
@@ -331,7 +332,7 @@ class SearchTracer:
)
)
def add_rrf_merged(self, merged_results: List[tuple]):
def add_rrf_merged(self, merged_results: list[tuple]):
"""
Record RRF merged results.
@@ -350,7 +351,7 @@ class SearchTracer:
)
)
def add_reranked(self, reranked_results: List[Dict[str, Any]], rrf_merged: List):
def add_reranked(self, reranked_results: list[dict[str, Any]], rrf_merged: list):
"""
Record reranked results.
@@ -373,7 +374,15 @@ class SearchTracer:
# Keys from ScoredResult.to_dict(): cross_encoder_score, cross_encoder_score_normalized,
# rrf_normalized, temporal, recency, combined_score, weight
score_components = {}
for key in ["cross_encoder_score", "cross_encoder_score_normalized", "rrf_score", "rrf_normalized", "temporal", "recency", "combined_score"]:
for key in [
"cross_encoder_score",
"cross_encoder_score_normalized",
"rrf_score",
"rrf_normalized",
"temporal",
"recency",
"combined_score",
]:
if key in result and result[key] is not None:
score_components[key] = result[key]
@@ -389,7 +398,7 @@ class SearchTracer:
)
)
def finalize(self, final_results: List[Dict[str, Any]]) -> SearchTrace:
def finalize(self, final_results: list[dict[str, Any]]) -> SearchTrace:
"""
Finalize the trace and return the complete SearchTrace object.
@@ -416,7 +425,7 @@ class SearchTracer:
query_info = QueryInfo(
query_text=self.query_text,
query_embedding=self.query_embedding or [],
timestamp=datetime.now(timezone.utc),
timestamp=datetime.now(UTC),
budget=self.budget,
max_tokens=self.max_tokens,
)
@@ -6,8 +6,8 @@ providing type safety and making data flow explicit.
"""
from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any
from datetime import datetime
from typing import Any
@dataclass
@@ -17,28 +17,29 @@ class RetrievalResult:
This represents a raw result from the database query, before merging or reranking.
"""
id: str
text: str
fact_type: str
context: Optional[str] = None
event_date: Optional[datetime] = None
occurred_start: Optional[datetime] = None
occurred_end: Optional[datetime] = None
mentioned_at: Optional[datetime] = None
document_id: Optional[str] = None
chunk_id: Optional[str] = None
context: str | None = None
event_date: datetime | None = None
occurred_start: datetime | None = None
occurred_end: datetime | None = None
mentioned_at: datetime | None = None
document_id: str | None = None
chunk_id: str | None = None
access_count: int = 0
embedding: Optional[List[float]] = None
embedding: list[float] | None = None
# Retrieval-specific scores (only one will be set depending on retrieval method)
similarity: Optional[float] = None # Semantic retrieval
bm25_score: Optional[float] = None # BM25 retrieval
activation: Optional[float] = None # Graph retrieval (spreading activation)
temporal_score: Optional[float] = None # Temporal retrieval
temporal_proximity: Optional[float] = None # Temporal retrieval
similarity: float | None = None # Semantic retrieval
bm25_score: float | None = None # BM25 retrieval
activation: float | None = None # Graph retrieval (spreading activation)
temporal_score: float | None = None # Temporal retrieval
temporal_proximity: float | None = None # Temporal retrieval
@classmethod
def from_db_row(cls, row: Dict[str, Any]) -> "RetrievalResult":
def from_db_row(cls, row: dict[str, Any]) -> "RetrievalResult":
"""Create from a database row (asyncpg Record converted to dict)."""
return cls(
id=str(row["id"]),
@@ -68,13 +69,14 @@ class MergedCandidate:
Contains the original retrieval data plus RRF metadata.
"""
# Original retrieval data
retrieval: RetrievalResult
# RRF metadata
rrf_score: float
rrf_rank: int = 0
source_ranks: Dict[str, int] = field(default_factory=dict) # method_name -> rank
source_ranks: dict[str, int] = field(default_factory=dict) # method_name -> rank
@property
def id(self) -> str:
@@ -89,6 +91,7 @@ class ScoredResult:
Contains all retrieval/merge data plus reranking scores and combined score.
"""
# Original merged candidate
candidate: MergedCandidate
@@ -115,7 +118,7 @@ class ScoredResult:
"""Convenience property to access retrieval data."""
return self.candidate.retrieval
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> dict[str, Any]:
"""
Convert to dict for backwards compatibility.
@@ -6,10 +6,12 @@ This provides an abstraction that can be adapted to different execution models:
- Pub/Sub architectures (future)
- Message brokers (future)
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, Callable, Awaitable
import asyncio
import logging
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from typing import Any
logger = logging.getLogger(__name__)
@@ -29,10 +31,10 @@ class TaskBackend(ABC):
def __init__(self):
"""Initialize the task backend."""
self._executor: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None
self._executor: Callable[[dict[str, Any]], Awaitable[None]] | None = None
self._initialized = False
def set_executor(self, executor: Callable[[Dict[str, Any]], Awaitable[None]]):
def set_executor(self, executor: Callable[[dict[str, Any]], Awaitable[None]]):
"""
Set the executor callback for processing tasks.
@@ -49,7 +51,7 @@ class TaskBackend(ABC):
pass
@abstractmethod
async def submit_task(self, task_dict: Dict[str, Any]):
async def submit_task(self, task_dict: dict[str, Any]):
"""
Submit a task for execution.
@@ -65,7 +67,7 @@ class TaskBackend(ABC):
"""
pass
async def _execute_task(self, task_dict: Dict[str, Any]):
async def _execute_task(self, task_dict: dict[str, Any]):
"""
Execute a task through the registered executor.
@@ -73,19 +75,52 @@ class TaskBackend(ABC):
task_dict: Task dictionary to execute
"""
if self._executor is None:
task_type = task_dict.get('type', 'unknown')
task_type = task_dict.get("type", "unknown")
logger.warning(f"No executor registered, skipping task {task_type}")
return
try:
await self._executor(task_dict)
except Exception as e:
task_type = task_dict.get('type', 'unknown')
task_type = task_dict.get("type", "unknown")
logger.error(f"Error executing task {task_type}: {e}")
import traceback
traceback.print_exc()
class SyncTaskBackend(TaskBackend):
"""
Synchronous task backend that executes tasks immediately.
This is useful for embedded/CLI usage where we don't want background
workers that prevent clean exit. Tasks are executed inline rather than
being queued.
"""
async def initialize(self):
"""No-op for sync backend."""
self._initialized = True
logger.debug("SyncTaskBackend initialized")
async def submit_task(self, task_dict: dict[str, Any]):
"""
Execute the task immediately (synchronously).
Args:
task_dict: Task dictionary to execute
"""
if not self._initialized:
await self.initialize()
await self._execute_task(task_dict)
async def shutdown(self):
"""No-op for sync backend."""
self._initialized = False
logger.debug("SyncTaskBackend shutdown")
class AsyncIOQueueBackend(TaskBackend):
"""
Task backend implementation using asyncio queues.
@@ -94,11 +129,7 @@ class AsyncIOQueueBackend(TaskBackend):
and a periodic consumer worker.
"""
def __init__(
self,
batch_size: int = 100,
batch_interval: float = 1.0
):
def __init__(self, batch_size: int = 100, batch_interval: float = 1.0):
"""
Initialize AsyncIO queue backend.
@@ -107,9 +138,9 @@ class AsyncIOQueueBackend(TaskBackend):
batch_interval: Maximum time (seconds) to wait before processing batch
"""
super().__init__()
self._queue: Optional[asyncio.Queue] = None
self._worker_task: Optional[asyncio.Task] = None
self._shutdown_event: Optional[asyncio.Event] = None
self._queue: asyncio.Queue | None = None
self._worker_task: asyncio.Task | None = None
self._shutdown_event: asyncio.Event | None = None
self._batch_size = batch_size
self._batch_interval = batch_interval
@@ -124,7 +155,7 @@ class AsyncIOQueueBackend(TaskBackend):
self._initialized = True
logger.info("AsyncIOQueueBackend initialized")
async def submit_task(self, task_dict: Dict[str, Any]):
async def submit_task(self, task_dict: dict[str, Any]):
"""
Submit a task by putting it in the queue.
@@ -135,8 +166,8 @@ class AsyncIOQueueBackend(TaskBackend):
await self.initialize()
await self._queue.put(task_dict)
task_type = task_dict.get('type', 'unknown')
task_id = task_dict.get('id')
task_type = task_dict.get("type", "unknown")
task_id = task_dict.get("id")
async def wait_for_pending_tasks(self, timeout: float = 5.0):
"""
@@ -200,20 +231,16 @@ class AsyncIOQueueBackend(TaskBackend):
while len(tasks) < self._batch_size and asyncio.get_event_loop().time() < deadline:
try:
remaining_time = max(0.1, deadline - asyncio.get_event_loop().time())
task_dict = await asyncio.wait_for(
self._queue.get(),
timeout=remaining_time
)
task_dict = await asyncio.wait_for(self._queue.get(), timeout=remaining_time)
tasks.append(task_dict)
except asyncio.TimeoutError:
except TimeoutError:
break
# Process batch
if tasks:
# Execute tasks concurrently
await asyncio.gather(
*[self._execute_task(task_dict) for task_dict in tasks],
return_exceptions=True
*[self._execute_task(task_dict) for task_dict in tasks], return_exceptions=True
)
except asyncio.CancelledError:
+25 -10
View File
@@ -1,9 +1,10 @@
"""
Utility functions for memory system.
"""
import logging
from datetime import datetime
from typing import List, Dict, TYPE_CHECKING
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .llm_wrapper import LLMConfig
@@ -12,7 +13,14 @@ if TYPE_CHECKING:
from .retain.fact_extraction import extract_facts_from_text
async def extract_facts(text: str, event_date: datetime, context: str = "", llm_config: 'LLMConfig' = None, agent_name: str = None, extract_opinions: bool = False) -> tuple[List['Fact'], List[tuple[str, int]]]:
async def extract_facts(
text: str,
event_date: datetime,
context: str = "",
llm_config: "LLMConfig" = None,
agent_name: str = None,
extract_opinions: bool = False,
) -> tuple[list["Fact"], list[tuple[str, int]]]:
"""
Extract semantic facts from text using LLM.
@@ -41,16 +49,25 @@ async def extract_facts(text: str, event_date: datetime, context: str = "", llm_
if not text or not text.strip():
return [], []
facts, chunks = await extract_facts_from_text(text, event_date, context=context, llm_config=llm_config, agent_name=agent_name, extract_opinions=extract_opinions)
facts, chunks, _ = await extract_facts_from_text(
text,
event_date,
context=context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
)
if not facts:
logging.warning(f"LLM extracted 0 facts from text of length {len(text)}. This may indicate the text contains no meaningful information, or the LLM failed to extract facts. Full text: {text}")
logging.warning(
f"LLM extracted 0 facts from text of length {len(text)}. This may indicate the text contains no meaningful information, or the LLM failed to extract facts. Full text: {text}"
)
return [], chunks
return facts, chunks
def cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
"""
Calculate cosine similarity between two vectors.
@@ -100,6 +117,7 @@ def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -
Weight between 0 and 1
"""
import math
# Logarithmic decay: 1 / (1 + log(1 + days_since/half_life))
# This decays much slower than exponential, giving better long-term differentiation
normalized_age = days_since / half_life_days
@@ -121,6 +139,7 @@ def calculate_frequency_weight(access_count: int, max_boost: float = 2.0) -> flo
Weight between 1.0 and max_boost
"""
import math
if access_count <= 0:
return 1.0
@@ -158,11 +177,7 @@ def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime)
return midpoint
def calculate_temporal_proximity(
anchor_a: datetime,
anchor_b: datetime,
half_life_days: float = 30.0
) -> float:
def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float:
"""
Calculate temporal proximity between two temporal anchors.
@@ -0,0 +1,66 @@
"""
Hindsight Extensions System.
Extensions allow customizing and extending Hindsight behavior without modifying core code.
Extensions are loaded via environment variables pointing to implementation classes.
Example:
HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator
HINDSIGHT_API_OPERATION_VALIDATOR_MAX_RETRIES=3
HINDSIGHT_API_HTTP_EXTENSION=mypackage.http:MyHttpExtension
HINDSIGHT_API_HTTP_SOME_CONFIG=value
Extensions receive an ExtensionContext that provides a controlled API for interacting
with the system (e.g., running migrations for tenant schemas).
"""
from hindsight_api.extensions.base import Extension
from hindsight_api.extensions.builtin import ApiKeyTenantExtension
from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionContext
from hindsight_api.extensions.http import HttpExtension
from hindsight_api.extensions.loader import load_extension
from hindsight_api.extensions.operation_validator import (
OperationValidationError,
OperationValidatorExtension,
RecallContext,
RecallResult,
ReflectContext,
ReflectResultContext,
RetainContext,
RetainResult,
ValidationResult,
)
from hindsight_api.extensions.tenant import (
AuthenticationError,
TenantContext,
TenantExtension,
)
from hindsight_api.models import RequestContext
__all__ = [
# Base
"Extension",
"load_extension",
# Context
"ExtensionContext",
"DefaultExtensionContext",
# HTTP Extension
"HttpExtension",
# Operation Validator
"OperationValidationError",
"OperationValidatorExtension",
"RecallContext",
"RecallResult",
"ReflectContext",
"ReflectResultContext",
"RetainContext",
"RetainResult",
"ValidationResult",
# Tenant/Auth
"ApiKeyTenantExtension",
"AuthenticationError",
"RequestContext",
"TenantContext",
"TenantExtension",
]
@@ -0,0 +1,81 @@
"""Base Extension class for all Hindsight extensions."""
from abc import ABC
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from hindsight_api.extensions.context import ExtensionContext
class Extension(ABC):
"""
Base class for all Hindsight extensions.
Extensions are loaded via environment variables and receive configuration
from prefixed environment variables.
Example:
HINDSIGHT_API_MY_EXTENSION=mypackage.ext:MyExtension
HINDSIGHT_API_MY_SOME_CONFIG=value
The extension receives: {"some_config": "value"}
Extensions also receive an ExtensionContext that provides a controlled API
for interacting with the system (e.g., running migrations for tenant schemas).
"""
def __init__(self, config: dict[str, str]):
"""
Initialize the extension with configuration.
Args:
config: Dictionary of configuration values from environment variables.
Keys are lowercased with the prefix stripped.
"""
self.config = config
self._context: "ExtensionContext | None" = None
def set_context(self, context: "ExtensionContext") -> None:
"""
Set the extension context.
Called by the extension loader after instantiation.
Extensions should not call this directly.
Args:
context: The ExtensionContext providing system APIs.
"""
self._context = context
@property
def context(self) -> "ExtensionContext":
"""
Get the extension context.
Returns:
The ExtensionContext providing system APIs.
Raises:
RuntimeError: If context has not been set yet.
"""
if self._context is None:
raise RuntimeError(
"Extension context not set. Context is available after the extension is loaded by the system."
)
return self._context
async def on_startup(self) -> None:
"""
Called when the application starts.
Override to perform initialization tasks like connecting to external services.
"""
pass
async def on_shutdown(self) -> None:
"""
Called when the application shuts down.
Override to perform cleanup tasks like closing connections.
"""
pass
@@ -0,0 +1,18 @@
"""
Built-in extension implementations.
These are ready-to-use implementations of the extension interfaces.
They can be used directly or serve as examples for custom implementations.
Available built-in extensions:
- ApiKeyTenantExtension: Simple API key validation with public schema
Example usage:
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
"""
from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension
__all__ = [
"ApiKeyTenantExtension",
]
@@ -0,0 +1,33 @@
"""Built-in tenant extension implementations."""
from hindsight_api.extensions.tenant import AuthenticationError, TenantContext, TenantExtension
from hindsight_api.models import RequestContext
class ApiKeyTenantExtension(TenantExtension):
"""
Built-in tenant extension that validates API key against an environment variable.
This is a simple implementation that:
1. Validates the API key matches HINDSIGHT_API_TENANT_API_KEY
2. Returns 'public' as the schema for all authenticated requests
Configuration:
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
For multi-tenant setups with separate schemas per tenant, implement a custom
TenantExtension that looks up the schema based on the API key or token claims.
"""
def __init__(self, config: dict[str, str]):
super().__init__(config)
self.expected_api_key = config.get("api_key")
if not self.expected_api_key:
raise ValueError("HINDSIGHT_API_TENANT_API_KEY is required when using ApiKeyTenantExtension")
async def authenticate(self, context: RequestContext) -> TenantContext:
"""Validate API key and return public schema context."""
if context.api_key != self.expected_api_key:
raise AuthenticationError("Invalid API key")
return TenantContext(schema_name="public")
@@ -0,0 +1,117 @@
"""Extension context providing a controlled API for extensions to interact with the system."""
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from hindsight_api.engine.interface import MemoryEngineInterface
class ExtensionContext(ABC):
"""
Abstract context providing a controlled API for extensions.
Extensions receive this context instead of direct access to internal
components like MemoryEngine or database connections. This provides:
- A stable API that won't break when internals change
- Security by limiting what extensions can access
- Clear documentation of what extensions can do
Built-in implementation:
hindsight_api.extensions.builtin.context.DefaultExtensionContext
Example usage in an extension:
class MyTenantExtension(TenantExtension):
async def on_startup(self) -> None:
# Run migrations for a new tenant schema
await self.context.run_migration("tenant_acme")
class MyHttpExtension(HttpExtension):
def get_router(self, memory):
# Use memory engine for custom endpoints
engine = self.context.get_memory_engine()
...
"""
@abstractmethod
async def run_migration(self, schema: str) -> None:
"""
Run database migrations for a specific schema.
This creates the schema if it doesn't exist and runs all pending
migrations. Uses advisory locks to coordinate between distributed workers.
Args:
schema: PostgreSQL schema name (e.g., "tenant_acme").
The schema will be created if it doesn't exist.
Raises:
RuntimeError: If migrations fail to complete.
Example:
# Provision a new tenant schema
await context.run_migration("tenant_acme")
"""
...
@abstractmethod
def get_memory_engine(self) -> "MemoryEngineInterface":
"""
Get the memory engine interface.
Returns the MemoryEngineInterface for performing memory operations
like retain, recall, reflect, and entity/document management.
Returns:
MemoryEngineInterface instance.
Example:
engine = context.get_memory_engine()
result = await engine.recall_async(bank_id, query)
"""
...
class DefaultExtensionContext(ExtensionContext):
"""
Default implementation of ExtensionContext.
Uses the system's database URL and migration infrastructure.
"""
def __init__(
self,
database_url: str,
memory_engine: "MemoryEngineInterface | None" = None,
):
"""
Initialize the context.
Args:
database_url: SQLAlchemy database URL for migrations.
memory_engine: Optional MemoryEngine instance for memory operations.
"""
self._database_url = database_url
self._memory_engine = memory_engine
async def run_migration(self, schema: str) -> None:
"""Run migrations for a specific schema."""
from hindsight_api.migrations import run_migrations
# Prefer getting URL from memory engine (handles pg0 case where URL is set after init)
db_url = self._database_url
if self._memory_engine is not None:
engine_url = getattr(self._memory_engine, "db_url", None)
if engine_url:
db_url = engine_url
run_migrations(db_url, schema=schema)
def get_memory_engine(self) -> "MemoryEngineInterface":
"""Get the memory engine interface."""
if self._memory_engine is None:
raise RuntimeError(
"Memory engine not configured in ExtensionContext. "
"Ensure the context was created with a memory_engine parameter."
)
return self._memory_engine
@@ -0,0 +1,89 @@
"""
HTTP Extension for adding custom endpoints to the Hindsight API.
This extension allows adding custom HTTP endpoints under the /ext/ path prefix.
The extension provides a FastAPI router that is mounted on the main application.
"""
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from fastapi import APIRouter
from hindsight_api.extensions.base import Extension
if TYPE_CHECKING:
from hindsight_api import MemoryEngine
class HttpExtension(Extension, ABC):
"""
Base class for HTTP extensions that add custom API endpoints.
HTTP extensions provide a FastAPI router that gets mounted under /ext/.
The extension has full control over the routes, request/response models, and handlers.
Example:
```python
from fastapi import APIRouter
from hindsight_api.extensions import HttpExtension
class MyHttpExtension(HttpExtension):
def get_router(self, memory: MemoryEngine) -> APIRouter:
router = APIRouter()
@router.get("/hello")
async def hello():
return {"message": "Hello from extension!"}
@router.post("/custom/{bank_id}/action")
async def custom_action(bank_id: str):
# Access memory engine for database operations
pool = await memory._get_pool()
# ... custom logic
return {"status": "ok"}
return router
```
The routes will be available at:
- GET /ext/hello
- POST /ext/custom/{bank_id}/action
Configuration via environment variables:
HINDSIGHT_API_HTTP_EXTENSION=mypackage.ext:MyHttpExtension
HINDSIGHT_API_HTTP_SOME_CONFIG=value
The extension receives config: {"some_config": "value"}
"""
@abstractmethod
def get_router(self, memory: "MemoryEngine") -> APIRouter:
"""
Return a FastAPI router with custom endpoints.
The router will be mounted at /ext/ on the main application.
All routes defined in the router will be prefixed with /ext/.
Args:
memory: The MemoryEngine instance for database access and core operations.
Use this to access the connection pool, run queries, or call
memory operations like retain, recall, etc.
Returns:
A FastAPI APIRouter with the custom endpoints defined.
Example:
```python
def get_router(self, memory: MemoryEngine) -> APIRouter:
router = APIRouter(tags=["My Extension"])
@router.get("/status")
async def status():
health = await memory.health_check()
return {"extension": "healthy", "memory": health}
return router
```
"""
pass
@@ -0,0 +1,125 @@
"""Extension loader utilities."""
import importlib
import logging
import os
from typing import TYPE_CHECKING, TypeVar
from hindsight_api.extensions.base import Extension
if TYPE_CHECKING:
from hindsight_api.extensions.context import ExtensionContext
logger = logging.getLogger(__name__)
T = TypeVar("T", bound=Extension)
class ExtensionLoadError(Exception):
"""Raised when an extension fails to load."""
pass
def load_extension(
prefix: str,
base_class: type[T],
env_prefix: str = "HINDSIGHT_API",
context: "ExtensionContext | None" = None,
) -> T | None:
"""
Load an extension from environment variable configuration.
The extension class is specified via {env_prefix}_{prefix}_EXTENSION environment
variable in the format "module.path:ClassName".
Configuration for the extension is collected from all environment variables
matching {env_prefix}_{prefix}_* (excluding the EXTENSION variable itself).
Args:
prefix: The extension prefix (e.g., "OPERATION_VALIDATOR").
base_class: The base class that the extension must inherit from.
env_prefix: The environment variable prefix (default: "HINDSIGHT_API").
context: Optional ExtensionContext to provide system APIs to the extension.
Returns:
An instance of the extension, or None if not configured.
Raises:
ExtensionLoadError: If the extension fails to load or validate.
Example:
HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator
HINDSIGHT_API_OPERATION_VALIDATOR_MAX_REQUESTS=100
ext = load_extension("OPERATION_VALIDATOR", OperationValidatorExtension)
# ext.config == {"max_requests": "100"}
"""
env_var = f"{env_prefix}_{prefix}_EXTENSION"
ext_path = os.getenv(env_var)
if not ext_path:
logger.debug(f"No extension configured for {env_var}")
return None
logger.info(f"Loading extension from {env_var}={ext_path}")
# Parse "module.path:ClassName"
if ":" not in ext_path:
raise ExtensionLoadError(f"Invalid extension path '{ext_path}'. Expected format: 'module.path:ClassName'")
module_path, class_name = ext_path.rsplit(":", 1)
# Import the module
try:
module = importlib.import_module(module_path)
except ImportError as e:
raise ExtensionLoadError(f"Failed to import extension module '{module_path}': {e}") from e
# Get the class
try:
ext_class = getattr(module, class_name)
except AttributeError as e:
raise ExtensionLoadError(f"Extension class '{class_name}' not found in module '{module_path}'") from e
# Validate inheritance
if not isinstance(ext_class, type) or not issubclass(ext_class, base_class):
raise ExtensionLoadError(f"Extension class '{ext_class.__name__}' must inherit from '{base_class.__name__}'")
# Collect configuration from environment variables
config = _collect_config(env_prefix, prefix)
logger.info(f"Loaded extension {ext_class.__name__} with config keys: {list(config.keys())}")
# Instantiate the extension
try:
extension = ext_class(config)
except Exception as e:
raise ExtensionLoadError(f"Failed to instantiate extension '{ext_class.__name__}': {e}") from e
# Set the context if provided
if context is not None:
extension.set_context(context)
logger.debug(f"Set context on extension {ext_class.__name__}")
return extension
def _collect_config(env_prefix: str, prefix: str) -> dict[str, str]:
"""
Collect configuration from environment variables.
Collects all variables matching {env_prefix}_{prefix}_* except for
{env_prefix}_{prefix}_EXTENSION, strips the prefix, and lowercases keys.
"""
config = {}
full_prefix = f"{env_prefix}_{prefix}_"
extension_var = f"{full_prefix}EXTENSION"
for key, value in os.environ.items():
if key.startswith(full_prefix) and key != extension_var:
# Strip prefix and lowercase the key
config_key = key[len(full_prefix) :].lower()
config[config_key] = value
return config
@@ -0,0 +1,327 @@
"""Operation Validator Extension for validating retain/recall/reflect operations."""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any
from hindsight_api.extensions.base import Extension
if TYPE_CHECKING:
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import RecallResult as RecallResultModel
from hindsight_api.engine.response_models import ReflectResult
from hindsight_api.models import RequestContext
class OperationValidationError(Exception):
"""Raised when an operation fails validation."""
def __init__(self, reason: str, status_code: int = 403):
self.reason = reason
self.status_code = status_code
super().__init__(f"Operation validation failed: {reason}")
@dataclass
class ValidationResult:
"""Result of an operation validation."""
allowed: bool
reason: str | None = None
status_code: int = 403 # Default to Forbidden
@classmethod
def accept(cls) -> "ValidationResult":
"""Create an accepted validation result."""
return cls(allowed=True)
@classmethod
def reject(cls, reason: str, status_code: int = 403) -> "ValidationResult":
"""Create a rejected validation result with a reason and HTTP status code."""
return cls(allowed=False, reason=reason, status_code=status_code)
# =============================================================================
# Pre-operation Contexts (all user-provided parameters)
# =============================================================================
@dataclass
class RetainContext:
"""Context for a retain operation validation (pre-operation).
Contains ALL user-provided parameters for the retain operation.
"""
bank_id: str
contents: list[dict] # List of {content, context, event_date, document_id}
request_context: "RequestContext"
document_id: str | None = None
fact_type_override: str | None = None
confidence_score: float | None = None
@dataclass
class RecallContext:
"""Context for a recall operation validation (pre-operation).
Contains ALL user-provided parameters for the recall operation.
"""
bank_id: str
query: str
request_context: "RequestContext"
budget: "Budget | None" = None
max_tokens: int = 4096
enable_trace: bool = False
fact_types: list[str] = field(default_factory=list)
question_date: datetime | None = None
include_entities: bool = False
max_entity_tokens: int = 500
include_chunks: bool = False
max_chunk_tokens: int = 8192
@dataclass
class ReflectContext:
"""Context for a reflect operation validation (pre-operation).
Contains ALL user-provided parameters for the reflect operation.
"""
bank_id: str
query: str
request_context: "RequestContext"
budget: "Budget | None" = None
context: str | None = None
# =============================================================================
# Post-operation Contexts (includes results)
# =============================================================================
@dataclass
class RetainResult:
"""Result context for post-retain hook.
Contains the operation parameters and the result.
"""
bank_id: str
contents: list[dict]
request_context: "RequestContext"
document_id: str | None
fact_type_override: str | None
confidence_score: float | None
# Result
unit_ids: list[list[str]] # List of unit IDs per content item
success: bool = True
error: str | None = None
@dataclass
class RecallResult:
"""Result context for post-recall hook.
Contains the operation parameters and the result.
"""
bank_id: str
query: str
request_context: "RequestContext"
budget: "Budget | None"
max_tokens: int
enable_trace: bool
fact_types: list[str]
question_date: datetime | None
include_entities: bool
max_entity_tokens: int
include_chunks: bool
max_chunk_tokens: int
# Result
result: "RecallResultModel | None" = None
success: bool = True
error: str | None = None
@dataclass
class ReflectResultContext:
"""Result context for post-reflect hook.
Contains the operation parameters and the result.
"""
bank_id: str
query: str
request_context: "RequestContext"
budget: "Budget | None"
context: str | None
# Result
result: "ReflectResult | None" = None
success: bool = True
error: str | None = None
class OperationValidatorExtension(Extension, ABC):
"""
Validates and hooks into retain/recall/reflect operations.
This extension allows implementing custom logic such as:
- Rate limiting (pre-operation)
- Quota enforcement (pre-operation)
- Permission checks (pre-operation)
- Content filtering (pre-operation)
- Usage tracking (post-operation)
- Audit logging (post-operation)
- Metrics collection (post-operation)
Enable via environment variable:
HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator
Configuration is passed from prefixed environment variables:
HINDSIGHT_API_OPERATION_VALIDATOR_MAX_REQUESTS=100
-> config = {"max_requests": "100"}
Hook execution order:
1. validate_retain/validate_recall/validate_reflect (pre-operation)
2. [operation executes]
3. on_retain_complete/on_recall_complete/on_reflect_complete (post-operation)
"""
# =========================================================================
# Pre-operation validation hooks (abstract - must be implemented)
# =========================================================================
@abstractmethod
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
"""
Validate a retain operation before execution.
Called before the retain operation is processed. Return ValidationResult.reject()
to prevent the operation from executing.
Args:
ctx: Context containing all user-provided parameters:
- bank_id: Bank identifier
- contents: List of content dicts
- request_context: Request context with auth info
- document_id: Optional document ID
- fact_type_override: Optional fact type override
- confidence_score: Optional confidence score
Returns:
ValidationResult indicating whether the operation is allowed.
"""
...
@abstractmethod
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
"""
Validate a recall operation before execution.
Called before the recall operation is processed. Return ValidationResult.reject()
to prevent the operation from executing.
Args:
ctx: Context containing all user-provided parameters:
- bank_id: Bank identifier
- query: Search query
- request_context: Request context with auth info
- budget: Budget level
- max_tokens: Maximum tokens to return
- enable_trace: Whether to include trace info
- fact_types: List of fact types to search
- question_date: Optional date context for query
- include_entities: Whether to include entity data
- max_entity_tokens: Max tokens for entities
- include_chunks: Whether to include chunks
- max_chunk_tokens: Max tokens for chunks
Returns:
ValidationResult indicating whether the operation is allowed.
"""
...
@abstractmethod
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
"""
Validate a reflect operation before execution.
Called before the reflect operation is processed. Return ValidationResult.reject()
to prevent the operation from executing.
Args:
ctx: Context containing all user-provided parameters:
- bank_id: Bank identifier
- query: Question to answer
- request_context: Request context with auth info
- budget: Budget level
- context: Optional additional context
Returns:
ValidationResult indicating whether the operation is allowed.
"""
...
# =========================================================================
# Post-operation hooks (optional - override to implement)
# =========================================================================
async def on_retain_complete(self, result: RetainResult) -> None:
"""
Called after a retain operation completes (success or failure).
Override this method to implement post-operation logic such as:
- Usage tracking
- Audit logging
- Metrics collection
- Notifications
Args:
result: Result context containing:
- All original operation parameters
- unit_ids: List of created unit IDs (if success)
- success: Whether the operation succeeded
- error: Error message (if failed)
"""
pass
async def on_recall_complete(self, result: RecallResult) -> None:
"""
Called after a recall operation completes (success or failure).
Override this method to implement post-operation logic such as:
- Usage tracking
- Audit logging
- Metrics collection
- Query analytics
Args:
result: Result context containing:
- All original operation parameters
- result: RecallResultModel (if success)
- success: Whether the operation succeeded
- error: Error message (if failed)
"""
pass
async def on_reflect_complete(self, result: ReflectResultContext) -> None:
"""
Called after a reflect operation completes (success or failure).
Override this method to implement post-operation logic such as:
- Usage tracking
- Audit logging
- Metrics collection
- Response analytics
Args:
result: Result context containing:
- All original operation parameters
- result: ReflectResult (if success)
- success: Whether the operation succeeded
- error: Error message (if failed)
"""
pass
@@ -0,0 +1,63 @@
"""Tenant Extension for multi-tenancy and API key authentication."""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from hindsight_api.extensions.base import Extension
from hindsight_api.models import RequestContext
class AuthenticationError(Exception):
"""Raised when authentication fails."""
def __init__(self, reason: str):
self.reason = reason
super().__init__(f"Authentication failed: {reason}")
@dataclass
class TenantContext:
"""
Tenant context returned by authentication.
Contains the PostgreSQL schema name for tenant isolation.
All database queries will use fully-qualified table names
with this schema (e.g., schema_name.memory_units).
"""
schema_name: str
class TenantExtension(Extension, ABC):
"""
Extension for multi-tenancy and API key authentication.
This extension validates incoming requests and returns the tenant context
including the PostgreSQL schema to use for database operations.
Built-in implementation:
hindsight_api.extensions.builtin.tenant.ApiKeyTenantExtension
Enable via environment variable:
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
The returned schema_name is used for fully-qualified table names in queries,
enabling tenant isolation at the database level.
"""
@abstractmethod
async def authenticate(self, context: RequestContext) -> TenantContext:
"""
Authenticate the action context and return tenant context.
Args:
context: The action context containing API key and other auth data.
Returns:
TenantContext with the schema_name for database operations.
Raises:
AuthenticationError: If authentication fails.
"""
...
+146 -49
View File
@@ -4,8 +4,12 @@ Command-line interface for Hindsight API.
Run the server with:
hindsight-api
Run as background daemon:
hindsight-api --daemon
Stop with Ctrl+C.
"""
import argparse
import asyncio
import atexit
@@ -13,17 +17,21 @@ import os
import signal
import sys
import warnings
from typing import Optional
import uvicorn
from . import MemoryEngine
from .api import create_app
from .config import get_config, HindsightConfig
from .banner import print_banner
print()
print_banner()
from .config import HindsightConfig, get_config
from .daemon import (
DEFAULT_DAEMON_PORT,
DEFAULT_IDLE_TIMEOUT,
DaemonLock,
IdleTimeoutMiddleware,
daemonize,
)
from .extensions import DefaultExtensionContext, OperationValidatorExtension, TenantExtension, load_extension
# Filter deprecation warnings from third-party libraries
warnings.filterwarnings("ignore", message="websockets.legacy is deprecated")
@@ -33,7 +41,7 @@ warnings.filterwarnings("ignore", message="websockets.server.WebSocketServerProt
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# Global reference for cleanup
_memory: Optional[MemoryEngine] = None
_memory: MemoryEngine | None = None
def _cleanup():
@@ -70,62 +78,88 @@ def main():
# Server options
parser.add_argument(
"--host", default=config.host,
help=f"Host to bind to (default: {config.host}, env: HINDSIGHT_API_HOST)"
"--host", default=config.host, help=f"Host to bind to (default: {config.host}, env: HINDSIGHT_API_HOST)"
)
parser.add_argument(
"--port", type=int, default=config.port,
help=f"Port to bind to (default: {config.port}, env: HINDSIGHT_API_PORT)"
"--port",
type=int,
default=config.port,
help=f"Port to bind to (default: {config.port}, env: HINDSIGHT_API_PORT)",
)
parser.add_argument(
"--log-level", default=config.log_level,
"--log-level",
default=config.log_level,
choices=["critical", "error", "warning", "info", "debug", "trace"],
help=f"Log level (default: {config.log_level}, env: HINDSIGHT_API_LOG_LEVEL)"
help=f"Log level (default: {config.log_level}, env: HINDSIGHT_API_LOG_LEVEL)",
)
# Development options
parser.add_argument(
"--reload", action="store_true",
help="Enable auto-reload on code changes (development only)"
)
parser.add_argument(
"--workers", type=int, default=1,
help="Number of worker processes (default: 1)"
)
parser.add_argument("--reload", action="store_true", help="Enable auto-reload on code changes (development only)")
parser.add_argument("--workers", type=int, default=1, help="Number of worker processes (default: 1)")
# Access log options
parser.add_argument(
"--access-log", action="store_true",
help="Enable access log"
)
parser.add_argument(
"--no-access-log", dest="access_log", action="store_false",
help="Disable access log (default)"
)
parser.add_argument("--access-log", action="store_true", help="Enable access log")
parser.add_argument("--no-access-log", dest="access_log", action="store_false", help="Disable access log (default)")
parser.set_defaults(access_log=False)
# Proxy options
parser.add_argument(
"--proxy-headers", action="store_true",
help="Enable X-Forwarded-Proto, X-Forwarded-For headers"
"--proxy-headers", action="store_true", help="Enable X-Forwarded-Proto, X-Forwarded-For headers"
)
parser.add_argument(
"--forwarded-allow-ips", default=None,
help="Comma separated list of IPs to trust with proxy headers"
"--forwarded-allow-ips", default=None, help="Comma separated list of IPs to trust with proxy headers"
)
# SSL options
parser.add_argument("--ssl-keyfile", default=None, help="SSL key file")
parser.add_argument("--ssl-certfile", default=None, help="SSL certificate file")
# Daemon mode options
parser.add_argument(
"--ssl-keyfile", default=None,
help="SSL key file"
"--daemon",
action="store_true",
help=f"Run as background daemon (uses port {DEFAULT_DAEMON_PORT}, auto-exits after idle)",
)
parser.add_argument(
"--ssl-certfile", default=None,
help="SSL certificate file"
"--idle-timeout",
type=int,
default=DEFAULT_IDLE_TIMEOUT,
help=f"Idle timeout in seconds before auto-exit in daemon mode (default: {DEFAULT_IDLE_TIMEOUT})",
)
args = parser.parse_args()
# Daemon mode handling
if args.daemon:
# Use fixed daemon port
args.port = DEFAULT_DAEMON_PORT
args.host = "127.0.0.1" # Only bind to localhost for security
# Check if another daemon is already running
daemon_lock = DaemonLock()
if not daemon_lock.acquire():
print(f"Daemon already running (PID: {daemon_lock.get_pid()})", file=sys.stderr)
sys.exit(1)
# Fork into background
daemonize()
# Re-acquire lock in child process
daemon_lock = DaemonLock()
if not daemon_lock.acquire():
sys.exit(1)
# Register cleanup to release lock
def release_lock():
daemon_lock.release()
atexit.register(release_lock)
# Print banner (not in daemon mode)
if not args.daemon:
print()
print_banner()
# Configure Python logging based on log level
# Update config with CLI override if provided
if args.log_level != config.log_level:
@@ -135,6 +169,8 @@ def main():
llm_api_key=config.llm_api_key,
llm_model=config.llm_model,
llm_base_url=config.llm_base_url,
llm_max_concurrent=config.llm_max_concurrent,
llm_timeout=config.llm_timeout,
embeddings_provider=config.embeddings_provider,
embeddings_local_model=config.embeddings_local_model,
embeddings_tei_url=config.embeddings_tei_url,
@@ -145,16 +181,52 @@ def main():
port=args.port,
log_level=args.log_level,
mcp_enabled=config.mcp_enabled,
graph_retriever=config.graph_retriever,
observation_min_facts=config.observation_min_facts,
observation_top_entities=config.observation_top_entities,
retain_max_completion_tokens=config.retain_max_completion_tokens,
skip_llm_verification=config.skip_llm_verification,
lazy_reranker=config.lazy_reranker,
run_migrations_on_startup=config.run_migrations_on_startup,
)
config.configure_logging()
if not args.daemon:
config.log_config()
# Register cleanup handlers
atexit.register(_cleanup)
signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler)
# Load operation validator extension if configured
operation_validator = load_extension("OPERATION_VALIDATOR", OperationValidatorExtension)
if operation_validator:
import logging
logging.info(f"Loaded operation validator: {operation_validator.__class__.__name__}")
# Load tenant extension if configured
tenant_extension = load_extension("TENANT", TenantExtension)
if tenant_extension:
import logging
logging.info(f"Loaded tenant extension: {tenant_extension.__class__.__name__}")
# Create MemoryEngine (reads configuration from environment)
_memory = MemoryEngine()
_memory = MemoryEngine(
operation_validator=operation_validator,
tenant_extension=tenant_extension,
run_migrations=config.run_migrations_on_startup,
)
# Set extension context on tenant extension (needed for schema provisioning)
if tenant_extension:
extension_context = DefaultExtensionContext(
database_url=config.database_url,
memory_engine=_memory,
)
tenant_extension.set_context(extension_context)
logging.info("Extension context set on tenant extension")
# Create FastAPI app
app = create_app(
@@ -165,6 +237,12 @@ def main():
initialize_memory=True,
)
# Wrap with idle timeout middleware in daemon mode
idle_middleware = None
if args.daemon:
idle_middleware = IdleTimeoutMiddleware(app, idle_timeout=args.idle_timeout)
app = idle_middleware
# Prepare uvicorn config
uvicorn_config = {
"app": app,
@@ -188,21 +266,40 @@ def main():
if args.ssl_certfile:
uvicorn_config["ssl_certfile"] = args.ssl_certfile
# Print startup info (not in daemon mode)
if not args.daemon:
from .banner import print_startup_info
print_startup_info(
host=args.host,
port=args.port,
database_url=config.database_url,
llm_provider=config.llm_provider,
llm_model=config.llm_model,
embeddings_provider=config.embeddings_provider,
reranker_provider=config.reranker_provider,
mcp_enabled=config.mcp_enabled,
)
from .banner import print_startup_info
print_startup_info(
host=args.host,
port=args.port,
database_url=config.database_url,
llm_provider=config.llm_provider,
llm_model=config.llm_model,
embeddings_provider=config.embeddings_provider,
reranker_provider=config.reranker_provider,
mcp_enabled=config.mcp_enabled,
)
# Start idle checker in daemon mode
if idle_middleware is not None:
# Start the idle checker in a background thread with its own event loop
import threading
uvicorn.run(**uvicorn_config)
def run_idle_checker():
import time
time.sleep(2) # Wait for uvicorn to start
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(idle_middleware._check_idle())
except Exception:
pass
threading.Thread(target=run_idle_checker, daemon=True).start()
uvicorn.run(**uvicorn_config) # type: ignore[invalid-argument-type] - dict kwargs
if __name__ == "__main__":
+199
View File
@@ -0,0 +1,199 @@
"""
Local MCP server for use with Claude Code (stdio transport).
This runs a fully local Hindsight instance with embedded PostgreSQL (pg0).
No external database or server required.
Run with:
hindsight-local-mcp
Or with uvx:
uvx hindsight-api@latest hindsight-local-mcp
Configure in Claude Code's MCP settings:
{
"mcpServers": {
"hindsight": {
"command": "uvx",
"args": ["hindsight-api@latest", "hindsight-local-mcp"],
"env": {
"HINDSIGHT_API_LLM_API_KEY": "your-openai-key"
}
}
}
}
Environment variables:
HINDSIGHT_API_LLM_API_KEY: Required. API key for LLM provider.
HINDSIGHT_API_LLM_PROVIDER: Optional. LLM provider (default: "openai").
HINDSIGHT_API_LLM_MODEL: Optional. LLM model (default: "gpt-4o-mini").
HINDSIGHT_API_MCP_LOCAL_BANK_ID: Optional. Memory bank ID (default: "mcp").
HINDSIGHT_API_LOG_LEVEL: Optional. Log level (default: "warning").
HINDSIGHT_API_MCP_INSTRUCTIONS: Optional. Additional instructions appended to both retain and recall tools.
Example custom instructions (these are ADDED to the default behavior):
To also store assistant actions:
HINDSIGHT_API_MCP_INSTRUCTIONS="Also store every action you take, including tool calls, code written, and decisions made."
To also store conversation summaries:
HINDSIGHT_API_MCP_INSTRUCTIONS="Also store summaries of important conversations and their outcomes."
"""
import logging
import os
import sys
from mcp.server.fastmcp import FastMCP
from mcp.types import Icon
from hindsight_api.config import (
DEFAULT_MCP_LOCAL_BANK_ID,
DEFAULT_MCP_RECALL_DESCRIPTION,
DEFAULT_MCP_RETAIN_DESCRIPTION,
ENV_MCP_INSTRUCTIONS,
ENV_MCP_LOCAL_BANK_ID,
)
# Configure logging - default to warning to avoid polluting stderr during MCP init
# MCP clients interpret stderr output as errors, so we suppress INFO logs by default
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "warning").lower()
_log_level_map = {
"critical": logging.CRITICAL,
"error": logging.ERROR,
"warning": logging.WARNING,
"info": logging.INFO,
"debug": logging.DEBUG,
}
logging.basicConfig(
level=_log_level_map.get(_log_level_str, logging.WARNING),
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
stream=sys.stderr, # MCP uses stdout for protocol, logs go to stderr
)
logger = logging.getLogger(__name__)
def create_local_mcp_server(bank_id: str, memory=None) -> FastMCP:
"""
Create a stdio MCP server with retain/recall tools.
Args:
bank_id: The memory bank ID to use for all operations.
memory: Optional MemoryEngine instance. If not provided, creates one with pg0.
Returns:
Configured FastMCP server instance.
"""
# Import here to avoid slow startup if just checking --help
from hindsight_api import MemoryEngine
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.models import RequestContext
# Create memory engine with pg0 embedded database if not provided
if memory is None:
memory = MemoryEngine(db_url="pg0://hindsight-mcp")
# Get custom instructions from environment variable (appended to both tools)
extra_instructions = os.environ.get(ENV_MCP_INSTRUCTIONS, "")
retain_description = DEFAULT_MCP_RETAIN_DESCRIPTION
recall_description = DEFAULT_MCP_RECALL_DESCRIPTION
if extra_instructions:
retain_description = f"{DEFAULT_MCP_RETAIN_DESCRIPTION}\n\nAdditional instructions: {extra_instructions}"
recall_description = f"{DEFAULT_MCP_RECALL_DESCRIPTION}\n\nAdditional instructions: {extra_instructions}"
mcp = FastMCP("hindsight")
@mcp.tool(description=retain_description)
async def retain(content: str, context: str = "general") -> dict:
"""
Args:
content: The fact/memory to store (be specific and include relevant details)
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
"""
import asyncio
async def _retain():
try:
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": content, "context": context}],
request_context=RequestContext(),
)
except Exception as e:
logger.error(f"Error storing memory: {e}", exc_info=True)
# Fire and forget - don't block on memory storage
asyncio.create_task(_retain())
return {"status": "accepted", "message": "Memory storage initiated"}
@mcp.tool(description=recall_description)
async def recall(query: str, max_tokens: int = 4096, budget: str = "low") -> dict:
"""
Args:
query: Natural language search query (e.g., "user's food preferences", "what projects is user working on")
max_tokens: Maximum tokens to return in results (default: 4096)
budget: Search budget level - "low", "mid", or "high" (default: "low")
"""
try:
# Map string budget to enum
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(budget.lower(), Budget.LOW)
search_result = await memory.recall_async(
bank_id=bank_id,
query=query,
fact_type=list(VALID_RECALL_FACT_TYPES),
budget=budget_enum,
max_tokens=max_tokens,
request_context=RequestContext(),
)
return search_result.model_dump()
except Exception as e:
logger.error(f"Error searching: {e}", exc_info=True)
return {"error": str(e), "results": []}
return mcp
async def _initialize_and_run(bank_id: str):
"""Initialize memory and run the MCP server."""
from hindsight_api import MemoryEngine
# Create and initialize memory engine with pg0 embedded database
# Note: We avoid printing to stderr during init as MCP clients show it as "errors"
memory = MemoryEngine(db_url="pg0://hindsight-mcp")
await memory.initialize()
# Create and run the server
mcp = create_local_mcp_server(bank_id, memory=memory)
await mcp.run_stdio_async()
def main():
"""Main entry point for the stdio MCP server."""
import asyncio
from hindsight_api.config import ENV_LLM_API_KEY, get_config
# Check for required environment variables
config = get_config()
if not config.llm_api_key:
print(f"Error: {ENV_LLM_API_KEY} environment variable is required", file=sys.stderr)
print("Set it in your MCP configuration or shell environment", file=sys.stderr)
sys.exit(1)
# Get bank ID from environment, default to "mcp"
bank_id = os.environ.get(ENV_MCP_LOCAL_BANK_ID, DEFAULT_MCP_LOCAL_BANK_ID)
# Note: We don't print to stderr as MCP clients display it as "error output"
# Use HINDSIGHT_API_LOG_LEVEL=debug for verbose startup logging
# Run the async initialization and server
asyncio.run(_initialize_and_run(bank_id))
if __name__ == "__main__":
main()
+44 -30
View File
@@ -6,16 +6,15 @@ This module provides metrics for:
- Token usage (input/output) per operation
- Per-bank granularity via labels
"""
import logging
from typing import Dict, Any, Optional
from contextlib import contextmanager
import time
from contextlib import contextmanager
from opentelemetry import metrics
from opentelemetry.exporter.prometheus import PrometheusMetricReader
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.resources import Resource
from opentelemetry.exporter.prometheus import PrometheusMetricReader
from prometheus_client import REGISTRY
logger = logging.getLogger(__name__)
@@ -39,19 +38,18 @@ def initialize_metrics(service_name: str = "hindsight-api", service_version: str
global _meter
# Create resource with service information
resource = Resource.create({
"service.name": service_name,
"service.version": service_version,
})
resource = Resource.create(
{
"service.name": service_name,
"service.version": service_version,
}
)
# Create Prometheus metric reader
prometheus_reader = PrometheusMetricReader()
# Create meter provider with Prometheus exporter
provider = MeterProvider(
resource=resource,
metric_readers=[prometheus_reader]
)
provider = MeterProvider(resource=resource, metric_readers=[prometheus_reader])
# Set the global meter provider
metrics.set_meter_provider(provider)
@@ -73,11 +71,19 @@ class MetricsCollectorBase:
"""Base class for metrics collectors."""
@contextmanager
def record_operation(self, operation: str, bank_id: str, budget: Optional[str] = None, max_tokens: Optional[int] = None):
def record_operation(self, operation: str, bank_id: str, budget: str | None = None, max_tokens: int | None = None):
"""Context manager to record operation duration and status."""
raise NotImplementedError
def record_tokens(self, operation: str, bank_id: str, input_tokens: int = 0, output_tokens: int = 0, budget: Optional[str] = None, max_tokens: Optional[int] = None):
def record_tokens(
self,
operation: str,
bank_id: str,
input_tokens: int = 0,
output_tokens: int = 0,
budget: str | None = None,
max_tokens: int | None = None,
):
"""Record token usage for an operation."""
raise NotImplementedError
@@ -86,11 +92,19 @@ class NoOpMetricsCollector(MetricsCollectorBase):
"""No-op metrics collector that does nothing. Used when metrics are disabled."""
@contextmanager
def record_operation(self, operation: str, bank_id: str, budget: Optional[str] = None, max_tokens: Optional[int] = None):
def record_operation(self, operation: str, bank_id: str, budget: str | None = None, max_tokens: int | None = None):
"""No-op context manager."""
yield
def record_tokens(self, operation: str, bank_id: str, input_tokens: int = 0, output_tokens: int = 0, budget: Optional[str] = None, max_tokens: Optional[int] = None):
def record_tokens(
self,
operation: str,
bank_id: str,
input_tokens: int = 0,
output_tokens: int = 0,
budget: str | None = None,
max_tokens: int | None = None,
):
"""No-op token recording."""
pass
@@ -108,33 +122,25 @@ class MetricsCollector(MetricsCollectorBase):
# Operation latency histogram (in seconds)
# Records duration of retain, recall, reflect operations
self.operation_duration = self.meter.create_histogram(
name="hindsight.operation.duration",
description="Duration of Hindsight operations in seconds",
unit="s"
name="hindsight.operation.duration", description="Duration of Hindsight operations in seconds", unit="s"
)
# Token usage counters
self.tokens_input = self.meter.create_counter(
name="hindsight.tokens.input",
description="Number of input tokens consumed",
unit="tokens"
name="hindsight.tokens.input", description="Number of input tokens consumed", unit="tokens"
)
self.tokens_output = self.meter.create_counter(
name="hindsight.tokens.output",
description="Number of output tokens generated",
unit="tokens"
name="hindsight.tokens.output", description="Number of output tokens generated", unit="tokens"
)
# Operation counter (success/failure)
self.operation_total = self.meter.create_counter(
name="hindsight.operation.total",
description="Total number of operations executed",
unit="operations"
name="hindsight.operation.total", description="Total number of operations executed", unit="operations"
)
@contextmanager
def record_operation(self, operation: str, bank_id: str, budget: Optional[str] = None, max_tokens: Optional[int] = None):
def record_operation(self, operation: str, bank_id: str, budget: str | None = None, max_tokens: int | None = None):
"""
Context manager to record operation duration and status.
@@ -175,7 +181,15 @@ class MetricsCollector(MetricsCollectorBase):
# Record operation count
self.operation_total.add(1, attributes)
def record_tokens(self, operation: str, bank_id: str, input_tokens: int = 0, output_tokens: int = 0, budget: Optional[str] = None, max_tokens: Optional[int] = None):
def record_tokens(
self,
operation: str,
bank_id: str,
input_tokens: int = 0,
output_tokens: int = 0,
budget: str | None = None,
max_tokens: int | None = None,
):
"""
Record token usage for an operation.
+190 -16
View File
@@ -6,16 +6,19 @@ on application startup. It is designed to be safe for concurrent
execution using PostgreSQL advisory locks to coordinate between
distributed workers.
Supports multi-tenant schema isolation: migrations can target a specific
PostgreSQL schema, allowing each tenant to have isolated tables.
Important: All migrations must be backward-compatible to allow
safe rolling deployments.
No alembic.ini required - all configuration is done programmatically.
"""
import hashlib
import logging
import os
import shutil
from pathlib import Path
from typing import Optional
from alembic import command
from alembic.config import Config
@@ -27,11 +30,29 @@ logger = logging.getLogger(__name__)
MIGRATION_LOCK_ID = 123456789
def _run_migrations_internal(database_url: str, script_location: str) -> None:
def _get_schema_lock_id(schema: str) -> int:
"""
Generate a unique advisory lock ID for a schema.
Uses hash of schema name to create a deterministic lock ID.
"""
# Use hash to create a unique lock ID per schema
# Keep within PostgreSQL's bigint range
hash_bytes = hashlib.sha256(schema.encode()).digest()[:8]
return int.from_bytes(hash_bytes, byteorder="big") % (2**31)
def _run_migrations_internal(database_url: str, script_location: str, schema: str | None = None) -> None:
"""
Internal function to run migrations without locking.
Args:
database_url: SQLAlchemy database URL
script_location: Path to alembic scripts
schema: Target schema (None for default/public)
"""
logger.info(f"Running database migrations to head...")
schema_name = schema or "public"
logger.info(f"Running database migrations to head for schema '{schema_name}'...")
logger.info(f"Database URL: {database_url}")
logger.info(f"Script location: {script_location}")
@@ -51,13 +72,22 @@ def _run_migrations_internal(database_url: str, script_location: str) -> None:
# Set path_separator to avoid deprecation warning
alembic_cfg.set_main_option("path_separator", "os")
# Run migrations to head (latest version)
# If targeting a specific schema, pass it to env.py via config
# env.py will handle setting search_path and version_table_schema
if schema:
alembic_cfg.set_main_option("target_schema", schema)
# Run migrations
command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully")
logger.info(f"Database migrations completed successfully for schema '{schema_name}'")
def run_migrations(database_url: str, script_location: Optional[str] = None) -> None:
def run_migrations(
database_url: str,
script_location: str | None = None,
schema: str | None = None,
) -> None:
"""
Run database migrations to the latest version using programmatic Alembic configuration.
@@ -66,19 +96,28 @@ def run_migrations(database_url: str, script_location: Optional[str] = None) ->
- Other workers wait for the lock, then verify migrations are complete
- If schema is already up-to-date, this is a fast no-op
Supports multi-tenant schema isolation: when a schema is specified, migrations
run in that schema instead of public. This allows tenant extensions to provision
new tenant schemas with their own isolated tables.
Args:
database_url: SQLAlchemy database URL (e.g., "postgresql://user:pass@host/db")
script_location: Path to alembic migrations directory (e.g., "/path/to/alembic").
If None, defaults to hindsight-api/alembic directory.
schema: Target PostgreSQL schema name. If None, uses default (public).
When specified, creates the schema if needed and runs migrations there.
Raises:
RuntimeError: If migrations fail to complete
FileNotFoundError: If script_location doesn't exist
Example:
# Using default location (hindsight_api package)
# Using default location and public schema
run_migrations("postgresql://user:pass@host/db")
# Run migrations for a specific tenant schema
run_migrations("postgresql://user:pass@host/db", schema="tenant_acme")
# Using custom location (when importing from another project)
run_migrations(
"postgresql://user:pass@host/db",
@@ -97,25 +136,28 @@ def run_migrations(database_url: str, script_location: Optional[str] = None) ->
script_path = Path(script_location)
if not script_path.exists():
raise FileNotFoundError(
f"Alembic script location not found at {script_location}. "
"Database migrations cannot be run."
f"Alembic script location not found at {script_location}. Database migrations cannot be run."
)
# Use schema-specific lock ID for multi-tenant isolation
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
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 (id={MIGRATION_LOCK_ID})...")
conn.execute(text(f"SELECT pg_advisory_lock({MIGRATION_LOCK_ID})"))
logger.debug(f"Acquiring migration advisory lock for schema '{schema_name}' (id={lock_id})...")
conn.execute(text(f"SELECT pg_advisory_lock({lock_id})"))
logger.debug("Migration advisory lock acquired")
try:
# Run migrations while holding the lock
_run_migrations_internal(database_url, script_location)
_run_migrations_internal(database_url, script_location, schema=schema)
finally:
# Explicitly release the lock (also released on connection close)
conn.execute(text(f"SELECT pg_advisory_unlock({MIGRATION_LOCK_ID})"))
conn.execute(text(f"SELECT pg_advisory_unlock({lock_id})"))
logger.debug("Migration advisory lock released")
except FileNotFoundError:
@@ -130,7 +172,9 @@ def run_migrations(database_url: str, script_location: Optional[str] = None) ->
raise RuntimeError("Database migration failed") from e
def check_migration_status(database_url: Optional[str] = None, script_location: Optional[str] = None) -> tuple[str | None, str | None]:
def check_migration_status(
database_url: str | None = None, script_location: str | None = None
) -> tuple[str | None, str | None]:
"""
Check current database schema version and latest available version.
@@ -151,7 +195,9 @@ def check_migration_status(database_url: Optional[str] = None, script_location:
if database_url is None:
database_url = os.getenv("HINDSIGHT_API_DATABASE_URL")
if not database_url:
logger.warning("Database URL not provided and HINDSIGHT_API_DATABASE_URL not set, cannot check migration status")
logger.warning(
"Database URL not provided and HINDSIGHT_API_DATABASE_URL not set, cannot check migration status"
)
return None, None
# Get current revision from database
@@ -183,3 +229,131 @@ def check_migration_status(database_url: Optional[str] = None, script_location:
except Exception as e:
logger.warning(f"Unable to check migration status: {e}")
return None, None
def ensure_embedding_dimension(
database_url: str,
required_dimension: int,
schema: str | None = None,
) -> None:
"""
Ensure the embedding column dimension matches the model's dimension.
This function checks the current vector column dimension in the database
and adjusts it if necessary:
- If dimensions match: no action needed
- If dimensions differ and table is empty: ALTER COLUMN to new dimension
- If dimensions differ and table has data: raise error with migration guidance
Args:
database_url: SQLAlchemy database URL
required_dimension: The embedding dimension required by the model
schema: Target PostgreSQL schema name (None for public)
Raises:
RuntimeError: If dimension mismatch with existing data
"""
schema_name = schema or "public"
engine = create_engine(database_url)
with engine.connect() as conn:
# Check if memory_units table exists
table_exists = conn.execute(
text("""
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = :schema AND table_name = 'memory_units'
)
"""),
{"schema": schema_name},
).scalar()
if not table_exists:
logger.debug(f"memory_units table does not exist in schema '{schema_name}', skipping dimension check")
return
# Get current column dimension from pg_attribute
# pgvector stores dimension in atttypmod
current_dim = conn.execute(
text("""
SELECT atttypmod
FROM pg_attribute a
JOIN pg_class c ON a.attrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE n.nspname = :schema
AND c.relname = 'memory_units'
AND a.attname = 'embedding'
"""),
{"schema": schema_name},
).scalar()
if current_dim is None:
logger.warning("Could not determine current embedding dimension, skipping check")
return
# pgvector stores dimension directly in atttypmod (no offset like other types)
current_dimension = current_dim
if current_dimension == required_dimension:
logger.debug(f"Embedding dimension OK: {current_dimension}")
return
logger.info(
f"Embedding dimension mismatch: database has {current_dimension}, model requires {required_dimension}"
)
# Check if table has data
row_count = conn.execute(
text(f"SELECT COUNT(*) FROM {schema_name}.memory_units WHERE embedding IS NOT NULL")
).scalar()
if row_count > 0:
raise RuntimeError(
f"Cannot change embedding dimension from {current_dimension} to {required_dimension}: "
f"memory_units table contains {row_count} rows with embeddings. "
f"To change dimensions, you must either:\n"
f" 1. Re-embed all data: DELETE FROM {schema_name}.memory_units; then restart\n"
f" 2. Use a model with {current_dimension}-dimensional embeddings"
)
# Table is empty, safe to alter column
logger.info(f"Altering embedding column dimension from {current_dimension} to {required_dimension}")
# Drop the HNSW index on embedding column if it exists
# Only drop indexes that use 'hnsw' and reference the 'embedding' column
conn.execute(
text(f"""
DO $$
DECLARE idx_name TEXT;
BEGIN
FOR idx_name IN
SELECT indexname FROM pg_indexes
WHERE schemaname = '{schema_name}'
AND tablename = 'memory_units'
AND indexdef LIKE '%hnsw%'
AND indexdef LIKE '%embedding%'
LOOP
EXECUTE 'DROP INDEX IF EXISTS {schema_name}.' || idx_name;
END LOOP;
END $$;
""")
)
# Alter the column type
conn.execute(
text(f"ALTER TABLE {schema_name}.memory_units ALTER COLUMN embedding TYPE vector({required_dimension})")
)
conn.commit()
# Recreate the HNSW index
conn.execute(
text(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_embedding_hnsw
ON {schema_name}.memory_units
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)
""")
)
conn.commit()
logger.info(f"Successfully changed embedding dimension to {required_dimension}")
+82 -74
View File
@@ -1,49 +1,67 @@
"""
SQLAlchemy models for the memory system.
"""
from datetime import datetime
from typing import Optional
from uuid import UUID as PyUUID, uuid4
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID as PyUUID
@dataclass
class RequestContext:
"""
Context for request authentication and authorization.
This dataclass carries authentication data from HTTP requests to the
memory engine operations. It can be extended to include additional
context like headers, tokens, user info, etc.
"""
api_key: str | None = None
api_key_id: str | None = None # UUID of the API key used for authentication
tenant_id: str | None = None # Tenant identifier (set by extension after auth)
internal: bool = False # True for background/internal operations (not user-visible)
from pgvector.sqlalchemy import Vector
from sqlalchemy import (
CheckConstraint,
Column,
Float,
ForeignKey,
ForeignKeyConstraint,
Index,
Integer,
PrimaryKeyConstraint,
Text,
func,
)
from sqlalchemy import (
text as sql_text,
)
from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP, UUID
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from pgvector.sqlalchemy import Vector
from .config import EMBEDDING_DIMENSION
class Base(AsyncAttrs, DeclarativeBase):
"""Base class for all models."""
pass
class Document(Base):
"""Source documents for memory units."""
__tablename__ = "documents"
id: Mapped[str] = mapped_column(Text, primary_key=True)
bank_id: Mapped[str] = mapped_column(Text, primary_key=True)
original_text: Mapped[Optional[str]] = mapped_column(Text)
content_hash: Mapped[Optional[str]] = mapped_column(Text)
original_text: Mapped[str | None] = mapped_column(Text)
content_hash: Mapped[str | None] = mapped_column(Text)
doc_metadata: Mapped[dict] = mapped_column("metadata", JSONB, server_default=sql_text("'{}'::jsonb"))
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
# Relationships
memory_units = relationship("MemoryUnit", back_populates="document", cascade="all, delete-orphan")
@@ -56,45 +74,42 @@ class Document(Base):
class MemoryUnit(Base):
"""Individual sentence-level memories."""
__tablename__ = "memory_units"
id: Mapped[PyUUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, server_default=sql_text("gen_random_uuid()")
)
bank_id: Mapped[str] = mapped_column(Text, nullable=False)
document_id: Mapped[Optional[str]] = mapped_column(Text)
document_id: Mapped[str | None] = mapped_column(Text)
text: Mapped[str] = mapped_column(Text, nullable=False)
embedding = mapped_column(Vector(384)) # pgvector type
context: Mapped[Optional[str]] = mapped_column(Text)
event_date: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) # Kept for backward compatibility
occurred_start: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP(timezone=True)) # When fact occurred (range start)
occurred_end: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP(timezone=True)) # When fact occurred (range end)
mentioned_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP(timezone=True)) # When fact was mentioned
embedding = mapped_column(Vector(EMBEDDING_DIMENSION)) # pgvector type
context: Mapped[str | None] = mapped_column(Text)
event_date: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False
) # Kept for backward compatibility
occurred_start: Mapped[datetime | None] = mapped_column(
TIMESTAMP(timezone=True)
) # When fact occurred (range start)
occurred_end: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) # When fact occurred (range end)
mentioned_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) # When fact was mentioned
fact_type: Mapped[str] = mapped_column(Text, nullable=False, server_default="world")
confidence_score: Mapped[Optional[float]] = mapped_column(Float)
confidence_score: Mapped[float | None] = mapped_column(Float)
access_count: Mapped[int] = mapped_column(Integer, server_default="0")
unit_metadata: Mapped[dict] = mapped_column("metadata", JSONB, server_default=sql_text("'{}'::jsonb")) # User-defined metadata (str->str)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
unit_metadata: Mapped[dict] = mapped_column(
"metadata", JSONB, server_default=sql_text("'{}'::jsonb")
) # User-defined metadata (str->str)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
# Relationships
document = relationship("Document", back_populates="memory_units")
unit_entities = relationship("UnitEntity", back_populates="memory_unit", cascade="all, delete-orphan")
outgoing_links = relationship(
"MemoryLink",
foreign_keys="MemoryLink.from_unit_id",
back_populates="from_unit",
cascade="all, delete-orphan"
"MemoryLink", foreign_keys="MemoryLink.from_unit_id", back_populates="from_unit", cascade="all, delete-orphan"
)
incoming_links = relationship(
"MemoryLink",
foreign_keys="MemoryLink.to_unit_id",
back_populates="to_unit",
cascade="all, delete-orphan"
"MemoryLink", foreign_keys="MemoryLink.to_unit_id", back_populates="to_unit", cascade="all, delete-orphan"
)
__table_args__ = (
@@ -110,7 +125,7 @@ class MemoryUnit(Base):
"(fact_type = 'opinion' AND confidence_score IS NOT NULL) OR "
"(fact_type = 'observation') OR "
"(fact_type NOT IN ('opinion', 'observation') AND confidence_score IS NULL)",
name="confidence_score_fact_type_check"
name="confidence_score_fact_type_check",
),
Index("idx_memory_units_bank_id", "bank_id"),
Index("idx_memory_units_document_id", "document_id"),
@@ -119,39 +134,46 @@ class MemoryUnit(Base):
Index("idx_memory_units_access_count", "access_count", postgresql_ops={"access_count": "DESC"}),
Index("idx_memory_units_fact_type", "fact_type"),
Index("idx_memory_units_bank_fact_type", "bank_id", "fact_type"),
Index("idx_memory_units_bank_type_date", "bank_id", "fact_type", "event_date", postgresql_ops={"event_date": "DESC"}),
Index(
"idx_memory_units_bank_type_date",
"bank_id",
"fact_type",
"event_date",
postgresql_ops={"event_date": "DESC"},
),
Index(
"idx_memory_units_opinion_confidence",
"bank_id",
"confidence_score",
postgresql_where=sql_text("fact_type = 'opinion'"),
postgresql_ops={"confidence_score": "DESC"}
postgresql_ops={"confidence_score": "DESC"},
),
Index(
"idx_memory_units_opinion_date",
"bank_id",
"event_date",
postgresql_where=sql_text("fact_type = 'opinion'"),
postgresql_ops={"event_date": "DESC"}
postgresql_ops={"event_date": "DESC"},
),
Index(
"idx_memory_units_observation_date",
"bank_id",
"event_date",
postgresql_where=sql_text("fact_type = 'observation'"),
postgresql_ops={"event_date": "DESC"}
postgresql_ops={"event_date": "DESC"},
),
Index(
"idx_memory_units_embedding",
"embedding",
postgresql_using="hnsw",
postgresql_ops={"embedding": "vector_cosine_ops"}
postgresql_ops={"embedding": "vector_cosine_ops"},
),
)
class Entity(Base):
"""Resolved entities (people, organizations, locations, etc.)."""
__tablename__ = "entities"
id: Mapped[PyUUID] = mapped_column(
@@ -160,12 +182,8 @@ class Entity(Base):
canonical_name: Mapped[str] = mapped_column(Text, nullable=False)
bank_id: Mapped[str] = mapped_column(Text, nullable=False)
entity_metadata: Mapped[dict] = mapped_column("metadata", JSONB, server_default=sql_text("'{}'::jsonb"))
first_seen: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
last_seen: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
first_seen: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
last_seen: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
mention_count: Mapped[int] = mapped_column(Integer, server_default="1")
# Relationships
@@ -175,13 +193,13 @@ class Entity(Base):
"EntityCooccurrence",
foreign_keys="EntityCooccurrence.entity_id_1",
back_populates="entity_1",
cascade="all, delete-orphan"
cascade="all, delete-orphan",
)
cooccurrences_2 = relationship(
"EntityCooccurrence",
foreign_keys="EntityCooccurrence.entity_id_2",
back_populates="entity_2",
cascade="all, delete-orphan"
cascade="all, delete-orphan",
)
__table_args__ = (
@@ -193,6 +211,7 @@ class Entity(Base):
class UnitEntity(Base):
"""Association between memory units and entities."""
__tablename__ = "unit_entities"
unit_id: Mapped[PyUUID] = mapped_column(
@@ -214,6 +233,7 @@ class UnitEntity(Base):
class EntityCooccurrence(Base):
"""Materialized cache of entity co-occurrences."""
__tablename__ = "entity_cooccurrences"
entity_id_1: Mapped[PyUUID] = mapped_column(
@@ -223,9 +243,7 @@ class EntityCooccurrence(Base):
UUID(as_uuid=True), ForeignKey("entities.id", ondelete="CASCADE"), primary_key=True
)
cooccurrence_count: Mapped[int] = mapped_column(Integer, server_default="1")
last_cooccurred: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
last_cooccurred: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
# Relationships
entity_1 = relationship("Entity", foreign_keys=[entity_id_1], back_populates="cooccurrences_1")
@@ -241,6 +259,7 @@ class EntityCooccurrence(Base):
class MemoryLink(Base):
"""Links between memory units (temporal, semantic, entity)."""
__tablename__ = "memory_links"
from_unit_id: Mapped[PyUUID] = mapped_column(
@@ -250,13 +269,11 @@ class MemoryLink(Base):
UUID(as_uuid=True), ForeignKey("memory_units.id", ondelete="CASCADE"), primary_key=True
)
link_type: Mapped[str] = mapped_column(Text, primary_key=True)
entity_id: Mapped[Optional[PyUUID]] = mapped_column(
entity_id: Mapped[PyUUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("entities.id", ondelete="CASCADE"), primary_key=True
)
weight: Mapped[float] = mapped_column(Float, nullable=False, server_default="1.0")
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
# Relationships
from_unit = relationship("MemoryUnit", foreign_keys=[from_unit_id], back_populates="outgoing_links")
@@ -266,7 +283,7 @@ class MemoryLink(Base):
__table_args__ = (
CheckConstraint(
"link_type IN ('temporal', 'semantic', 'entity', 'causes', 'caused_by', 'enables', 'prevents')",
name="memory_links_link_type_check"
name="memory_links_link_type_check",
),
CheckConstraint("weight >= 0.0 AND weight <= 1.0", name="memory_links_weight_check"),
Index("idx_memory_links_from", "from_unit_id"),
@@ -278,31 +295,22 @@ class MemoryLink(Base):
"from_unit_id",
"weight",
postgresql_where=sql_text("weight >= 0.1"),
postgresql_ops={"weight": "DESC"}
postgresql_ops={"weight": "DESC"},
),
)
class Bank(Base):
"""Memory bank profiles with disposition traits and background."""
__tablename__ = "banks"
bank_id: Mapped[str] = mapped_column(Text, primary_key=True)
disposition: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
server_default=sql_text(
'\'{"skepticism": 3, "literalism": 3, "empathy": 3}\'::jsonb'
)
JSONB, nullable=False, server_default=sql_text('\'{"skepticism": 3, "literalism": 3, "empathy": 3}\'::jsonb')
)
background: Mapped[str] = mapped_column(Text, nullable=False, server_default="")
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
__table_args__ = (
Index("idx_banks_bank_id", "bank_id"),
)
__table_args__ = (Index("idx_banks_bank_id", "bank_id"),)
+75 -23
View File
@@ -1,12 +1,10 @@
import asyncio
import logging
from typing import Optional
from pg0 import Pg0
logger = logging.getLogger(__name__)
DEFAULT_PORT = 5555
DEFAULT_USERNAME = "hindsight"
DEFAULT_PASSWORD = "hindsight"
DEFAULT_DATABASE = "hindsight"
@@ -17,34 +15,38 @@ class EmbeddedPostgres:
def __init__(
self,
port: int = DEFAULT_PORT,
port: int | None = None,
username: str = DEFAULT_USERNAME,
password: str = DEFAULT_PASSWORD,
database: str = DEFAULT_DATABASE,
name: str = "hindsight",
**kwargs,
):
self.port = port
self.port = port # None means pg0 will auto-assign
self.username = username
self.password = password
self.database = database
self.name = name
self._pg0: Optional[Pg0] = None
self._pg0: Pg0 | None = None
def _get_pg0(self) -> Pg0:
if self._pg0 is None:
self._pg0 = Pg0(
name=self.name,
port=self.port,
username=self.username,
password=self.password,
database=self.database,
)
kwargs = {
"name": self.name,
"username": self.username,
"password": self.password,
"database": self.database,
}
# Only set port if explicitly specified
if self.port is not None:
kwargs["port"] = self.port
self._pg0 = Pg0(**kwargs) # type: ignore[invalid-argument-type] - dict kwargs
return self._pg0
async def start(self, max_retries: int = 3, retry_delay: float = 2.0) -> str:
async def start(self, max_retries: int = 5, retry_delay: float = 4.0) -> str:
"""Start the PostgreSQL server with retry logic."""
logger.info(f"Starting embedded PostgreSQL (name: {self.name}, port: {self.port})...")
port_info = f"port={self.port}" if self.port else "port=auto"
logger.info(f"Starting embedded PostgreSQL (name={self.name}, {port_info})...")
pg0 = self._get_pg0()
last_error = None
@@ -53,9 +55,9 @@ class EmbeddedPostgres:
try:
loop = asyncio.get_event_loop()
info = await loop.run_in_executor(None, pg0.start)
logger.info(f"PostgreSQL started on port {self.port}")
# Construct URI manually since pg0-embedded may return None
uri = info.uri if info and info.uri else f"postgresql://{self.username}:{self.password}@localhost:{self.port}/{self.database}"
# Get URI from pg0 (includes auto-assigned port)
uri = info.uri
logger.info(f"PostgreSQL started: {uri}")
return uri
except Exception as e:
last_error = str(e)
@@ -68,8 +70,7 @@ class EmbeddedPostgres:
logger.debug(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error}")
raise RuntimeError(
f"Failed to start embedded PostgreSQL after {max_retries} attempts. "
f"Last error: {last_error}"
f"Failed to start embedded PostgreSQL after {max_retries} attempts. Last error: {last_error}"
)
async def stop(self) -> None:
@@ -91,9 +92,7 @@ class EmbeddedPostgres:
pg0 = self._get_pg0()
loop = asyncio.get_event_loop()
info = await loop.run_in_executor(None, pg0.info)
# Construct URI manually since pg0-embedded may return None
uri = info.uri if info and info.uri else f"postgresql://{self.username}:{self.password}@localhost:{self.port}/{self.database}"
return uri
return info.uri
async def is_running(self) -> bool:
"""Check if the PostgreSQL server is currently running."""
@@ -112,7 +111,7 @@ class EmbeddedPostgres:
return await self.start()
_default_instance: Optional[EmbeddedPostgres] = None
_default_instance: EmbeddedPostgres | None = None
def get_embedded_postgres() -> EmbeddedPostgres:
@@ -133,3 +132,56 @@ async def stop_embedded_postgres() -> None:
global _default_instance
if _default_instance:
await _default_instance.stop()
def parse_pg0_url(db_url: str) -> tuple[bool, str | None, int | None]:
"""
Parse a database URL and check if it's a pg0:// embedded database URL.
Supports:
- "pg0" -> default instance "hindsight"
- "pg0://instance-name" -> named instance
- "pg0://instance-name:port" -> named instance with explicit port
- Any other URL (e.g., postgresql://) -> not a pg0 URL
Args:
db_url: The database URL to parse
Returns:
Tuple of (is_pg0, instance_name, port)
- is_pg0: True if this is a pg0 URL
- instance_name: The instance name (or None if not pg0)
- port: The explicit port (or None for auto-assign)
"""
if db_url == "pg0":
return True, "hindsight", None
if db_url.startswith("pg0://"):
url_part = db_url[6:] # Remove "pg0://"
if ":" in url_part:
instance_name, port_str = url_part.rsplit(":", 1)
return True, instance_name or "hindsight", int(port_str)
else:
return True, url_part or "hindsight", None
return False, None, None
async def resolve_database_url(db_url: str) -> str:
"""
Resolve a database URL, handling pg0:// embedded database URLs.
If the URL is a pg0:// URL, starts the embedded PostgreSQL and returns
the actual postgresql:// connection URL. Otherwise, returns the URL unchanged.
Args:
db_url: Database URL (pg0://, pg0, or postgresql://)
Returns:
The resolved postgresql:// connection URL
"""
is_pg0, instance_name, port = parse_pg0_url(db_url)
if is_pg0:
pg0 = EmbeddedPostgres(name=instance_name, port=port)
return await pg0.ensure_running()
return db_url
+3 -6
View File
@@ -6,6 +6,7 @@ This module provides the ASGI app for uvicorn import string usage:
For CLI usage, use the hindsight-api command instead.
"""
import os
import warnings
@@ -29,15 +30,11 @@ config.configure_logging()
_memory = MemoryEngine()
# Create unified app with both HTTP and optionally MCP
app = create_app(
memory=_memory,
http_api_enabled=True,
mcp_api_enabled=config.mcp_enabled,
mcp_mount_path="/mcp"
)
app = create_app(memory=_memory, http_api_enabled=True, mcp_api_enabled=config.mcp_enabled, mcp_mount_path="/mcp")
if __name__ == "__main__":
# When run directly, delegate to the CLI
from hindsight_api.main import main
main()
+64 -5
View File
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api"
version = "0.1.5"
description = "Temporal + Semantic + Entity Memory System for AI agents using PostgreSQL"
version = "0.2.1"
description = "Hindsight: Agent Memory That Works Like Human Memory"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
@@ -25,11 +25,11 @@ dependencies = [
"greenlet>=3.2.4",
"psycopg2-binary>=2.9.11",
"transformers>=4.30.0,<4.46.0",
"torch>=2.0.0,<2.6.0",
"torch>=2.0.0",
"tiktoken>=0.12.0",
"httpx>=0.27.0",
"fastmcp>=2.3.0",
"pg0-embedded>=0.1.0",
"pg0-embedded>=0.11.0",
"python-dateutil>=2.8.0",
"opentelemetry-api>=1.20.0",
"opentelemetry-sdk>=1.20.0",
@@ -37,6 +37,8 @@ dependencies = [
"opentelemetry-exporter-prometheus>=0.41b0",
"dateparser>=1.2.2",
"google-genai>=1.0.0",
"anthropic>=0.40.0",
"typer>=0.9.0",
]
[project.optional-dependencies]
@@ -50,6 +52,8 @@ test = [
[project.scripts]
hindsight-api = "hindsight_api.main:main"
hindsight-local-mcp = "hindsight_api.mcp_local:main"
hindsight-admin = "hindsight_api.admin.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["hindsight_api"]
@@ -73,7 +77,7 @@ log_cli = true
log_cli_level = "INFO"
log_cli_format = "%(asctime)s - %(levelname)s - %(name)s - %(message)s"
log_cli_date_format = "%Y-%m-%d %H:%M:%S"
addopts = "--timeout 120 -n 8 --durations=10 -v"
addopts = "--timeout 120 -n 8 --dist loadgroup --durations=10 -v"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
log_auto_indent = true
@@ -90,4 +94,59 @@ dev = [
"pytest-xdist>=3.8.0",
"python-dotenv>=1.2.1",
"filelock>=3.0.0",
"ruff>=0.8.0",
"ty>=0.0.1",
]
[tool.ruff]
line-length = 120
target-version = "py311"
exclude = [
"tests/",
"**/tests/",
]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
]
ignore = [
"E501", # line too long (handled by formatter)
"E402", # module import not at top of file
"F401", # unused import (too noisy during development)
"F841", # unused variable (too noisy during development)
"F811", # redefined while unused
"F821", # undefined name (forward references in type hints)
]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.ty]
# Type checking configuration
# ty is an extremely fast Python type checker from Astral (same team as ruff/uv)
[tool.ty.environment]
python-version = "3.11"
[tool.ty.src]
exclude = [
"tests/",
"hindsight_api/alembic/",
]
[tool.ty.rules]
# Disable noisy rules while keeping important ones
invalid-argument-type = "ignore" # False positives with **kwargs patterns
invalid-return-type = "ignore" # Often intentional in async code
invalid-parameter-default = "ignore" # Optional params with None default
possibly-missing-attribute = "ignore" # Common with Optional types
invalid-raise = "ignore" # False positives with exception tracking
call-non-callable = "ignore" # False positives with Optional types
invalid-key = "ignore" # Pydantic ConfigDict not understood
invalid-method-override = "ignore" # Intentional signature differences
unresolved-reference = "ignore" # Forward references not always resolved
+7 -1
View File
@@ -8,7 +8,7 @@ import os
import filelock
from pathlib import Path
from dotenv import load_dotenv
from hindsight_api import MemoryEngine, LLMConfig, LocalSTEmbeddings
from hindsight_api import MemoryEngine, LLMConfig, LocalSTEmbeddings, RequestContext
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
@@ -99,6 +99,12 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
return url
@pytest.fixture(scope="function")
def request_context():
"""Provide a default RequestContext for tests."""
return RequestContext()
@pytest.fixture(scope="session")
def llm_config():
"""
@@ -0,0 +1,292 @@
"""
Tests for admin backup and restore functionality.
These tests use an isolated schema to avoid interfering with other tests.
The backup/restore operations truncate tables, which would cause deadlocks
and race conditions if run against the shared public schema.
"""
import tempfile
import uuid
import zipfile
from pathlib import Path
import asyncpg
import pytest
import pytest_asyncio
from hindsight_api.admin.cli import _backup, _restore, BACKUP_TABLES
from hindsight_api.migrations import run_migrations
# Run these tests sequentially since they do full DB backup/restore
pytestmark = pytest.mark.xdist_group(name="backup_restore")
@pytest_asyncio.fixture(scope="function")
async def backup_test_schema(pg0_db_url, embeddings):
"""Create an isolated schema for backup/restore tests.
Uses a unique schema name per test invocation to avoid conflicts with
parallel test runs or leftover state from interrupted runs.
Returns a tuple of (db_url, schema_name, fq_helper, embeddings).
"""
# Initialize embeddings if not already done
await embeddings.initialize()
# Use unique schema name to avoid conflicts
schema_name = f"backup_test_{uuid.uuid4().hex[:8]}"
def _fq(table: str) -> str:
"""Get fully-qualified table name in test schema."""
return f"{schema_name}.{table}"
conn = await asyncpg.connect(pg0_db_url)
try:
await conn.execute(f"CREATE SCHEMA {schema_name}")
finally:
await conn.close()
# Run migrations on the isolated schema
run_migrations(pg0_db_url, schema=schema_name)
yield pg0_db_url, schema_name, _fq, embeddings
# Cleanup after test
conn = await asyncpg.connect(pg0_db_url)
try:
await conn.execute(f"DROP SCHEMA IF EXISTS {schema_name} CASCADE")
finally:
await conn.close()
@pytest.mark.asyncio
async def test_backup_restore_roundtrip(backup_test_schema):
"""Test that backup and restore preserves all data correctly."""
db_url, schema_name, _fq, embeddings = backup_test_schema
bank_id = f"test-backup-{uuid.uuid4().hex[:8]}"
conn = await asyncpg.connect(db_url)
try:
# Create a bank
await conn.execute(
f"INSERT INTO {_fq('banks')} (bank_id) VALUES ($1) ON CONFLICT DO NOTHING",
bank_id,
)
# Create some test memory units with embeddings
# Convert embedding list to pgvector format string
embedding_list = embeddings.encode(["Test content about Alice"])[0]
embedding_str = "[" + ",".join(str(x) for x in embedding_list) + "]"
for text in [
"Alice is a software engineer who loves Python.",
"Bob works with Alice on the backend team.",
"The team uses PostgreSQL for their database.",
]:
await conn.execute(
f"""INSERT INTO {_fq('memory_units')}
(bank_id, text, fact_type, embedding, event_date)
VALUES ($1, $2, 'world', $3::vector, NOW())""",
bank_id,
text,
embedding_str,
)
# Get counts before backup
counts_before = {}
for table in BACKUP_TABLES:
counts_before[table] = await conn.fetchval(f"SELECT COUNT(*) FROM {_fq(table)}")
# Verify we have data
assert counts_before["banks"] > 0
assert counts_before["memory_units"] > 0
finally:
await conn.close()
# Backup to a temp file
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as f:
backup_path = Path(f.name)
try:
manifest = await _backup(db_url, backup_path, schema=schema_name)
# Verify backup file exists and is valid
assert backup_path.exists()
assert backup_path.stat().st_size > 0
# Verify manifest
assert manifest["version"] == "1"
assert "created_at" in manifest
for table in BACKUP_TABLES:
assert table in manifest["tables"]
assert manifest["tables"][table]["rows"] == counts_before[table]
# Verify zip contents
with zipfile.ZipFile(backup_path, "r") as zf:
assert "manifest.json" in zf.namelist()
for table in BACKUP_TABLES:
assert f"{table}.bin" in zf.namelist()
# Clear all data
conn = await asyncpg.connect(db_url)
try:
for table in reversed(BACKUP_TABLES):
await conn.execute(f"TRUNCATE TABLE {_fq(table)} CASCADE")
# Verify data is gone
for table in BACKUP_TABLES:
count = await conn.fetchval(f"SELECT COUNT(*) FROM {_fq(table)}")
assert count == 0, f"Table {table} should be empty after truncate"
finally:
await conn.close()
# Restore from backup
await _restore(db_url, backup_path, schema=schema_name)
# Verify counts match original
conn = await asyncpg.connect(db_url)
try:
for table in BACKUP_TABLES:
count = await conn.fetchval(f"SELECT COUNT(*) FROM {_fq(table)}")
assert count == counts_before[table], f"Table {table} count mismatch after restore"
# Verify data content is preserved
texts = await conn.fetch(
f"SELECT text FROM {_fq('memory_units')} WHERE bank_id = $1",
bank_id,
)
text_content = " ".join(r["text"] for r in texts)
assert "Alice" in text_content or "software" in text_content
finally:
await conn.close()
finally:
# Cleanup
if backup_path.exists():
backup_path.unlink()
@pytest.mark.asyncio
async def test_backup_restore_preserves_all_column_types(backup_test_schema):
"""Test that all column types are preserved: vectors, UUIDs, timestamps, JSONB."""
db_url, schema_name, _fq, embeddings = backup_test_schema
bank_id = f"test-types-{uuid.uuid4().hex[:8]}"
conn = await asyncpg.connect(db_url)
try:
# Create a bank
await conn.execute(
f"INSERT INTO {_fq('banks')} (bank_id) VALUES ($1) ON CONFLICT DO NOTHING",
bank_id,
)
# Create a memory unit with all column types
# Convert embedding list to pgvector format string
embedding_list = embeddings.encode(["John Smith engineer"])[0]
embedding_str = "[" + ",".join(str(x) for x in embedding_list) + "]"
await conn.execute(
f"""INSERT INTO {_fq('memory_units')}
(bank_id, text, fact_type, embedding, event_date, metadata)
VALUES ($1, $2, 'world', $3::vector, NOW(), $4)""",
bank_id,
"John Smith is a senior engineer at Acme Corp since 2020.",
embedding_str,
'{"key": "value"}',
)
# Create an entity
await conn.execute(
f"""INSERT INTO {_fq('entities')}
(bank_id, canonical_name, metadata)
VALUES ($1, $2, $3)""",
bank_id,
"John Smith",
'{"role": "engineer"}',
)
# Get original data
original_unit = await conn.fetchrow(
f"""SELECT id, embedding, event_date, created_at, metadata, text
FROM {_fq('memory_units')} WHERE bank_id = $1 LIMIT 1""",
bank_id,
)
original_entity = await conn.fetchrow(
f"""SELECT id, first_seen, last_seen, metadata, canonical_name
FROM {_fq('entities')} WHERE bank_id = $1 LIMIT 1""",
bank_id,
)
original_bank = await conn.fetchrow(
f"SELECT bank_id, created_at, updated_at FROM {_fq('banks')} WHERE bank_id = $1",
bank_id,
)
finally:
await conn.close()
assert original_unit is not None, "Should have created memory units"
assert original_unit["embedding"] is not None, "Should have embedding"
assert original_unit["id"] is not None, "Should have UUID"
assert original_entity is not None, "Should have created entities"
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as f:
backup_path = Path(f.name)
try:
await _backup(db_url, backup_path, schema=schema_name)
# Clear all data
conn = await asyncpg.connect(db_url)
try:
for table in reversed(BACKUP_TABLES):
await conn.execute(f"TRUNCATE TABLE {_fq(table)} CASCADE")
finally:
await conn.close()
await _restore(db_url, backup_path, schema=schema_name)
# Verify all column types are preserved exactly
conn = await asyncpg.connect(db_url)
try:
restored_unit = await conn.fetchrow(
f"""SELECT id, embedding, event_date, created_at, metadata, text
FROM {_fq('memory_units')} WHERE bank_id = $1 LIMIT 1""",
bank_id,
)
restored_entity = await conn.fetchrow(
f"""SELECT id, first_seen, last_seen, metadata, canonical_name
FROM {_fq('entities')} WHERE bank_id = $1 LIMIT 1""",
bank_id,
)
restored_bank = await conn.fetchrow(
f"SELECT bank_id, created_at, updated_at FROM {_fq('banks')} WHERE bank_id = $1",
bank_id,
)
finally:
await conn.close()
# Verify memory_units
assert restored_unit is not None, "Should have restored memory unit"
assert restored_unit["id"] == original_unit["id"], "UUID should match exactly"
assert restored_unit["text"] == original_unit["text"], "Text should match"
assert list(restored_unit["embedding"]) == list(original_unit["embedding"]), "Vector embedding should match exactly"
assert restored_unit["event_date"] == original_unit["event_date"], "Timestamp should match exactly"
assert restored_unit["created_at"] == original_unit["created_at"], "Created timestamp should match"
assert restored_unit["metadata"] == original_unit["metadata"], "JSONB metadata should match"
# Verify entities
assert restored_entity is not None, "Should have restored entity"
assert restored_entity["id"] == original_entity["id"], "Entity UUID should match"
assert restored_entity["canonical_name"] == original_entity["canonical_name"], "Entity name should match"
assert restored_entity["first_seen"] == original_entity["first_seen"], "Entity first_seen should match"
assert restored_entity["last_seen"] == original_entity["last_seen"], "Entity last_seen should match"
assert restored_entity["metadata"] == original_entity["metadata"], "Entity metadata should match"
# Verify banks
assert restored_bank is not None, "Should have restored bank"
assert restored_bank["bank_id"] == original_bank["bank_id"], "Bank ID should match"
assert restored_bank["created_at"] == original_bank["created_at"], "Bank created_at should match"
finally:
if backup_path.exists():
backup_path.unlink()
+39 -32
View File
@@ -3,7 +3,7 @@ Tests for agent management API (profile, disposition, background).
"""
import pytest
import uuid
from hindsight_api import MemoryEngine
from hindsight_api import MemoryEngine, RequestContext
from hindsight_api.api import CreateBankRequest, DispositionTraits
from hindsight_api.engine.memory_engine import Budget
@@ -17,11 +17,11 @@ class TestAgentProfile:
"""Tests for agent profile management."""
@pytest.mark.asyncio
async def test_get_agent_profile_creates_default(self, memory: MemoryEngine):
async def test_get_agent_profile_creates_default(self, memory: MemoryEngine, request_context):
"""Test that getting a profile for a new agent creates default disposition."""
bank_id = unique_agent_id("test_profile_default")
profile = await memory.get_bank_profile(bank_id)
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
assert profile is not None
assert "disposition" in profile
@@ -35,11 +35,11 @@ class TestAgentProfile:
assert profile["background"] == ""
@pytest.mark.asyncio
async def test_update_agent_disposition(self, memory: MemoryEngine):
async def test_update_agent_disposition(self, memory: MemoryEngine, request_context):
"""Test updating agent disposition traits."""
bank_id = unique_agent_id("test_profile_update")
profile = await memory.get_bank_profile(bank_id)
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
assert profile["disposition"].skepticism == 3
new_disposition = {
@@ -47,26 +47,26 @@ class TestAgentProfile:
"literalism": 4,
"empathy": 2,
}
await memory.update_bank_disposition(bank_id, new_disposition)
await memory.update_bank_disposition(bank_id, new_disposition, request_context=request_context)
updated_profile = await memory.get_bank_profile(bank_id)
updated_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
disposition = updated_profile["disposition"]
assert disposition.skepticism == new_disposition["skepticism"]
assert disposition.literalism == new_disposition["literalism"]
assert disposition.empathy == new_disposition["empathy"]
@pytest.mark.asyncio
async def test_list_agents(self, memory: MemoryEngine):
async def test_list_agents(self, memory: MemoryEngine, request_context):
"""Test listing all agents."""
agent_id_1 = unique_agent_id("test_list")
agent_id_2 = unique_agent_id("test_list")
agent_id_3 = unique_agent_id("test_list")
await memory.get_bank_profile(agent_id_1)
await memory.get_bank_profile(agent_id_2)
await memory.get_bank_profile(agent_id_3)
await memory.get_bank_profile(agent_id_1, request_context=request_context)
await memory.get_bank_profile(agent_id_2, request_context=request_context)
await memory.get_bank_profile(agent_id_3, request_context=request_context)
agents = await memory.list_banks()
agents = await memory.list_banks(request_context=request_context)
agent_ids = [a["bank_id"] for a in agents]
assert agent_id_1 in agent_ids
@@ -85,46 +85,50 @@ class TestAgentBackground:
"""Tests for agent background management."""
@pytest.mark.asyncio
async def test_merge_agent_background(self, memory: MemoryEngine):
async def test_merge_agent_background(self, memory: MemoryEngine, request_context):
"""Test merging agent background information."""
bank_id = unique_agent_id("test_profile_merge")
profile = await memory.get_bank_profile(bank_id)
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
assert profile["background"] == ""
result1 = await memory.merge_bank_background(
bank_id,
"I was born in Texas",
update_disposition=False
update_disposition=False,
request_context=request_context,
)
assert "Texas" in result1["background"]
result2 = await memory.merge_bank_background(
bank_id,
"I have 10 years of startup experience",
update_disposition=False
update_disposition=False,
request_context=request_context,
)
assert "Texas" in result2["background"] or "startup" in result2["background"]
final_profile = await memory.get_bank_profile(bank_id)
final_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
assert final_profile["background"] != ""
@pytest.mark.asyncio
async def test_merge_background_handles_conflicts(self, memory: MemoryEngine):
async def test_merge_background_handles_conflicts(self, memory: MemoryEngine, request_context):
"""Test that merging background handles conflicts (new overwrites old)."""
bank_id = unique_agent_id("test_profile_conflict")
result1 = await memory.merge_bank_background(
bank_id,
"I was born in Colorado",
update_disposition=False
update_disposition=False,
request_context=request_context,
)
assert "Colorado" in result1["background"]
result2 = await memory.merge_bank_background(
bank_id,
"You were born in Texas",
update_disposition=False
update_disposition=False,
request_context=request_context,
)
assert "Texas" in result2["background"]
@@ -133,7 +137,7 @@ class TestAgentEndpoint:
"""Tests for agent PUT endpoint logic."""
@pytest.mark.asyncio
async def test_put_agent_create(self, memory: MemoryEngine):
async def test_put_agent_create(self, memory: MemoryEngine, request_context):
"""Test creating an agent via PUT endpoint."""
bank_id = unique_agent_id("test_put_create")
@@ -146,12 +150,13 @@ class TestAgentEndpoint:
background="I am a creative software engineer"
)
profile = await memory.get_bank_profile(bank_id)
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
if request.disposition is not None:
await memory.update_bank_disposition(
bank_id,
request.disposition.model_dump()
request.disposition.model_dump(),
request_context=request_context,
)
if request.background is not None:
@@ -168,14 +173,14 @@ class TestAgentEndpoint:
request.background
)
final_profile = await memory.get_bank_profile(bank_id)
final_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
assert final_profile["disposition"].skepticism == 4
assert final_profile["disposition"].literalism == 5
assert final_profile["background"] == "I am a creative software engineer"
@pytest.mark.asyncio
async def test_put_agent_partial_update(self, memory: MemoryEngine):
async def test_put_agent_partial_update(self, memory: MemoryEngine, request_context):
"""Test updating only background."""
bank_id = unique_agent_id("test_put_partial")
@@ -183,7 +188,7 @@ class TestAgentEndpoint:
background="I am a data scientist"
)
profile = await memory.get_bank_profile(bank_id)
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
if request.background is not None:
pool = await memory._get_pool()
@@ -199,7 +204,7 @@ class TestAgentEndpoint:
request.background
)
final_profile = await memory.get_bank_profile(bank_id)
final_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
assert final_profile["disposition"].skepticism == 3 # Default
assert final_profile["background"] == "I am a data scientist"
@@ -209,7 +214,7 @@ class TestAgentDispositionIntegration:
"""Tests for disposition integration with other features."""
@pytest.mark.asyncio
async def test_think_uses_disposition(self, memory: MemoryEngine):
async def test_think_uses_disposition(self, memory: MemoryEngine, request_context):
"""Test that THINK operation uses agent disposition."""
bank_id = unique_agent_id("test_think")
@@ -218,12 +223,13 @@ class TestAgentDispositionIntegration:
"literalism": 4, # High literalism
"empathy": 2, # Low empathy
}
await memory.update_bank_disposition(bank_id, disposition)
await memory.update_bank_disposition(bank_id, disposition, request_context=request_context)
await memory.merge_bank_background(
bank_id,
"I am a creative artist who values innovation over tradition",
update_disposition=False
update_disposition=False,
request_context=request_context,
)
await memory.retain_batch_async(
@@ -232,13 +238,14 @@ class TestAgentDispositionIntegration:
{"content": "Traditional painting techniques have been used for centuries"},
{"content": "Modern digital art is changing the art world"}
],
document_id="art_facts"
request_context=request_context,
)
result = await memory.reflect_async(
bank_id=bank_id,
query="What do you think about traditional vs modern art?",
budget=Budget.LOW
budget=Budget.LOW,
request_context=request_context,
)
assert result.text is not None
+6 -4
View File
@@ -6,7 +6,7 @@ import os
@pytest.mark.asyncio
async def test_large_batch_auto_chunks(memory):
async def test_large_batch_auto_chunks(memory, request_context):
bank_id = "test_chunking_agent"
# Create a large batch that should trigger chunking
# Each item is ~2000 chars, so 30 items = 60k chars (exceeds 50k threshold)
@@ -24,7 +24,8 @@ async def test_large_batch_auto_chunks(memory):
# Ingest the large batch (should auto-chunk)
result = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents
contents=contents,
request_context=request_context,
)
# Verify we got results back
@@ -33,7 +34,7 @@ async def test_large_batch_auto_chunks(memory):
@pytest.mark.asyncio
async def test_small_batch_no_chunking(memory):
async def test_small_batch_no_chunking(memory, request_context):
bank_id = "test_no_chunking_agent"
# Create a small batch that should NOT trigger chunking
@@ -50,7 +51,8 @@ async def test_small_batch_no_chunking(memory):
# Ingest the small batch (should NOT auto-chunk)
result = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents
contents=contents,
request_context=request_context,
)
# Verify we got results back
@@ -0,0 +1,222 @@
"""
Test suite for causal relationship extraction.
Tests that the fact extraction system correctly identifies and validates
causal relationships between facts, with valid indices.
"""
from datetime import datetime
import pytest
from hindsight_api import LLMConfig
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
class TestCausalRelationships:
"""Tests for causal relationship extraction and validation."""
@pytest.mark.asyncio
async def test_causal_chain_extraction(self):
"""
Test that a clear causal chain is extracted with valid relationships.
Story: Lost job -> couldn't pay rent -> had to move -> found new apartment
This is a 4-fact causal chain where each fact causes the next.
The extracted causal relations should have valid indices (0-3).
"""
text = """
I lost my job at the tech company in January because of layoffs.
Because I lost my job, I couldn't pay my rent anymore.
Since I couldn't afford rent, I had to move out of my apartment.
After searching for weeks, I finally found a cheaper apartment in Brooklyn.
"""
context = "Personal story about housing change"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 3, 15),
context=context,
llm_config=llm_config,
agent_name="TestUser"
)
assert len(facts) >= 3, f"Should extract at least 3 facts from the causal chain. Got {len(facts)}"
# Collect all causal relations from all facts
all_causal_relations = []
for i, fact in enumerate(facts):
if fact.causal_relations:
for rel in fact.causal_relations:
all_causal_relations.append({
"from_fact_index": i,
"to_fact_index": rel.target_fact_index,
"relation_type": rel.relation_type,
"strength": rel.strength,
"from_fact_text": fact.fact[:50],
})
# Verify that ALL causal relation indices are valid
num_facts = len(facts)
invalid_relations = []
for rel in all_causal_relations:
if rel["to_fact_index"] < 0 or rel["to_fact_index"] >= num_facts:
invalid_relations.append(rel)
assert len(invalid_relations) == 0, (
f"Found {len(invalid_relations)} causal relations with invalid indices! "
f"Valid range is 0-{num_facts - 1}. "
f"Invalid relations: {invalid_relations}"
)
# Should have at least some causal relations extracted
assert len(all_causal_relations) >= 2, (
f"Should extract at least 2 causal relationships from this clear chain. "
f"Got {len(all_causal_relations)}: {all_causal_relations}"
)
# Verify relation types are valid
valid_types = {"causes", "caused_by", "enables", "prevents"}
for rel in all_causal_relations:
assert rel["relation_type"] in valid_types, (
f"Invalid relation_type '{rel['relation_type']}'. Must be one of {valid_types}"
)
@pytest.mark.asyncio
async def test_complex_causal_web(self):
"""
Test a more complex scenario with multiple interconnected causes.
This tests the LLM's ability to identify multiple causal links and
ensure all referenced indices exist.
"""
text = """
The heavy rain caused flooding in the basement.
The flooding damaged the electrical system.
Because of the electrical damage, we had to call an electrician.
The electrician found that the wiring was old and needed replacement.
We decided to renovate the entire basement while fixing the wiring.
The renovation took three months and cost $15,000.
"""
context = "Home repair story"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 6, 1),
context=context,
llm_config=llm_config,
agent_name="TestUser"
)
assert len(facts) >= 4, f"Should extract at least 4 facts. Got {len(facts)}"
# Validate all causal relation indices
num_facts = len(facts)
for i, fact in enumerate(facts):
if fact.causal_relations:
for rel in fact.causal_relations:
assert 0 <= rel.target_fact_index < num_facts, (
f"Fact {i} has causal relation to invalid index {rel.target_fact_index}. "
f"Valid range is 0-{num_facts - 1}. "
f"Fact text: {fact.fact[:80]}..."
)
@pytest.mark.asyncio
async def test_no_self_referencing_causal_relations(self):
"""
Test that facts don't have causal relations pointing to themselves.
"""
text = """
I started learning Python because I wanted to automate my work tasks.
Learning Python led me to discover machine learning.
Machine learning fascinated me so much that I changed my career to data science.
"""
context = "Career change story"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 1, 1),
context=context,
llm_config=llm_config,
agent_name="TestUser"
)
# Check no fact references itself
for i, fact in enumerate(facts):
if fact.causal_relations:
for rel in fact.causal_relations:
assert rel.target_fact_index != i, (
f"Fact {i} has a self-referencing causal relation! "
f"Fact text: {fact.fact}"
)
@pytest.mark.asyncio
async def test_bidirectional_causal_relationships(self):
"""
Test that bidirectional causal relationships (causes and caused_by)
are handled correctly.
"""
text = """
My promotion at work caused me to move to New York.
Moving to New York was caused by my promotion at work.
The new role enabled me to lead a team of engineers.
"""
context = "Work promotion story"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 2, 15),
context=context,
llm_config=llm_config,
agent_name="TestUser"
)
num_facts = len(facts)
# Validate all indices
for i, fact in enumerate(facts):
if fact.causal_relations:
for rel in fact.causal_relations:
assert 0 <= rel.target_fact_index < num_facts, (
f"Invalid target_fact_index {rel.target_fact_index} in fact {i}. "
f"Valid range: 0-{num_facts - 1}"
)
@pytest.mark.asyncio
async def test_causal_relation_strength_values(self):
"""
Test that causal relation strength values are within valid range [0.0, 1.0].
"""
text = """
The stock market crash directly caused the company to lay off employees.
The layoffs indirectly led to reduced consumer spending in the area.
Reduced spending somewhat affected local businesses.
"""
context = "Economic impact story"
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 4, 1),
context=context,
llm_config=llm_config,
agent_name="TestUser"
)
for i, fact in enumerate(facts):
if fact.causal_relations:
for rel in fact.causal_relations:
assert 0.0 <= rel.strength <= 1.0, (
f"Causal relation strength {rel.strength} is outside valid range [0.0, 1.0]. "
f"Fact {i}: {fact.fact[:50]}..."
)
+17 -6
View File
@@ -10,6 +10,7 @@ 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
class TestRRFNormalization:
@@ -125,7 +126,7 @@ class TestCombinedScoringFormula:
@pytest.mark.asyncio
async def test_trace_has_normalized_rrf(memory):
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()}"
@@ -135,21 +136,25 @@ async def test_trace_has_normalized_rrf(memory):
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,
)
# Search with tracing
@@ -160,6 +165,7 @@ async def test_trace_has_normalized_rrf(memory):
budget=Budget.LOW,
max_tokens=1024,
enable_trace=True,
request_context=request_context,
)
assert result.trace is not None, "Trace should be present"
@@ -210,11 +216,11 @@ async def test_trace_has_normalized_rrf(memory):
print(f" - First result score components: {sc}")
finally:
await memory.delete_bank(bank_id)
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_rrf_normalized_not_raw_in_trace(memory):
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()}"
@@ -225,6 +231,7 @@ async def test_rrf_normalized_not_raw_in_trace(memory):
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(
@@ -234,6 +241,7 @@ async def test_rrf_normalized_not_raw_in_trace(memory):
budget=Budget.LOW,
max_tokens=512,
enable_trace=True,
request_context=request_context,
)
trace = result.trace
@@ -268,11 +276,11 @@ async def test_rrf_normalized_not_raw_in_trace(memory):
print("\n✓ RRF raw vs normalized test passed!")
finally:
await memory.delete_bank(bank_id)
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_combined_score_matches_components(memory):
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()}"
@@ -281,11 +289,13 @@ async def test_combined_score_matches_components(memory):
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(
@@ -295,6 +305,7 @@ async def test_combined_score_matches_components(memory):
budget=Budget.LOW,
max_tokens=512,
enable_trace=True,
request_context=request_context,
)
trace = result.trace
@@ -320,4 +331,4 @@ async def test_combined_score_matches_components(memory):
print("\n✓ Combined score verification test passed!")
finally:
await memory.delete_bank(bank_id)
await memory.delete_bank(bank_id, request_context=request_context)

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