Compare commits

..
Author SHA1 Message Date
Nicolò Boschi 087fb649f5 chore: drop unused access_count column 2026-01-20 10:23:35 +01:00
948 changed files with 30979 additions and 175980 deletions
+2 -37
View File
@@ -2,10 +2,10 @@
# Copy this file to .env and fill in your values
# LLM Configuration (Required)
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai
# 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=gpt-4o-mini
HINDSIGHT_API_LLM_MODEL=o3-mini
HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# Example: Anthropic Claude configuration
@@ -13,13 +13,6 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
# HINDSIGHT_API_LLM_API_KEY=your-anthropic-api-key
# HINDSIGHT_API_LLM_MODEL=claude-sonnet-4-20250514
# Example: Google Vertex AI configuration
# HINDSIGHT_API_LLM_PROVIDER=vertexai
# HINDSIGHT_API_LLM_MODEL=google/gemini-2.0-flash-001
# HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=your-gcp-project-id
# HINDSIGHT_API_LLM_VERTEXAI_REGION=us-central1
# HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY=/path/to/service-account-key.json # Optional, uses ADC if not set
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
# HINDSIGHT_API_LLM_API_KEY=lmstudio
@@ -31,21 +24,8 @@ HINDSIGHT_API_HOST=0.0.0.0
HINDSIGHT_API_PORT=8888
HINDSIGHT_API_LOG_LEVEL=info
# Base Path / Reverse Proxy Support (Optional)
# Set these when deploying behind a reverse proxy with path-based routing
# Example: To deploy at example.com/hindsight/, set both to "/hindsight"
# HINDSIGHT_API_BASE_PATH=/hindsight
# NEXT_PUBLIC_BASE_PATH=/hindsight
# Database (Optional - uses embedded pg0 by default)
# HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/db
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
# Vector Extension (Optional - uses pgvector by default)
# Options: "pgvector" (default), "vchord", "pgvectorscale" (DiskANN)
# HINDSIGHT_API_VECTOR_EXTENSION=pgvector
# For Azure PostgreSQL with DiskANN:
# HINDSIGHT_API_VECTOR_EXTENSION=pgvectorscale # Auto-detects pg_diskann on Azure
# Embeddings Configuration (Optional - uses local by default)
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
@@ -62,18 +42,3 @@ HINDSIGHT_API_LOG_LEVEL=info
# HINDSIGHT_API_RERANKER_LOCAL_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
# For TEI provider:
# HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
# Observability & Tracing (Optional - disabled by default)
# Enable OpenTelemetry tracing for LLM calls (GenAI semantic conventions)
# HINDSIGHT_API_OTEL_TRACES_ENABLED=true
#
# Local development with Grafana LGTM stack (recommended - see scripts/dev/grafana/README.md)
# HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
#
# Cloud backends (Grafana Cloud, Langfuse, DataDog, etc.)
# HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=https://your-backend-url
# HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer your-token"
#
# Custom service name and environment (optional, defaults: hindsight-api, development)
# HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-production
# HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production
-3
View File
@@ -31,9 +31,6 @@ jobs:
- run: npm ci --workspace=hindsight-docs
- run: uv run generate-llms-full
- run: npm run build --workspace=hindsight-docs
env:
UMAMI_URL: https://analytics.hindsight.vectorize.io
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID }}
- uses: actions/upload-pages-artifact@v3
with:
path: hindsight-docs/build
+3 -223
View File
@@ -46,14 +46,6 @@ jobs:
working-directory: ./hindsight-embed
run: uv build --out-dir dist
- name: Build hindsight-crewai
working-directory: ./hindsight-integrations/crewai
run: uv build --out-dir dist
- name: Build hindsight-pydantic-ai
working-directory: ./hindsight-integrations/pydantic-ai
run: uv build --out-dir dist
# Publish in order (client and api first, then hindsight-all which depends on them)
- name: Publish hindsight-client to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
@@ -85,18 +77,6 @@ jobs:
packages-dir: ./hindsight-embed/dist
skip-existing: true
- name: Publish hindsight-crewai to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/crewai/dist
skip-existing: true
- name: Publish hindsight-pydantic-ai to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/pydantic-ai/dist
skip-existing: true
# Upload artifacts for GitHub release
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -108,8 +88,6 @@ jobs:
hindsight/dist/*
hindsight-integrations/litellm/dist/*
hindsight-embed/dist/*
hindsight-integrations/crewai/dist/*
hindsight-integrations/pydantic-ai/dist/*
retention-days: 1
release-typescript-client:
@@ -161,153 +139,6 @@ jobs:
path: hindsight-clients/typescript/*.tgz
retention-days: 1
release-openclaw-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/openclaw
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/openclaw
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/openclaw
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-integrations/openclaw
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: openclaw-integration
path: hindsight-integrations/openclaw/*.tgz
retention-days: 1
release-ai-sdk-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/ai-sdk
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/ai-sdk
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/ai-sdk
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-integrations/ai-sdk
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: ai-sdk-integration
path: hindsight-integrations/ai-sdk/*.tgz
retention-days: 1
release-chat-integration:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
working-directory: ./hindsight-integrations/chat
run: npm ci
- name: Build
working-directory: ./hindsight-integrations/chat
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-integrations/chat
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-integrations/chat
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: chat-integration
path: hindsight-integrations/chat/*.tgz
retention-days: 1
release-control-plane:
runs-on: ubuntu-latest
environment: npm
@@ -339,14 +170,11 @@ jobs:
- name: Build
run: npm run build --workspace=hindsight-control-plane
- name: Verify standalone build
run: test -f hindsight-control-plane/standalone/server.js || (echo 'standalone/server.js missing - build failed' && exit 1)
- name: Publish to npm
working-directory: ./hindsight-control-plane
run: |
set +e
OUTPUT=$(npm publish --access public --ignore-scripts 2>&1)
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
@@ -414,7 +242,6 @@ jobs:
retention-days: 1
release-docker-images:
name: Release Docker (${{ matrix.image_name }}${{ matrix.tag_suffix }})
runs-on: ubuntu-latest
permissions:
contents: read
@@ -424,28 +251,10 @@ jobs:
include:
- target: api-only
image_name: hindsight-api
tag_suffix: ""
build_args: ""
- target: api-only
image_name: hindsight-api
tag_suffix: "-slim"
build_args: |
INCLUDE_LOCAL_MODELS=false
PRELOAD_ML_MODELS=false
- target: cp-only
image_name: hindsight-control-plane
tag_suffix: ""
build_args: ""
- target: standalone
image_name: hindsight
tag_suffix: ""
build_args: ""
- target: standalone
image_name: hindsight
tag_suffix: "-slim"
build_args: |
INCLUDE_LOCAL_MODELS=false
PRELOAD_ML_MODELS=false
steps:
- uses: actions/checkout@v4
@@ -483,9 +292,6 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}
flavor: |
latest=auto
suffix=${{ matrix.tag_suffix }}
tags: |
type=semver,pattern={{version}},value=${{ steps.get_version.outputs.VERSION }}
type=semver,pattern={{major}}.{{minor}},value=${{ steps.get_version.outputs.VERSION }}
@@ -511,7 +317,7 @@ jobs:
# - name: Smoke test - verify container starts
# env:
# GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
# run: ./docker/test-image.sh "${{ matrix.image_name }}:test" "${{ matrix.target }}"
# 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
@@ -520,7 +326,6 @@ jobs:
context: .
file: docker/standalone/Dockerfile
target: ${{ matrix.target }}
build-args: ${{ matrix.build_args }}
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
@@ -561,7 +366,7 @@ jobs:
create-github-release:
runs-on: ubuntu-latest
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-ai-sdk-integration, release-chat-integration, release-control-plane, 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
@@ -584,24 +389,6 @@ jobs:
name: typescript-client
path: ./artifacts/typescript-client
- name: Download OpenClaw Integration
uses: actions/download-artifact@v4
with:
name: openclaw-integration
path: ./artifacts/openclaw-integration
- name: Download AI SDK Integration
uses: actions/download-artifact@v4
with:
name: ai-sdk-integration
path: ./artifacts/ai-sdk-integration
- name: Download Chat Integration
uses: actions/download-artifact@v4
with:
name: chat-integration
path: ./artifacts/chat-integration
- name: Download Control Plane
uses: actions/download-artifact@v4
with:
@@ -640,16 +427,9 @@ jobs:
cp artifacts/python-packages/hindsight-api/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-integrations/litellm/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-integrations/pydantic-ai/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
# TypeScript client
cp artifacts/typescript-client/*.tgz release-assets/ || true
# OpenClaw Integration
cp artifacts/openclaw-integration/*.tgz release-assets/ || true
# AI SDK Integration
cp artifacts/ai-sdk-integration/*.tgz release-assets/ || true
# Chat Integration
cp artifacts/chat-integration/*.tgz release-assets/ || true
# Control Plane
cp artifacts/control-plane/*.tgz release-assets/ || true
# Rust CLI binaries
+110 -776
View File
File diff suppressed because it is too large Load Diff
+1 -7
View File
@@ -45,15 +45,9 @@ hindsight-docs/static/llms-full.txt
hindsight-dev/benchmarks/locomo/results/
hindsight-dev/benchmarks/longmemeval/results/
hindsight-dev/benchmarks/consolidation/results/
hindsight-dev/benchmarks/perf/results/
benchmarks/results/
hindsight-cli/target
hindsight-clients/rust/target
.claude
whats-next.md
TASK.md
# Changelog is now tracked in hindsight-docs/src/pages/changelog.md
# CHANGELOG.md
blog-post*
CHANGELOG.md
+10 -53
View File
@@ -7,7 +7,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Hindsight is an agent memory system that provides long-term memory for AI agents using biomimetic data structures. Memories are organized as:
- **World facts**: General knowledge ("The sky is blue")
- **Experience facts**: Personal experiences ("I visited Paris in 2023")
- **Mental models**: Consolidated knowledge synthesized from facts ("User prefers functional programming patterns")
- **Opinion facts**: Beliefs with confidence scores ("Paris is beautiful" - 0.9 confidence)
- **Observations**: Complex mental models derived from reflection
## Development Commands
@@ -45,7 +46,6 @@ cd hindsight-control-plane && npm run dev
./scripts/dev/start-docs.sh
```
### Generating Clients/OpenAPI
```bash
# Regenerate OpenAPI spec after API changes (REQUIRED after changing endpoints)
@@ -57,15 +57,8 @@ cd hindsight-control-plane && npm run dev
### Benchmarks
```bash
# Accuracy benchmarks
./scripts/benchmarks/run-longmemeval.sh
./scripts/benchmarks/run-locomo.sh
# Performance benchmarks
./scripts/benchmarks/run-consolidation.sh
./scripts/benchmarks/run-retain-perf.sh --document <path> # Requires API server running
# Results viewer
./scripts/benchmarks/start-visualizer.sh # View results at localhost:8001
```
@@ -108,7 +101,7 @@ cd hindsight-control-plane && npm run dev
Main operations:
- **Retain**: Store memories, extracts facts/entities/relationships
- **Recall**: Retrieve memories via 4 parallel strategies (semantic, BM25, graph, temporal) + reranking
- **Reflect**: Disposition-aware reasoning using memories and mental models.
- **Reflect**: Deep analysis forming new opinions/observations (disposition-aware)
### Database
PostgreSQL with pgvector. Schema managed via Alembic migrations in `hindsight-api/hindsight_api/alembic/`. Migrations run automatically on API startup.
@@ -245,61 +238,26 @@ def process(data: UserData) -> str:
### Adding New API Configuration Flags
Configuration follows a hierarchical system: **Global (env vars) → Tenant (via extension) → Bank (database)**.
Fields must be categorized as either **hierarchical** (can be overridden per-tenant/bank) or **static** (server-level only).
#### Adding a New Configuration Field
When adding a new environment variable configuration:
1. **config.py** (`hindsight-api/hindsight_api/config.py`):
- Add `ENV_*` constant for the environment variable name (e.g., `ENV_MY_SETTING = "HINDSIGHT_API_MY_SETTING"`)
- Add `ENV_*` constant for the environment variable name
- Add `DEFAULT_*` constant for the default value
- Add field to `HindsightConfig` dataclass with type annotation
- **Mark as hierarchical or static** by adding to `_HIERARCHICAL_FIELDS` set (hierarchical) or leaving it out (static)
- Add field to `HindsightConfig` dataclass
- Add initialization in `from_env()` method
```python
# Hierarchical field (can be overridden per-bank)
_HIERARCHICAL_FIELDS = {
...,
"my_setting", # Add here for hierarchical
}
# Static field - just don't add to _HIERARCHICAL_FIELDS
```
2. **main.py** (`hindsight-api/hindsight_api/main.py`):
- Add field to the manual `HindsightConfig()` constructor call (search for "CLI override")
3. **Use hierarchical config in MemoryEngine**:
```python
# Config is resolved automatically per bank via ConfigResolver
config_dict = await self._config_resolver.get_bank_config(bank_id, context)
value = config_dict["my_setting"]
```
4. **Use static config** (non-hierarchical):
3. **Use the config** in code:
```python
from ...config import get_config
config = get_config()
value = config.my_static_field
value = config.your_new_field
```
5. **Documentation** (`hindsight-docs/docs/developer/configuration.md`):
4. **Documentation** (`hindsight-docs/docs/developer/configuration.md`):
- Add to appropriate section table with Variable, Description, Default
- Mark if it's hierarchical (can be overridden per-bank)
#### Hierarchical vs Static Guidelines
**Hierarchical** (per-bank overridable):
- LLM settings (provider, model, API key, base URL)
- Operation-specific settings (retain mode, chunk size, etc.)
- Feature flags that vary by customer/bank
**Static** (server-level only):
- Infrastructure settings (database URL, port, host)
- Global limits (max concurrent operations)
- System-wide feature flags
## Environment Setup
@@ -317,10 +275,9 @@ 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., gpt-4o-mini, claude-sonnet-4-20250514)
- `HINDSIGHT_API_LLM_MODEL`: Model name (e.g., o3-mini, claude-sonnet-4-20250514)
Optional (uses local models by default):
- `HINDSIGHT_API_EMBEDDINGS_PROVIDER`: local (default) or tei
- `HINDSIGHT_API_RERANKER_PROVIDER`: local (default) or tei
- `HINDSIGHT_API_DATABASE_URL`: External PostgreSQL (uses embedded pg0 by default)
- `HINDSIGHT_API_ENABLE_BANK_CONFIG_API`: Enable per-bank config API (default: true)
-28
View File
@@ -93,34 +93,6 @@ uv run ty check hindsight_api # Type check
3. Run tests to ensure nothing breaks
4. Submit a PR with a clear description of changes
## Release Process
The project uses `scripts/release.sh` for creating releases. This script automates the entire release workflow:
1. Bumps version in all components (API, clients, CLI, control plane, Helm)
2. **Regenerates OpenAPI spec and client SDKs** (Python, TypeScript, Rust)
3. Updates documentation versioning
4. Creates a commit and git tag
5. Pushes to GitHub (triggers CI/CD to publish packages)
### Usage
```bash
./scripts/release.sh <version>
```
**Example:**
```bash
./scripts/release.sh 0.5.0
```
### Important for Developers
- During development, version bumps in `__init__.py` do NOT require client regeneration
- Clients are only regenerated during releases
- Do not manually run `./scripts/generate-clients.sh` unless testing generation changes
- Client version comments will reflect the API version from the latest release
## Reporting Issues
Open an issue on GitHub with:
+58 -105
View File
@@ -1,8 +1,8 @@
<div align="center">
![Hindsight Banner](./hindsight-docs/static/img/hindsight-github-banner.png)
![Hindsight Banner](./hindsight-docs/static/img/banner.svg)
[Documentation](https://hindsight.vectorize.io) • [Paper](https://arxiv.org/abs/2512.12818) • [Cookbook](https://hindsight.vectorize.io/cookbook) • [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup)
[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)
[![Slack Community](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/hindsight-space/shared_invite/zt-3nhbm4w29-LeSJ5Ixi6j8PdiYOCPlOgg)
@@ -17,76 +17,76 @@
## What is Hindsight?
Hindsight™ is an agent memory system built to create smarter agents that learn over time. Most agent memory systems focus on recalling conversation history. Hindsight is focused on making agents that learn, not just remember.
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.
<video src="https://github.com/user-attachments/assets/923b798d-3581-4897-bb62-9cfa5a931682" controls></video>
- **Inconsistency:** Agents complete tasks successfully one time, then fail when asked to complete the same task again. Memory gives the agent a mechanism to remember what worked and what didn't and to use that information to reduce errors and improve consistency.
- **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.
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.
## How is Hindsight Different From Other Memory Systems?
![Overview](./hindsight-docs/static/img/hindsight-overview.webp)
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.
### 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 is the most accurate agent memory system ever tested according to benchmark performance. It 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 January 2026 is shown here:
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 has been independently 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.
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.
Hindsight is being used in production at Fortune 500 enterprises and by a growing number of AI startups.
## Adding Hindsight to Your AI Agents
The easiest way to use Hindsight with an existing agent is with the LLM Wrapper. You can add memory to your agent with 2 lines of code. That will swap your current LLM client out with the Hindsight wrapper. After that, memories will be stored and retrieved automatically as you make LLM calls.
If you need more control over how and when your agent stores and recalls memories, there's also a simple API you can integrate with using the SDKs or directly via HTTP.
![Hindsight Banner](./hindsight-docs/static/img/migration-code.png)
---
> 🤖 **Using a coding agent?** Install the Hindsight documentation skill for instant access to docs while you code:
> ```bash
> npx skills add https://github.com/vectorize-io/hindsight --skill hindsight-docs
> ```
> Works with Claude Code, Cursor, and other AI coding assistants.
---
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)
```bash
export OPENAI_API_KEY=sk-xxx
export OPENAI_API_KEY=your-key
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=o3-mini \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
>API: http://localhost:8888
>UI: http://localhost:9999
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
### Docker (external PostgreSQL)
```bash
export OPENAI_API_KEY=sk-xxx
export HINDSIGHT_DB_PASSWORD=choose-a-password
cd docker/docker-compose
docker compose up
```
>API: http://localhost:8888
>UI: http://localhost:9999
### Client
Install client:
```bash
pip install hindsight-client -U
@@ -94,7 +94,7 @@ pip install hindsight-client -U
npm install @vectorize-io/hindsight-client
```
#### Python
Python example:
```python
from hindsight_client import Hindsight
@@ -111,29 +111,7 @@ client.recall(bank_id="my-bank", query="What does Alice do?")
client.reflect(bank_id="my-bank", query="Tell me about Alice")
```
#### Node.js / TypeScript
```bash
npm install @vectorize-io/hindsight-client
```
```javascript
const { HindsightClient } = require('@vectorize-io/hindsight-client');
const main = async () => {
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
await client.retain('my-bank', 'Alice loves hiking in Yosemite');
const results = await client.recall('my-bank', 'What does Alice like?');
console.log(results);
}
main();
```
### Python Embedded (no server required)
### Python (embedded, no Docker)
```bash
pip install hindsight-all -U
@@ -153,48 +131,25 @@ with HindsightServer(
results = client.recall(bank_id="my-bank", query="Where does Alice work?")
```
### Node.js / TypeScript
---
```bash
npm install @vectorize-io/hindsight-client
```
## Use Cases
```javascript
const { HindsightClient } = require('@vectorize-io/hindsight-client');
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
Hindsight is built to support conversational AI agents as well as agents that are intended to perform tasks autonomously. The ideal use case for Hindsight are agents that require a blend of these features such as AI employees that need to handle open-ended tasks, change behavior based on user feedback, and learn to perform complex tasks to automate work at a level that approximates a human work. Hindsight can be used with simple AI workflows like those built with n8n and other similar tools, but may be overkill for such applications.
### Per-User Memories and Chat History
One of the simpler use cases you can use Hindsight for is to personalize AI chatbots and other conversational agents by storing and recalling memories associated with individual users.
The requirements for this use case usually look something like this:
![Per-User Memories](./hindsight-docs/static/img/per-user-memory-requirements.png)
<video src="https://github.com/user-attachments/assets/4805e8e1-e7d1-47c6-a4f8-2344a5ec8906" controls></video>
Satisfying these requirements in Hindsight is straightforward. When new user inputs and tool calls are ingested into Hindsight using the retain operation, custom metadata can be used to enrich the new memories. Metadata provides a convenient way to isolate memories that need to be restricted to a given user. Once these are fed into the retain operation, any raw memories and mental models that get created can be filtered when retrieving relevant memories.
![Per-User Memories](./hindsight-docs/static/img/per-user-memory-howto.png)
await client.retain('my-bank', 'Alice loves hiking in Yosemite');
await client.recall('my-bank', 'What does Alice like?');
```
---
## Architecture & Operations
![Overview](./hindsight-docs/static/img/hindsight-overview.webp)
Most agent memory implementations 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")
- **Mental Models:** Learned understanding of the agent's world formed by reflecting on raw memories and experiences.
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.
### Retain
The `retain` operation is used to push new memories into Hindsight. It tells Hindsight to _retain_ the information you pass in as an input.
@@ -253,7 +208,7 @@ The final output is trimmed as needed to fit within the token limit.
### Reflect
The reflect operation is used to perform a more thorough analysis of existing memories. This allows the agent to form new connections between memories and build a more thorough understanding of its world.
The reflect operation is used to perform a more thorough analysis of existing memories. This allows the agent to form new connections between memories which are then persisted as opinions and/or observations. When building agents, the reflect operation is a key capability to enable the agent to learn from its experiences.
For example, the `reflect` operation can be used to support use cases such as:
@@ -307,5 +262,3 @@ MIT — see [LICENSE](./LICENSE)
---
Built by [Vectorize.io](https://vectorize.io)
<img src="https://umami-pixel.chris-latimer.workers.dev/?id=a8b043e6-6964-454d-80df-69b69d3f0d50&host=github.com&url=/vectorize-io/hindsight" width="1" height="1" alt="" />
@@ -1,54 +0,0 @@
# Docker Compose file for Hindsight with PostgreSQL and pgvector
#
# Make sure to set the required environment variables before running:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - Configure LLM provider variables as needed (see below in the hindsight service)
#
# Usage:
# docker compose up -d
#
# Optional environment variables with defaults:
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
# - HINDSIGHT_DB_USER: PostgreSQL user (default: hindsight_user)
# - HINDSIGHT_DB_NAME: PostgreSQL database name (default: hindsight_db)
# - HINDSIGHT_DB_VERSION: PostgreSQL version (default: 18)
services:
db:
# Use a PostgreSQL-Image with pgvector extension pre-installed
# see https://hub.docker.com/r/pgvector/pgvector
image: pgvector/pgvector:pg${HINDSIGHT_DB_VERSION:-18}
container_name: hindsight-db
restart: always
# Expose PostgreSQL port
# ports:
# - "5432:5432"
environment:
POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user}
POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:?Please set the HINDSIGHT_DB_PASSWORD env variable}
POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db}
volumes:
- pg_data:/var/lib/postgresql/${HINDSIGHT_DB_VERSION:-18}/docker
networks:
- hindsight-net
hindsight:
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
container_name: hindsight-app
ports:
- "8888:8888"
- "9999:9999"
environment:
- HINDSIGHT_API_LLM_API_KEY=${OPENAI_API_KEY?Please set the OPENAI_API_KEY env variable}
- HINDSIGHT_API_DATABASE_URL=postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:?Please set the HINDSIGHT_DB_PASSWORD env variable}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
depends_on:
- db
networks:
- hindsight-net
networks:
hindsight-net:
driver: bridge
volumes:
pg_data:
-96
View File
@@ -1,96 +0,0 @@
# Nginx Reverse Proxy with Custom Base Path
Deploy Hindsight API under `/hindsight` (or any custom path) using Nginx reverse proxy.
## Quick Start (Published Image - API Only)
```bash
docker-compose up
```
- **API:** http://localhost:8080/hindsight/docs
- **Control Plane:** http://localhost:9999 (direct access, not proxied)
## Full Stack with Custom Base Path (Requires Build)
**Important:** You cannot rebuild from the published image with build args. You must build from source.
### Build from Source with Custom Base Path
1. **Clone the repository** (if you haven't):
```bash
git clone https://github.com/vectorize-io/hindsight.git
cd hindsight
```
2. **Build with base path**:
```bash
docker build \
--build-arg NEXT_PUBLIC_BASE_PATH=/hindsight \
-f docker/standalone/Dockerfile \
-t hindsight:custom \
.
```
3. **Update docker-compose.yml** to use your built image:
```yaml
services:
hindsight:
image: hindsight:custom # ← Change this
environment:
HINDSIGHT_API_BASE_PATH: /hindsight
NEXT_PUBLIC_BASE_PATH: /hindsight
```
4. **Update nginx.conf** to handle Control Plane routes (see below)
5. **Run**:
```bash
docker-compose up
```
### Required nginx.conf for Full Stack
Replace the current `nginx.conf` with this to proxy both API and Control Plane:
```nginx
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
upstream hindsight_api { server hindsight:8888; }
upstream hindsight_cp { server hindsight:9999; }
server {
listen 80;
# API
location ~ ^/hindsight/(docs|openapi\.json|health|metrics|v1|mcp) {
proxy_pass http://hindsight_api;
proxy_set_header Host $http_host;
}
# Control Plane static files
location ~ ^/hindsight/_next/ {
proxy_pass http://hindsight_cp;
proxy_set_header Host $http_host;
}
# Control Plane UI
location /hindsight {
proxy_pass http://hindsight_cp;
proxy_set_header Host $http_host;
}
location = / { return 301 /hindsight; }
}
}
```
### Why Build is Required
Next.js requires `basePath` at **build time**. The published image was built without a custom base path, so you must rebuild from source with the `NEXT_PUBLIC_BASE_PATH` build arg to deploy the Control Plane under a subpath.
The API works without rebuild because `HINDSIGHT_API_BASE_PATH` is a runtime environment variable.
@@ -1,88 +0,0 @@
# Hindsight API deployment with Nginx reverse proxy (API-only)
#
# This example deploys Hindsight API under the path /hindsight with:
# - Hindsight standalone image (API + Control Plane + embedded pg0)
# - Nginx reverse proxy (API only)
#
# Quick Start:
# docker-compose -f docker/docker-compose/nginx/docker-compose.yml up
#
# Access:
# API (via nginx): http://localhost:8080/hindsight/docs
# Control Plane (direct): http://localhost:9999
#
# For full stack deployment (API + Control Plane both under /hindsight):
# See README.md in this directory for instructions on building with basePath.
#
# Note: This configuration uses the published image (no build required).
# Control Plane is served directly because Next.js basePath requires
# build-time configuration. See README.md for the full stack option.
services:
# Hindsight (API + Control Plane + embedded pg0)
hindsight:
image: ghcr.io/vectorize-io/hindsight:latest
ports:
- "9999:9999" # Control Plane (direct access, not proxied)
environment:
# API base path for reverse proxy
HINDSIGHT_API_BASE_PATH: /hindsight
# LLM configuration
# Using mock provider for testing (no API key needed)
# For production, set OPENAI_API_KEY or ANTHROPIC_API_KEY and use a real provider
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-mock}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-not-needed-for-mock}
HINDSIGHT_API_LLM_MODEL: ${HINDSIGHT_API_LLM_MODEL:-mock-model}
# Production examples (uncomment and set appropriate API key):
# HINDSIGHT_API_LLM_PROVIDER: openai
# HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY}
# HINDSIGHT_API_LLM_MODEL: gpt-4o-mini
# HINDSIGHT_API_LLM_PROVIDER: anthropic
# HINDSIGHT_API_LLM_API_KEY: ${ANTHROPIC_API_KEY}
# HINDSIGHT_API_LLM_MODEL: claude-sonnet-4-20250514
# Server config
HINDSIGHT_API_HOST: 0.0.0.0
HINDSIGHT_API_PORT: 8888
HINDSIGHT_API_LOG_LEVEL: info
# Control Plane config
HINDSIGHT_CP_DATAPLANE_API_URL: http://localhost:8888
volumes:
# Persist embedded pg0 database
- hindsight_data:/app/data
# Note: Ports not exposed - access via Nginx at localhost:8080/hindsight/
# To debug directly, uncomment these ports:
# ports:
# - "8888:8888" # API
# - "9999:9999" # Control Plane
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8888/hindsight/health"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
networks:
- hindsight
# Nginx reverse proxy
nginx:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
hindsight:
condition: service_healthy
networks:
- hindsight
volumes:
hindsight_data:
networks:
hindsight:
-40
View File
@@ -1,40 +0,0 @@
# Nginx configuration for API-only reverse proxy
# Control Plane accessed directly (not through nginx)
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Upstream - Hindsight API
upstream hindsight_api {
server hindsight:8888;
}
server {
listen 80;
server_name _;
# API endpoints - forward with /hindsight prefix
location /hindsight/ {
proxy_pass http://hindsight_api;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Redirect root to API docs
location = / {
return 301 /hindsight/docs;
}
}
}
@@ -1,32 +0,0 @@
# PostgreSQL with pgvector and pg_textsearch extensions
# Note: pg_textsearch requires PostgreSQL 17+
FROM postgres:17
# Install build dependencies
RUN apt-get update && apt-get install -y \
build-essential \
git \
postgresql-server-dev-17 \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install pgvector
RUN cd /tmp && \
git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git && \
cd pgvector && \
make && \
make install
# Install pg_textsearch
RUN cd /tmp && \
git clone https://github.com/timescale/pg_textsearch.git && \
cd pg_textsearch && \
make && \
make install
# Clean up source files and build dependencies
RUN rm -rf /tmp/pgvector /tmp/pg_textsearch && \
apt-get purge -y --auto-remove build-essential git postgresql-server-dev-17
# Ensure extensions are preloaded
RUN echo "shared_preload_libraries = 'pg_textsearch'" >> /usr/share/postgresql/postgresql.conf.sample
@@ -1,91 +0,0 @@
name: hindsight
# Docker Compose file for Hindsight with PostgreSQL and Timescale pg_textsearch
# docker compose -f docker/docker-compose/pg_textsearch/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/pg_textsearch/docker-compose.yaml up -d
# Make sure to set the required environment variables before running:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - Configure LLM provider variables as needed (see below in the hindsight service)
#
# Usage:
# docker compose up -d
#
# Optional environment variables with defaults:
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
# - HINDSIGHT_DB_USER: PostgreSQL user (default: hindsight_user)
# - HINDSIGHT_DB_NAME: PostgreSQL database name (default: hindsight_db)
services:
db:
# Use custom PostgreSQL image with pgvector and pg_textsearch extensions
build:
context: .
dockerfile: Dockerfile
container_name: hindsight-db
restart: always
# Expose PostgreSQL port
ports:
- "5437:5432"
environment:
POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user}
POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:-hindsight_password}
POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db}
volumes:
- pg_data:/var/lib/postgresql/data
networks:
- hindsight-net
pg-textsearch-init:
build:
context: .
dockerfile: Dockerfile
depends_on:
- db
environment:
- PGPASSWORD=${HINDSIGHT_DB_PASSWORD:-hindsight_password}
command: >
bash -c "
echo 'Waiting for PostgreSQL to be ready...';
until pg_isready -h hindsight-db -p 5432 -U hindsight_user; do
echo 'PostgreSQL is unavailable - sleeping';
sleep 2;
done;
echo 'PostgreSQL is ready - creating hindsight_db database';
psql -h hindsight-db -p 5432 -U hindsight_user -c 'CREATE DATABASE hindsight_db;' 2>/dev/null || echo 'Database already exists';
echo 'Creating extensions in hindsight_db database';
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS vector CASCADE;';
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS pg_textsearch CASCADE;';
echo 'Database and extensions created successfully';
"
restart: "no"
networks:
- hindsight-net
hindsight:
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
container_name: hindsight-app
ports:
- "8888:8888"
- "9999:9999"
environment:
# LLM Configuration
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
# Database Configuration
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
# Vector and Text Search Extensions
HINDSIGHT_API_VECTOR_EXTENSION: pgvector
HINDSIGHT_API_TEXT_SEARCH_EXTENSION: pg_textsearch
depends_on:
- db
networks:
- hindsight-net
networks:
hindsight-net:
driver: bridge
volumes:
pg_data:
@@ -1,83 +0,0 @@
# Docker Compose file for Hindsight with S3 file storage (SeaweedFS)
#
# SeaweedFS (Apache 2.0) provides an S3-compatible object storage backend
# for storing uploaded files instead of PostgreSQL BYTEA storage.
#
# Make sure to set the required environment variables before running:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - Configure LLM provider variables as needed (see below in the hindsight service)
#
# Usage:
# docker compose up -d
#
# Optional environment variables with defaults:
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
# - HINDSIGHT_DB_USER: PostgreSQL user (default: hindsight_user)
# - HINDSIGHT_DB_NAME: PostgreSQL database name (default: hindsight_db)
# - HINDSIGHT_DB_VERSION: PostgreSQL version (default: 18)
# - SEAWEEDFS_S3_ACCESS_KEY: S3 access key (default: hindsight_s3_key)
# - SEAWEEDFS_S3_SECRET_KEY: S3 secret key (default: hindsight_s3_secret)
services:
db:
image: pgvector/pgvector:pg${HINDSIGHT_DB_VERSION:-18}
container_name: hindsight-db
restart: always
environment:
POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user}
POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:?Please set the HINDSIGHT_DB_PASSWORD env variable}
POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db}
volumes:
- pg_data:/var/lib/postgresql/${HINDSIGHT_DB_VERSION:-18}/docker
networks:
- hindsight-net
seaweedfs:
image: chrislusf/seaweedfs:latest
container_name: hindsight-seaweedfs
restart: always
# Single-node mode: master + volume + filer + S3 gateway all in one process
command: >
server
-s3
-s3.port=8333
-s3.config=/etc/seaweedfs/s3.json
-ip.bind=0.0.0.0
volumes:
- seaweedfs_data:/data
- ./s3.json:/etc/seaweedfs/s3.json:ro
# Expose S3 API port (uncomment to access from host)
# ports:
# - "8333:8333"
networks:
- hindsight-net
hindsight:
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
container_name: hindsight-app
ports:
- "8888:8888"
- "9999:9999"
environment:
- HINDSIGHT_API_LLM_API_KEY=${OPENAI_API_KEY?Please set the OPENAI_API_KEY env variable}
- HINDSIGHT_API_DATABASE_URL=postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:?Please set the HINDSIGHT_DB_PASSWORD env variable}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
# S3 file storage configuration (SeaweedFS)
- HINDSIGHT_API_FILE_STORAGE_TYPE=s3
- HINDSIGHT_API_FILE_STORAGE_S3_BUCKET=hindsight
- HINDSIGHT_API_FILE_STORAGE_S3_ENDPOINT=http://seaweedfs:8333
- HINDSIGHT_API_FILE_STORAGE_S3_REGION=us-east-1
- HINDSIGHT_API_FILE_STORAGE_S3_ACCESS_KEY_ID=${SEAWEEDFS_S3_ACCESS_KEY:-hindsight_s3_key}
- HINDSIGHT_API_FILE_STORAGE_S3_SECRET_ACCESS_KEY=${SEAWEEDFS_S3_SECRET_KEY:-hindsight_s3_secret}
depends_on:
- db
- seaweedfs
networks:
- hindsight-net
networks:
hindsight-net:
driver: bridge
volumes:
pg_data:
seaweedfs_data:
@@ -1,19 +0,0 @@
{
"identities": [
{
"name": "hindsight",
"credentials": [
{
"accessKey": "hindsight_s3_key",
"secretKey": "hindsight_s3_secret"
}
],
"actions": [
"Admin",
"Read",
"Write",
"List"
]
}
]
}
@@ -1,16 +0,0 @@
# Git
.git
.gitignore
.gitattributes
# Docker
docker-compose.yaml
.dockerignore
# Documentation
README.md
*.md
# Environment
.env
.env.example
@@ -1,25 +0,0 @@
# PostgreSQL Configuration
HINDSIGHT_DB_USER=hindsight_user
HINDSIGHT_DB_PASSWORD=change-me-to-secure-password
HINDSIGHT_DB_NAME=hindsight_db
# Hindsight Version
HINDSIGHT_VERSION=latest
# LLM Configuration
HINDSIGHT_API_LLM_PROVIDER=openai
OPENAI_API_KEY=your-openai-api-key-here
# Alternative LLM providers (uncomment and configure as needed):
# HINDSIGHT_API_LLM_PROVIDER=anthropic
# ANTHROPIC_API_KEY=your-anthropic-api-key
# HINDSIGHT_API_LLM_PROVIDER=gemini
# GEMINI_API_KEY=your-gemini-api-key
# HINDSIGHT_API_LLM_PROVIDER=groq
# GROQ_API_KEY=your-groq-api-key
# Vector and Text Search (already configured in docker-compose.yaml)
# HINDSIGHT_API_VECTOR_EXTENSION=pgvectorscale
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION=pg_textsearch
@@ -1,55 +0,0 @@
# PostgreSQL with pgvector, pgvectorscale, and pg_textsearch extensions
# All three extensions from Timescale/pgvector for high-performance vector and text search
# Note: Requires PostgreSQL 16+
FROM postgres:17
# Install build dependencies and Rust toolchain
RUN apt-get update && apt-get install -y \
build-essential \
git \
postgresql-server-dev-17 \
libpq-dev \
cmake \
curl \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Rust toolchain (required for pgvectorscale)
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
# Install pgvector (required by pgvectorscale)
RUN cd /tmp && \
git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git && \
cd pgvector && \
make && \
make install && \
rm -rf /tmp/pgvector
# Install cargo-pgrx (PostgreSQL extension framework for Rust)
RUN cargo install cargo-pgrx --version 0.12.5 --locked && \
cargo pgrx init --pg17 /usr/bin/pg_config
# Install pgvectorscale (DiskANN index support)
RUN cd /tmp && \
git clone --branch 0.5.1 https://github.com/timescale/pgvectorscale.git && \
cd pgvectorscale/pgvectorscale && \
cargo pgrx install --release && \
rm -rf /tmp/pgvectorscale
# Install pg_textsearch (BM25 text search)
RUN cd /tmp && \
git clone https://github.com/timescale/pg_textsearch.git && \
cd pg_textsearch && \
make && \
make install && \
rm -rf /tmp/pg_textsearch
# Clean up build dependencies (keep runtime dependencies)
RUN apt-get purge -y --auto-remove git cmake curl && \
rm -rf /root/.cargo/registry /root/.cargo/git
# Ensure extensions are preloaded (pg_textsearch requires preloading)
RUN echo "shared_preload_libraries = 'pg_textsearch'" >> /usr/share/postgresql/postgresql.conf.sample
-101
View File
@@ -1,101 +0,0 @@
# Hindsight with Timescale Extensions
This Docker Compose setup provides a complete Hindsight deployment with **Timescale extensions**:
- **pgvectorscale** - DiskANN algorithm for disk-based scalable vector search
- **pg_textsearch** - High-performance BM25 text search
Both extensions are from [Timescale](https://github.com/timescale) and provide production-grade performance.
## Prerequisites
- Docker and Docker Compose installed
- OpenAI API key (or another LLM provider)
## Quick Start
```bash
# Set environment variables
export HINDSIGHT_DB_PASSWORD="your-secure-password"
export OPENAI_API_KEY="your-openai-api-key"
# Build and start
docker compose -f docker/docker-compose/timescale/docker-compose.yaml up -d --build
# Check logs
docker compose -f docker/docker-compose/timescale/docker-compose.yaml logs -f
```
**Access:**
- API: http://localhost:8888
- Control Plane: http://localhost:9999
## Stop and Clean Up
```bash
# Stop services
docker compose -f docker/docker-compose/timescale/docker-compose.yaml down
# Remove volumes (deletes all data)
docker compose -f docker/docker-compose/timescale/docker-compose.yaml down -v
```
## Configuration
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_DB_PASSWORD` | PostgreSQL password | `hindsight_password` |
| `HINDSIGHT_DB_USER` | PostgreSQL username | `hindsight_user` |
| `HINDSIGHT_DB_NAME` | Database name | `hindsight_db` |
| `HINDSIGHT_VERSION` | Hindsight Docker image version | `latest` |
| `OPENAI_API_KEY` | OpenAI API key | (required) |
| `HINDSIGHT_API_LLM_PROVIDER` | LLM provider | `openai` |
### Why Timescale Extensions?
**pgvectorscale (DiskANN):**
- 28x lower p95 latency vs dedicated vector databases
- 16x higher query throughput at 99% recall
- 60-75% cost reduction (disk is cheaper than RAM)
- Best for large datasets (10M+ vectors)
**pg_textsearch (BM25):**
- High-performance keyword retrieval
- Native BM25 ranking algorithm
- Optimized for full-text search
## Troubleshooting
### Extensions not installed
Check if extensions are available:
```bash
docker exec -it hindsight-db-timescale psql -U hindsight_user -d hindsight_db -c "\dx"
```
You should see:
- `vector` (pgvector)
- `vectorscale` (pgvectorscale/DiskANN)
- `pg_textsearch` (BM25 search)
### Build fails
If the Docker build fails during pgvectorscale compilation:
1. Ensure you have sufficient memory (recommended: 4GB+)
2. Check Docker build logs for Rust compilation errors
3. Try building with more resources: `docker compose build --no-cache --memory 4g`
### Port conflicts
If port 5438 is already in use, modify the `ports` section in docker-compose.yaml.
## Learn More
- [pgvectorscale GitHub](https://github.com/timescale/pgvectorscale)
- [pg_textsearch GitHub](https://github.com/timescale/pg_textsearch)
- [HNSW vs DiskANN](https://www.tigerdata.com/learn/hnsw-vs-diskann)
- [Hindsight Documentation](https://hindsight.dev)
@@ -1,108 +0,0 @@
name: hindsight
# Docker Compose file for Hindsight with Timescale extensions
# - pgvectorscale: DiskANN vector search (disk-based, scalable)
# - pg_textsearch: BM25 text search (high-performance keyword retrieval)
#
# Quick start:
# docker compose -f docker/docker-compose/timescale/docker-compose.yaml up -d --build
#
# Required environment variables:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - OPENAI_API_KEY (or configure another LLM provider)
#
# Optional environment variables with defaults:
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
# - HINDSIGHT_DB_USER: PostgreSQL user (default: hindsight_user)
# - HINDSIGHT_DB_NAME: PostgreSQL database name (default: hindsight_db)
services:
db:
# Custom PostgreSQL image with Timescale extensions (pgvectorscale + pg_textsearch)
build:
context: .
dockerfile: Dockerfile
container_name: hindsight-db-timescale
restart: always
# Expose PostgreSQL port (using 5438 to avoid conflicts with other setups)
ports:
- "5438:5432"
environment:
POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user}
POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:-hindsight_password}
POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db}
volumes:
- pg_data:/var/lib/postgresql/data
networks:
- hindsight-net
# Health check to ensure database is ready
healthcheck:
test: ["CMD-SHELL", "pg_isready -U hindsight_user"]
interval: 5s
timeout: 5s
retries: 5
timescale-init:
build:
context: .
dockerfile: Dockerfile
depends_on:
db:
condition: service_healthy
environment:
- PGPASSWORD=${HINDSIGHT_DB_PASSWORD:-hindsight_password}
command: >
bash -c "
echo 'PostgreSQL is ready - creating hindsight_db database';
psql -h hindsight-db-timescale -p 5432 -U hindsight_user -c 'CREATE DATABASE hindsight_db;' 2>/dev/null || echo 'Database already exists';
echo 'Installing Timescale extensions...';
echo '1/3: Installing pgvector (required by pgvectorscale)...';
psql -h hindsight-db-timescale -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS vector CASCADE;';
echo '2/3: Installing pgvectorscale (DiskANN vector search)...';
psql -h hindsight-db-timescale -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS vectorscale CASCADE;';
echo '3/3: Installing pg_textsearch (BM25 text search)...';
psql -h hindsight-db-timescale -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS pg_textsearch CASCADE;';
echo '';
echo '✅ Timescale extensions installed successfully';
echo '';
echo 'Installed extensions:';
psql -h hindsight-db-timescale -p 5432 -U hindsight_user -d hindsight_db -c \"\\dx\" | grep -E '(vector|vectorscale|pg_textsearch)';
"
restart: "no"
networks:
- hindsight-net
hindsight:
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
container_name: hindsight-app-timescale
ports:
- "8888:8888"
- "9999:9999"
environment:
# LLM Configuration
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
# Database Configuration
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
# Timescale Extensions
# pgvectorscale: DiskANN algorithm for disk-based scalable vector search
HINDSIGHT_API_VECTOR_EXTENSION: pgvectorscale
# pg_textsearch: High-performance BM25 text search
HINDSIGHT_API_TEXT_SEARCH_EXTENSION: pg_textsearch
depends_on:
db:
condition: service_healthy
timescale-init:
condition: service_completed_successfully
networks:
- hindsight-net
networks:
hindsight-net:
driver: bridge
volumes:
pg_data:
@@ -1,93 +0,0 @@
name: hindsight
# Docker Compose file for Hindsight with PostgreSQL and vectorchord
# docker compose -f docker/docker-compose/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/docker-compose.yaml up -d
# Make sure to set the required environment variables before running:
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
# - Configure LLM provider variables as needed (see below in the hindsight service)
#
# Usage:
# docker compose up -d
#
# Optional environment variables with defaults:
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
# - HINDSIGHT_DB_USER: PostgreSQL user (default: hindsight_user)
# - HINDSIGHT_DB_NAME: PostgreSQL database name (default: hindsight_db)
# - HINDSIGHT_DB_VERSION: PostgreSQL version (default: 18)
services:
db:
# Use a PostgreSQL-Image with vectorchord extension pre-installed
image: tensorchord/vchord-suite:pg${HINDSIGHT_DB_VERSION:-18-latest}
container_name: hindsight-db
restart: always
# Expose PostgreSQL port
ports:
- "5436:5432"
environment:
POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user}
POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:-hindsight_password}
POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db}
volumes:
- pg_data:/var/lib/postgresql/${HINDSIGHT_DB_VERSION:-18}/docker
networks:
- hindsight-net
vectorchord-init:
image: tensorchord/vchord-suite:pg18-latest
#container_name: vectorchord-init
depends_on:
- db
environment:
- PGPASSWORD=${HINDSIGHT_DB_PASSWORD:-hindsight_password}
command: >
bash -c "
echo 'Waiting for PostgreSQL to be ready...';
until pg_isready -h hindsight-db -p 5432 -U hindsight_user; do
echo 'PostgreSQL is unavailable - sleeping';
sleep 2;
done;
echo 'PostgreSQL is ready - creating hindsight_db database';
psql -h hindsight-db -p 5432 -U hindsight_user -c 'CREATE DATABASE hindsight_db;' 2>/dev/null || echo 'Database already exists';
echo 'Creating extensions in hindsight_db database';
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS vchord CASCADE;';
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS pg_tokenizer CASCADE;';
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS vchord_bm25 CASCADE;';
echo 'Creating llmlingua2 tokenizer';
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c \"SELECT create_tokenizer('llmlingua2', \\$\\$ model = \\\"llmlingua2\\\" \\$\\$);\" 2>/dev/null || echo 'Tokenizer already exists or creation skipped';
echo 'Database and extensions created successfully';
"
restart: "no"
networks:
- hindsight-net
hindsight:
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
container_name: hindsight-app
ports:
- "8888:8888"
- "9999:9999"
environment:
# LLM Configuration (uses OpenAI for testing vchord)
# LLM configuration
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
# Database Configuration
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
# Vector and Text Search Extensions
HINDSIGHT_API_VECTOR_EXTENSION: vchord
HINDSIGHT_API_TEXT_SEARCH_EXTENSION: vchord
depends_on:
- db
networks:
- hindsight-net
networks:
hindsight-net:
driver: bridge
volumes:
pg_data:
+4 -105
View File
@@ -8,7 +8,6 @@
# Set to false when using external providers (TEI, OpenAI, Cohere)
# PRELOAD_ML_MODELS=true/false - Pre-download ML models during build (default: true)
# Only effective when INCLUDE_LOCAL_MODELS=true
# NOTE: tiktoken encodings are ALWAYS preloaded (required for air-gapped deployments)
#
# Examples:
# docker build -t hindsight . # Both (standalone)
@@ -112,10 +111,6 @@ RUN rm -f package-lock.json && sed -i '/"@vectorize-io\/hindsight-client":/d' pa
# 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
# Accept base path as build argument for reverse proxy deployments
# Usage: docker build --build-arg NEXT_PUBLIC_BASE_PATH=/hindsight ...
ARG NEXT_PUBLIC_BASE_PATH=""
# 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
@@ -170,64 +165,20 @@ RUN chown -R hindsight:hindsight /app
USER hindsight
# Create pg0 data directory as hindsight user so that Docker seeds new named
# volumes with correct ownership (UID 1000) on first use, avoiding the
# "Permission denied" error when mounting a fresh root-owned volume.
RUN mkdir -p /home/hindsight/.pg0
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
# Tiktoken is a core runtime dependency, not an optional ML model
RUN MAX_RETRIES=3; \
RETRY_DELAY=5; \
for i in $(seq 1 $MAX_RETRIES); do \
echo "Attempt $i/$MAX_RETRIES: Downloading tiktoken encoding..."; \
/app/api/.venv/bin/python -c "\
import tiktoken; \
print('Downloading cl100k_base encoding...'); \
tiktoken.get_encoding('cl100k_base'); \
print('Tiktoken encoding cached successfully')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
sleep $RETRY_DELAY; \
RETRY_DELAY=$((RETRY_DELAY * 2)); \
fi; \
done; \
if [ $i -eq $MAX_RETRIES ]; then \
echo "ERROR: Failed to download tiktoken encoding after $MAX_RETRIES attempts"; \
exit 1; \
fi
# Pre-download ML models to avoid runtime download (conditional)
# Only runs if both PRELOAD_ML_MODELS=true AND INCLUDE_LOCAL_MODELS=true
# Includes retry logic with exponential backoff for transient network failures
ARG PRELOAD_ML_MODELS
ARG INCLUDE_LOCAL_MODELS
ENV HF_HUB_DOWNLOAD_TIMEOUT=600
RUN if [ "$PRELOAD_ML_MODELS" = "true" ] && [ "$INCLUDE_LOCAL_MODELS" = "true" ]; then \
MAX_RETRIES=3; \
RETRY_DELAY=10; \
for i in $(seq 1 $MAX_RETRIES); do \
echo "Attempt $i/$MAX_RETRIES: Downloading ML models..."; \
/app/api/.venv/bin/python -c "\
import os; os.environ['HF_HUB_DOWNLOAD_TIMEOUT'] = '600'; \
/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')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
sleep $RETRY_DELAY; \
RETRY_DELAY=$((RETRY_DELAY * 2)); \
fi; \
done; \
if [ $i -eq $MAX_RETRIES ] && ! /app/api/.venv/bin/python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('BAAI/bge-small-en-v1.5')" 2>/dev/null; then \
echo "ERROR: Failed to download models after $MAX_RETRIES attempts"; \
exit 1; \
fi; \
print('Models cached successfully')"; \
elif [ "$INCLUDE_LOCAL_MODELS" != "true" ]; then echo "Skipping ML model preload (local-models not included)"; \
else echo "Skipping ML model preload"; fi
@@ -239,10 +190,6 @@ ENV HINDSIGHT_API_LOG_LEVEL=info
ENV HINDSIGHT_ENABLE_API=true
ENV HINDSIGHT_ENABLE_CP=false
ENV PYTHONUNBUFFERED=1
# Suppress verbose transformers/HuggingFace model loading warnings
ENV TRANSFORMERS_VERBOSITY=error
ENV HF_HUB_VERBOSITY=error
ENV TOKENIZERS_PARALLELISM=false
CMD ["/app/start-all.sh"]
@@ -326,64 +273,20 @@ RUN chown -R hindsight:hindsight /app
USER hindsight
# Create pg0 data directory as hindsight user so that Docker seeds new named
# volumes with correct ownership (UID 1000) on first use, avoiding the
# "Permission denied" error when mounting a fresh root-owned volume.
RUN mkdir -p /home/hindsight/.pg0
ENV PATH="/app/api/.venv/bin:${PATH}"
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
# Tiktoken is a core runtime dependency, not an optional ML model
RUN MAX_RETRIES=3; \
RETRY_DELAY=5; \
for i in $(seq 1 $MAX_RETRIES); do \
echo "Attempt $i/$MAX_RETRIES: Downloading tiktoken encoding..."; \
/app/api/.venv/bin/python -c "\
import tiktoken; \
print('Downloading cl100k_base encoding...'); \
tiktoken.get_encoding('cl100k_base'); \
print('Tiktoken encoding cached successfully')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
sleep $RETRY_DELAY; \
RETRY_DELAY=$((RETRY_DELAY * 2)); \
fi; \
done; \
if [ $i -eq $MAX_RETRIES ]; then \
echo "ERROR: Failed to download tiktoken encoding after $MAX_RETRIES attempts"; \
exit 1; \
fi
# Pre-download ML models to avoid runtime download (conditional)
# Only runs if both PRELOAD_ML_MODELS=true AND INCLUDE_LOCAL_MODELS=true
# Includes retry logic with exponential backoff for transient network failures
ARG PRELOAD_ML_MODELS
ARG INCLUDE_LOCAL_MODELS
ENV HF_HUB_DOWNLOAD_TIMEOUT=600
RUN if [ "$PRELOAD_ML_MODELS" = "true" ] && [ "$INCLUDE_LOCAL_MODELS" = "true" ]; then \
MAX_RETRIES=3; \
RETRY_DELAY=10; \
for i in $(seq 1 $MAX_RETRIES); do \
echo "Attempt $i/$MAX_RETRIES: Downloading ML models..."; \
/app/api/.venv/bin/python -c "\
import os; os.environ['HF_HUB_DOWNLOAD_TIMEOUT'] = '600'; \
/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')" && break; \
if [ $i -lt $MAX_RETRIES ]; then \
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
sleep $RETRY_DELAY; \
RETRY_DELAY=$((RETRY_DELAY * 2)); \
fi; \
done; \
if [ $i -eq $MAX_RETRIES ] && ! /app/api/.venv/bin/python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('BAAI/bge-small-en-v1.5')" 2>/dev/null; then \
echo "ERROR: Failed to download models after $MAX_RETRIES attempts"; \
exit 1; \
fi; \
print('Models cached successfully')"; \
elif [ "$INCLUDE_LOCAL_MODELS" != "true" ]; then echo "Skipping ML model preload (local-models not included)"; \
else echo "Skipping ML model preload"; fi
@@ -397,10 +300,6 @@ ENV HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888
ENV HINDSIGHT_ENABLE_API=true
ENV HINDSIGHT_ENABLE_CP=true
ENV PYTHONUNBUFFERED=1
# Suppress verbose transformers/HuggingFace model loading warnings
ENV TRANSFORMERS_VERBOSITY=error
ENV HF_HUB_VERBOSITY=error
ENV TOKENIZERS_PARALLELISM=false
CMD ["/app/start-all.sh"]
+2 -2
View File
@@ -97,7 +97,7 @@ fi
if [ "$ENABLE_CP" = "true" ]; then
echo "🎛️ Starting Control Plane..."
cd /app/control-plane
PORT="${HINDSIGHT_CP_PORT:-9999}" node server.js &
PORT=9999 node server.js &
CP_PID=$!
PIDS+=($CP_PID)
else
@@ -110,7 +110,7 @@ echo "✅ Hindsight is running!"
echo ""
echo "📍 Access:"
if [ "$ENABLE_CP" = "true" ]; then
echo " Control Plane: http://localhost:${HINDSIGHT_CP_PORT:-9999}"
echo " Control Plane: http://localhost:9999"
fi
if [ "$ENABLE_API" = "true" ]; then
echo " API: http://localhost:8888"
-217
View File
@@ -1,217 +0,0 @@
#!/bin/bash
#
# Docker Smoke Test Script
#
# Tests that a Hindsight Docker image starts correctly and becomes healthy.
# Can be run locally or in CI pipelines.
#
# Usage:
# ./docker/test-image.sh <image> [target]
#
# Arguments:
# image - Docker image to test (e.g., hindsight-api:test, ghcr.io/vectorize-io/hindsight:latest)
# target - Optional: 'cp-only' for control plane, otherwise assumes API image (default: api)
#
# Environment variables:
# HINDSIGHT_API_LLM_API_KEY - Required for API/standalone images (LLM verification)
# HINDSIGHT_API_LLM_PROVIDER - LLM provider (default: openai)
# HINDSIGHT_API_LLM_MODEL - LLM model (default: gpt-4o-mini)
# HINDSIGHT_API_EMBEDDINGS_PROVIDER - Embeddings provider (optional, for slim images: openai, cohere, tei)
# HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY - OpenAI API key for embeddings (optional)
# HINDSIGHT_API_RERANKER_PROVIDER - Reranker provider (optional, for slim images: cohere, tei)
# HINDSIGHT_API_COHERE_API_KEY - Cohere API key for reranking (optional)
# SMOKE_TEST_TIMEOUT - Timeout in seconds (default: 120)
# SMOKE_TEST_CONTAINER_NAME - Container name (default: hindsight-smoke-test)
#
# Examples:
# # Test a locally built full image
# ./docker/test-image.sh hindsight-api:test
#
# # Test a released image
# ./docker/test-image.sh ghcr.io/vectorize-io/hindsight:latest
#
# # Test control plane image
# ./docker/test-image.sh hindsight-control-plane:test cp-only
#
# # Test slim image with external providers
# export HINDSIGHT_API_LLM_API_KEY=sk_xxx
# export HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
# export HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=sk-xxx
# export HINDSIGHT_API_RERANKER_PROVIDER=cohere
# export HINDSIGHT_API_COHERE_API_KEY=xxx
# ./docker/test-image.sh hindsight-slim:test
#
# Exit codes:
# 0 - Success (container healthy)
# 1 - Failure (container not healthy within timeout)
# 2 - Invalid arguments
#
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
# Configuration
IMAGE="${1:-}"
TARGET="${2:-api}"
TIMEOUT="${SMOKE_TEST_TIMEOUT:-120}"
CONTAINER_NAME="${SMOKE_TEST_CONTAINER_NAME:-hindsight-smoke-test}"
LLM_PROVIDER="${HINDSIGHT_API_LLM_PROVIDER:-openai}"
LLM_MODEL="${HINDSIGHT_API_LLM_MODEL:-gpt-4o-mini}"
# Validate arguments
if [ -z "$IMAGE" ]; then
echo -e "${RED}Error: Image argument is required${NC}"
echo ""
echo "Usage: $0 <image> [target]"
echo ""
echo "Examples:"
echo " $0 hindsight-api:test"
echo " $0 ghcr.io/vectorize-io/hindsight:latest"
echo " $0 hindsight-control-plane:test cp-only"
exit 2
fi
# Determine health endpoint based on target
if [ "$TARGET" = "cp-only" ]; then
HEALTH_PORT=9999
HEALTH_PATH="/api/health"
NEEDS_LLM=false
else
HEALTH_PORT=8888
HEALTH_PATH="/health"
NEEDS_LLM=true
fi
# Check for required environment variables
if [ "$NEEDS_LLM" = true ] && [ "$LLM_PROVIDER" != "vertexai" ] && [ -z "${HINDSIGHT_API_LLM_API_KEY:-}" ]; then
echo -e "${RED}Error: HINDSIGHT_API_LLM_API_KEY environment variable is required for API/standalone images${NC}"
echo "Set it with: export HINDSIGHT_API_LLM_API_KEY=your-api-key"
exit 2
fi
# Cleanup function
cleanup() {
echo "Cleaning up..."
docker stop "$CONTAINER_NAME" 2>/dev/null || true
docker rm "$CONTAINER_NAME" 2>/dev/null || true
}
# Set trap to cleanup on exit
trap cleanup EXIT
echo -e "${YELLOW}Starting smoke test for: ${IMAGE}${NC}"
echo " Target: $TARGET"
echo " Health endpoint: http://localhost:${HEALTH_PORT}${HEALTH_PATH}"
echo " Timeout: ${TIMEOUT}s"
echo ""
# Remove any existing container with the same name
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
# Start container based on target type
echo "Starting container..."
if [ "$TARGET" = "cp-only" ]; then
docker run -d --name "$CONTAINER_NAME" \
-p "${HEALTH_PORT}:${HEALTH_PORT}" \
"$IMAGE"
else
# Build docker run command with required and optional env vars
DOCKER_CMD="docker run -d --name $CONTAINER_NAME"
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_PROVIDER=$LLM_PROVIDER"
if [ -n "${HINDSIGHT_API_LLM_API_KEY:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_API_KEY=${HINDSIGHT_API_LLM_API_KEY}"
fi
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_MODEL=$LLM_MODEL"
# Add Vertex AI config if provider is vertexai
if [ "$LLM_PROVIDER" = "vertexai" ]; then
if [ -n "${HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -v ${HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY}:/tmp/gcp-credentials.json:ro"
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY=/tmp/gcp-credentials.json"
fi
if [ -n "${HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=${HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID}"
fi
if [ -n "${HINDSIGHT_API_LLM_VERTEXAI_REGION:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_VERTEXAI_REGION=${HINDSIGHT_API_LLM_VERTEXAI_REGION}"
fi
fi
# Add optional embeddings provider config
if [ -n "${HINDSIGHT_API_EMBEDDINGS_PROVIDER:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_EMBEDDINGS_PROVIDER=${HINDSIGHT_API_EMBEDDINGS_PROVIDER}"
fi
if [ -n "${HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=${HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY}"
fi
# Add optional reranker provider config
if [ -n "${HINDSIGHT_API_RERANKER_PROVIDER:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_RERANKER_PROVIDER=${HINDSIGHT_API_RERANKER_PROVIDER}"
fi
if [ -n "${HINDSIGHT_API_COHERE_API_KEY:-}" ]; then
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_COHERE_API_KEY=${HINDSIGHT_API_COHERE_API_KEY}"
fi
DOCKER_CMD="$DOCKER_CMD -p ${HEALTH_PORT}:${HEALTH_PORT}"
DOCKER_CMD="$DOCKER_CMD $IMAGE"
eval $DOCKER_CMD
fi
# Wait for health endpoint
echo "Waiting for health endpoint at http://localhost:${HEALTH_PORT}${HEALTH_PATH}..."
start_time=$(date +%s)
for i in $(seq 1 "$TIMEOUT"); do
if curl -sf "http://localhost:${HEALTH_PORT}${HEALTH_PATH}" > /dev/null 2>&1; then
end_time=$(date +%s)
duration=$((end_time - start_time))
echo ""
echo -e "${GREEN}Container is healthy after ${duration}s${NC}"
echo ""
echo "=== Health Response ==="
curl -s "http://localhost:${HEALTH_PORT}${HEALTH_PATH}" | python3 -m json.tool 2>/dev/null || curl -s "http://localhost:${HEALTH_PORT}${HEALTH_PATH}"
echo ""
echo ""
echo "=== Container Logs (last 50 lines) ==="
docker logs "$CONTAINER_NAME" 2>&1 | tail -50
echo ""
echo -e "${GREEN}Smoke test PASSED${NC}"
exit 0
fi
# Show progress every 10 seconds
if [ $((i % 10)) -eq 0 ]; then
echo " Still waiting... (${i}s)"
fi
# Check if container is still running
if ! docker ps -q -f "name=$CONTAINER_NAME" | grep -q .; then
echo ""
echo -e "${RED}Container exited unexpectedly!${NC}"
echo ""
echo "=== Container Logs ==="
docker logs "$CONTAINER_NAME" 2>&1
echo ""
echo -e "${RED}Smoke test FAILED${NC}"
exit 1
fi
sleep 1
done
# Timeout reached
echo ""
echo -e "${RED}Container failed to become healthy after ${TIMEOUT}s${NC}"
echo ""
echo "=== Container Logs ==="
docker logs "$CONTAINER_NAME" 2>&1
echo ""
echo -e "${RED}Smoke test FAILED${NC}"
exit 1
-47
View File
@@ -1,47 +0,0 @@
#!/bin/bash
#
# Local Test Script for Slim Docker Images
#
# This script makes it easy to test slim images locally with external providers.
# It expects API keys to be set in environment variables.
#
# Usage:
# export OPENAI_API_KEY=sk-xxx
# export COHERE_API_KEY=xxx
# ./docker/test-slim-local.sh
#
# Or inline:
# OPENAI_API_KEY=sk_xxx COHERE_API_KEY=xxx ./docker/test-slim-local.sh
#
set -euo pipefail
# Check for required API keys
if [ -z "${OPENAI_API_KEY:-}" ]; then
echo "❌ Error: OPENAI_API_KEY environment variable is required"
echo "Set it with: export OPENAI_API_KEY=sk-xxx"
exit 1
fi
if [ -z "${COHERE_API_KEY:-}" ]; then
echo "❌ Error: COHERE_API_KEY environment variable is required"
echo "Set it with: export COHERE_API_KEY=xxx"
exit 1
fi
# Configuration
IMAGE="${1:-hindsight-slim:test}"
echo "Testing image: $IMAGE"
echo ""
# Set up LLM and external providers
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY
export HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
export HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=$OPENAI_API_KEY
export HINDSIGHT_API_RERANKER_PROVIDER=cohere
export HINDSIGHT_API_COHERE_API_KEY=$COHERE_API_KEY
# Run the test
exec "$(dirname "$0")/test-image.sh" "$IMAGE" standalone
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.4.15
appVersion: "0.4.15"
version: 0.3.0
appVersion: "0.3.0"
keywords:
- ai
- memory
-32
View File
@@ -127,38 +127,6 @@ API URL for control plane
{{- printf "http://%s-api:%d" (include "hindsight.fullname" .) (.Values.api.service.port | int) }}
{{- end }}
{{/*
TEI reranker labels
*/}}
{{- define "hindsight.tei.reranker.labels" -}}
{{ include "hindsight.labels" . }}
app.kubernetes.io/component: tei-reranker
{{- end }}
{{/*
TEI reranker selector labels
*/}}
{{- define "hindsight.tei.reranker.selectorLabels" -}}
{{ include "hindsight.selectorLabels" . }}
app.kubernetes.io/component: tei-reranker
{{- end }}
{{/*
TEI embedding labels
*/}}
{{- define "hindsight.tei.embedding.labels" -}}
{{ include "hindsight.labels" . }}
app.kubernetes.io/component: tei-embedding
{{- end }}
{{/*
TEI embedding selector labels
*/}}
{{- define "hindsight.tei.embedding.selectorLabels" -}}
{{ include "hindsight.selectorLabels" . }}
app.kubernetes.io/component: tei-embedding
{{- end }}
{{/*
Get the name of the secret to use
*/}}
+2 -17
View File
@@ -33,7 +33,7 @@ spec:
- name: api
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag | default .Values.version | default .Chart.AppVersion }}"
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag | default .Values.version }}"
imagePullPolicy: {{ .Values.api.image.pullPolicy }}
ports:
- name: http
@@ -60,25 +60,10 @@ spec:
- name: HINDSIGHT_API_WORKER_ENABLED
value: "false"
{{- end }}
{{- /* Explicitly set port to override K8s service discovery env var (HINDSIGHT_API_PORT) */}}
- name: HINDSIGHT_API_PORT
value: {{ .Values.api.service.targetPort | quote }}
{{- range $key, $value := .Values.api.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- if .Values.tei.reranker.enabled }}
- name: HINDSIGHT_API_RERANKER_PROVIDER
value: "tei"
- name: HINDSIGHT_API_RERANKER_TEI_URL
value: "http://{{ include "hindsight.fullname" . }}-tei-reranker:{{ .Values.tei.reranker.port }}"
{{- end }}
{{- if .Values.tei.embedding.enabled }}
- name: HINDSIGHT_API_EMBEDDINGS_PROVIDER
value: "tei"
- name: HINDSIGHT_API_EMBEDDINGS_TEI_URL
value: "http://{{ include "hindsight.fullname" . }}-tei-embedding:{{ .Values.tei.embedding.port }}"
{{- end }}
{{- /* Only use api.secrets when not using existingSecret (for chart-managed secrets) */}}
{{- if not .Values.existingSecret }}
{{- range $key, $value := .Values.api.secrets }}
@@ -99,7 +84,7 @@ spec:
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with (.Values.api.affinity | default .Values.affinity) }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -33,7 +33,7 @@ spec:
- name: control-plane
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.controlPlane.image.repository }}:{{ .Values.controlPlane.image.tag | default .Values.version | default .Chart.AppVersion }}"
image: "{{ .Values.controlPlane.image.repository }}:{{ .Values.controlPlane.image.tag | default .Values.version }}"
imagePullPolicy: {{ .Values.controlPlane.image.pullPolicy }}
ports:
- name: http
@@ -71,7 +71,7 @@ spec:
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with (.Values.controlPlane.affinity | default .Values.affinity) }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
-56
View File
@@ -1,56 +0,0 @@
{{- if and .Values.api.enabled .Values.api.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "hindsight.fullname" . }}-api
labels:
{{- include "hindsight.api.labels" . | nindent 4 }}
spec:
{{- if .Values.api.podDisruptionBudget.minAvailable }}
minAvailable: {{ .Values.api.podDisruptionBudget.minAvailable }}
{{- end }}
{{- if .Values.api.podDisruptionBudget.maxUnavailable }}
maxUnavailable: {{ .Values.api.podDisruptionBudget.maxUnavailable }}
{{- end }}
selector:
matchLabels:
{{- include "hindsight.api.selectorLabels" . | nindent 6 }}
{{- end }}
---
{{- if and .Values.controlPlane.enabled .Values.controlPlane.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "hindsight.fullname" . }}-control-plane
labels:
{{- include "hindsight.controlPlane.labels" . | nindent 4 }}
spec:
{{- if .Values.controlPlane.podDisruptionBudget.minAvailable }}
minAvailable: {{ .Values.controlPlane.podDisruptionBudget.minAvailable }}
{{- end }}
{{- if .Values.controlPlane.podDisruptionBudget.maxUnavailable }}
maxUnavailable: {{ .Values.controlPlane.podDisruptionBudget.maxUnavailable }}
{{- end }}
selector:
matchLabels:
{{- include "hindsight.controlPlane.selectorLabels" . | nindent 6 }}
{{- end }}
---
{{- if and .Values.worker.enabled .Values.worker.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "hindsight.fullname" . }}-worker
labels:
{{- include "hindsight.worker.labels" . | nindent 4 }}
spec:
{{- if .Values.worker.podDisruptionBudget.minAvailable }}
minAvailable: {{ .Values.worker.podDisruptionBudget.minAvailable }}
{{- end }}
{{- if .Values.worker.podDisruptionBudget.maxUnavailable }}
maxUnavailable: {{ .Values.worker.podDisruptionBudget.maxUnavailable }}
{{- end }}
selector:
matchLabels:
{{- include "hindsight.worker.selectorLabels" . | nindent 6 }}
{{- end }}
@@ -1,76 +0,0 @@
{{- if .Values.tei.embedding.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "hindsight.fullname" . }}-tei-embedding
labels:
{{- include "hindsight.tei.embedding.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.tei.embedding.replicaCount }}
selector:
matchLabels:
{{- include "hindsight.tei.embedding.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "hindsight.tei.embedding.selectorLabels" . | nindent 8 }}
spec:
{{- if .Values.serviceAccount.create }}
serviceAccountName: {{ include "hindsight.serviceAccountName" . }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: tei-embedding
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.tei.embedding.image.repository }}:{{ .Values.tei.embedding.image.tag }}"
imagePullPolicy: {{ .Values.tei.embedding.image.pullPolicy }}
args:
- "--model-id"
- {{ .Values.tei.embedding.model | quote }}
- "--hostname"
- "0.0.0.0"
{{- range .Values.tei.embedding.args }}
- {{ . | quote }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.tei.embedding.port }}
protocol: TCP
env:
- name: PORT
value: {{ .Values.tei.embedding.port | quote }}
{{- range $key, $value := .Values.tei.embedding.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
livenessProbe:
{{- toYaml .Values.tei.embedding.livenessProbe | nindent 10 }}
readinessProbe:
{{- toYaml .Values.tei.embedding.readinessProbe | nindent 10 }}
resources:
{{- toYaml .Values.tei.embedding.resources | nindent 10 }}
volumeMounts:
- name: model-cache
mountPath: /data
volumes:
- name: model-cache
emptyDir: {}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
@@ -1,17 +0,0 @@
{{- if .Values.tei.embedding.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "hindsight.fullname" . }}-tei-embedding
labels:
{{- include "hindsight.tei.embedding.labels" . | nindent 4 }}
spec:
type: ClusterIP
ports:
- port: {{ .Values.tei.embedding.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "hindsight.tei.embedding.selectorLabels" . | nindent 4 }}
{{- end }}
@@ -1,76 +0,0 @@
{{- if .Values.tei.reranker.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "hindsight.fullname" . }}-tei-reranker
labels:
{{- include "hindsight.tei.reranker.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.tei.reranker.replicaCount }}
selector:
matchLabels:
{{- include "hindsight.tei.reranker.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "hindsight.tei.reranker.selectorLabels" . | nindent 8 }}
spec:
{{- if .Values.serviceAccount.create }}
serviceAccountName: {{ include "hindsight.serviceAccountName" . }}
{{- end }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: tei-reranker
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.tei.reranker.image.repository }}:{{ .Values.tei.reranker.image.tag }}"
imagePullPolicy: {{ .Values.tei.reranker.image.pullPolicy }}
args:
- "--model-id"
- {{ .Values.tei.reranker.model | quote }}
- "--hostname"
- "0.0.0.0"
{{- range .Values.tei.reranker.args }}
- {{ . | quote }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.tei.reranker.port }}
protocol: TCP
env:
- name: PORT
value: {{ .Values.tei.reranker.port | quote }}
{{- range $key, $value := .Values.tei.reranker.env }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
livenessProbe:
{{- toYaml .Values.tei.reranker.livenessProbe | nindent 10 }}
readinessProbe:
{{- toYaml .Values.tei.reranker.readinessProbe | nindent 10 }}
resources:
{{- toYaml .Values.tei.reranker.resources | nindent 10 }}
volumeMounts:
- name: model-cache
mountPath: /data
volumes:
- name: model-cache
emptyDir: {}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
@@ -1,17 +0,0 @@
{{- if .Values.tei.reranker.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "hindsight.fullname" . }}-tei-reranker
labels:
{{- include "hindsight.tei.reranker.labels" . | nindent 4 }}
spec:
type: ClusterIP
ports:
- port: {{ .Values.tei.reranker.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "hindsight.tei.reranker.selectorLabels" . | nindent 4 }}
{{- end }}
@@ -32,7 +32,7 @@ spec:
- name: worker
securityContext:
{{- toYaml .Values.securityContext | nindent 10 }}
image: "{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag | default .Values.version | default .Chart.AppVersion }}"
image: "{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag | default .Values.version }}"
imagePullPolicy: {{ .Values.worker.image.pullPolicy }}
command: ["hindsight-worker"]
ports:
@@ -99,7 +99,7 @@ spec:
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with (.Values.worker.affinity | default .Values.affinity) }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
+4 -110
View File
@@ -1,8 +1,7 @@
# Default values for hindsight
# Global version override - use this to set a consistent image tag across all components
# If not set, defaults to Chart.appVersion from Chart.yaml
# version: ""
# 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
@@ -58,15 +57,6 @@ api:
timeoutSeconds: 3
failureThreshold: 3
# Pod disruption budget
podDisruptionBudget:
enabled: false
minAvailable: 1
# maxUnavailable: 1
# Pod affinity/anti-affinity (overrides global affinity for this component)
# affinity: {}
# Environment variables
env:
#HINDSIGHT_API_LLM_PROVIDER: "groq"
@@ -85,7 +75,7 @@ worker:
image:
repository: ghcr.io/vectorize-io/hindsight-api
pullPolicy: IfNotPresent
# tag: "" # defaults to .Values.version, then Chart.appVersion if not specified
# tag defaults to .Values.version if not specified
service:
# Service for metrics scraping (headless for StatefulSet)
@@ -131,15 +121,6 @@ worker:
# HTTP port for metrics/health (matches service.targetPort)
HINDSIGHT_API_WORKER_HTTP_PORT: "8889"
# Pod disruption budget
podDisruptionBudget:
enabled: false
minAvailable: 1
# maxUnavailable: 1
# Pod affinity/anti-affinity (overrides global affinity for this component)
# affinity: {}
# Secret environment variables (inherited from api.secrets if not specified)
secrets: {}
@@ -183,15 +164,6 @@ controlPlane:
timeoutSeconds: 3
failureThreshold: 3
# Pod disruption budget
podDisruptionBudget:
enabled: false
minAvailable: 1
# maxUnavailable: 1
# Pod affinity/anti-affinity (overrides global affinity for this component)
# affinity: {}
# Environment variables
env:
NODE_ENV: "production"
@@ -290,87 +262,9 @@ nodeSelector: {}
# Tolerations
tolerations: []
# Affinity (applied to all components unless overridden per-component)
# Affinity
affinity: {}
# TEI (Text Embeddings Inference) - optional standalone deployments
# for reranking and/or embedding models
tei:
reranker:
enabled: false
replicaCount: 1
image:
repository: ghcr.io/huggingface/text-embeddings-inference
tag: cpu-1.8.3
pullPolicy: IfNotPresent
model: "cross-encoder/ms-marco-MiniLM-L-6-v2"
port: 8090
args:
- "--auto-truncate"
env:
PAYLOAD_LIMIT: "10000000"
MAX_CLIENT_BATCH_SIZE: "256"
resources:
limits:
cpu: 2000m
memory: 2Gi
requests:
cpu: 500m
memory: 1Gi
livenessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
readinessProbe:
httpGet:
path: /health
port: 8090
initialDelaySeconds: 15
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
embedding:
enabled: false
replicaCount: 1
image:
repository: ghcr.io/huggingface/text-embeddings-inference
tag: cpu-1.8.3
pullPolicy: IfNotPresent
model: "sentence-transformers/all-MiniLM-L6-v2"
port: 8091
args: []
env:
PAYLOAD_LIMIT: "10000000"
MAX_CLIENT_BATCH_SIZE: "256"
resources:
limits:
cpu: 2000m
memory: 2Gi
requests:
cpu: 500m
memory: 1Gi
livenessProbe:
httpGet:
path: /health
port: 8091
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 6
readinessProbe:
httpGet:
path: /health
port: 8091
initialDelaySeconds: 15
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
# Autoscaling
autoscaling:
enabled: false
+1 -1
View File
@@ -46,4 +46,4 @@ __all__ = [
"RemoteTEICrossEncoder",
"LLMConfig",
]
__version__ = "0.4.15"
__version__ = "0.1.0"
@@ -6,13 +6,11 @@ Create Date: 2025-11-27 11:54:19.228030
"""
import os
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from pgvector.sqlalchemy import Vector
from sqlalchemy import text
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
@@ -22,114 +20,11 @@ branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _detect_vector_extension() -> str:
"""
Detect or validate vector extension: 'pgvector', 'vchord', or 'pgvectorscale'.
Respects HINDSIGHT_API_VECTOR_EXTENSION env var if set.
"""
conn = op.get_bind()
vector_extension = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
# Validate configured extension is installed
if vector_extension == "pgvectorscale":
# pgvectorscale/DiskANN requires pgvector
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"DiskANN requires pgvector. Install with: CREATE EXTENSION vector; then vectorscale or pg_diskann CASCADE;"
)
# Check for either vectorscale (open source) or pg_diskann (Azure)
vectorscale_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")).scalar()
pg_diskann_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_diskann'")).scalar()
if vectorscale_check:
return "pgvectorscale"
elif pg_diskann_check:
return "pg_diskann"
else:
raise RuntimeError(
"Configured vector extension 'pgvectorscale' not found. Install either:\n"
" - pgvectorscale: CREATE EXTENSION vectorscale CASCADE;\n"
" - pg_diskann (Azure): CREATE EXTENSION pg_diskann CASCADE;"
)
elif vector_extension == "vchord":
vchord_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vchord'")).scalar()
if not vchord_check:
raise RuntimeError(
"Configured vector extension 'vchord' not found. Install it with: CREATE EXTENSION vchord CASCADE;"
)
return "vchord"
elif vector_extension == "pgvector":
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"Configured vector extension 'pgvector' not found. Install it with: CREATE EXTENSION vector;"
)
return "pgvector"
else:
raise ValueError(
f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {vector_extension}. Must be 'pgvector', 'vchord', or 'pgvectorscale'"
)
def _detect_text_search_extension() -> str:
"""
Detect or validate text search extension: 'native', 'vchord', or 'pg_textsearch'.
Respects HINDSIGHT_API_TEXT_SEARCH_EXTENSION env var.
Creates the extension if needed.
"""
text_search_extension = os.getenv("HINDSIGHT_API_TEXT_SEARCH_EXTENSION", "native").lower()
if text_search_extension == "vchord":
# Create vchord_bm25 extension if not exists
try:
op.execute("CREATE EXTENSION IF NOT EXISTS vchord_bm25 CASCADE")
except Exception:
# Extension might already exist or user lacks permissions - verify it exists
conn = op.get_bind()
result = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vchord_bm25'")).fetchone()
if not result:
# Extension truly doesn't exist - re-raise the error
raise
return "vchord"
elif text_search_extension == "pg_textsearch":
# Create pg_textsearch extension if not exists
try:
op.execute("CREATE EXTENSION IF NOT EXISTS pg_textsearch CASCADE")
except Exception:
# Extension might already exist or user lacks permissions - verify it exists
conn = op.get_bind()
result = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_textsearch'")).fetchone()
if not result:
# Extension truly doesn't exist - re-raise the error
raise
return "pg_textsearch"
elif text_search_extension == "native":
return "native"
else:
raise ValueError(
f"Invalid HINDSIGHT_API_TEXT_SEARCH_EXTENSION: {text_search_extension}. Must be 'native', 'vchord', or 'pg_textsearch'"
)
def upgrade() -> None:
"""Upgrade schema - create all tables from scratch."""
# Note: pgvector extension is installed globally BEFORE migrations run
# See migrations.py:run_migrations() - this ensures the extension is available
# to all schemas, not just the one being migrated
# We keep this here as a fallback for backwards compatibility
# This may fail if user lacks permissions, which is fine if extension already exists
try:
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
except Exception:
# Extension might already exist or user lacks permissions - verify it exists
conn = op.get_bind()
result = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).fetchone()
if not result:
# Extension truly doesn't exist - re-raise the error
raise
# Enable required extensions
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
# Create banks table
op.create_table(
@@ -257,29 +152,11 @@ def upgrade() -> None:
)
# Add search_vector column for full-text search
# Type depends on configured text search backend
text_search_ext = _detect_text_search_extension()
if text_search_ext == "vchord":
# VectorChord BM25: bm25vector type (no GENERATED - tokenization happens on INSERT)
# Note: vchord_bm25 extension creates types in bm25_catalog schema
op.execute("""
ALTER TABLE memory_units
ADD COLUMN search_vector bm25_catalog.bm25vector
""")
elif text_search_ext == "pg_textsearch":
# Timescale pg_textsearch: dummy TEXT column for consistency (indexes operate on base columns directly)
op.execute("""
ALTER TABLE memory_units
ADD COLUMN search_vector TEXT
""")
else: # native
# Native PostgreSQL: tsvector with automatic generation
op.execute("""
ALTER TABLE memory_units
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', COALESCE(text, '') || ' ' || COALESCE(context, ''))) STORED
""")
op.execute("""
ALTER TABLE memory_units
ADD COLUMN search_vector tsvector
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"])
@@ -309,61 +186,19 @@ def upgrade() -> None:
["bank_id", sa.text("event_date DESC")],
postgresql_where=sa.text("fact_type = 'observation'"),
)
# Create vector index - conditional based on available extension
vector_ext = _detect_vector_extension()
op.create_index(
"idx_memory_units_embedding",
"memory_units",
["embedding"],
postgresql_using="hnsw",
postgresql_ops={"embedding": "vector_cosine_ops"},
)
if vector_ext == "pgvectorscale":
# Use DiskANN index for pgvectorscale (disk-based, scalable)
op.execute("""
CREATE INDEX idx_memory_units_embedding ON memory_units
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
""")
elif vector_ext == "pg_diskann":
# Use DiskANN index for pg_diskann (Azure)
op.execute("""
CREATE INDEX idx_memory_units_embedding ON memory_units
USING diskann (embedding vector_cosine_ops)
WITH (max_neighbors = 50)
""")
elif vector_ext == "vchord":
# Use vchordrq index for vchord (supports high-dimensional embeddings)
op.execute("""
CREATE INDEX idx_memory_units_embedding ON memory_units
USING vchordrq (embedding vector_l2_ops)
""")
else: # pgvector
# Use HNSW index for pgvector
op.create_index(
"idx_memory_units_embedding",
"memory_units",
["embedding"],
postgresql_using="hnsw",
postgresql_ops={"embedding": "vector_cosine_ops"},
)
# Create full-text search index on search_vector
# Index type depends on text search backend
if text_search_ext == "vchord":
# VectorChord BM25 index
op.execute("""
CREATE INDEX idx_memory_units_text_search ON memory_units
USING bm25 (search_vector bm25_catalog.bm25_ops)
""")
elif text_search_ext == "pg_textsearch":
# Timescale pg_textsearch BM25 index on text column
# Note: pg_textsearch doesn't support expressions, so we index the main text column
op.execute("""
CREATE INDEX idx_memory_units_text_search ON memory_units
USING bm25(text)
WITH (text_config='english')
""")
else: # native
# Native PostgreSQL GIN index
op.execute("""
CREATE INDEX idx_memory_units_text_search ON memory_units
USING gin(search_vector)
""")
# Create BM25 full-text search index on search_vector
op.execute("""
CREATE INDEX idx_memory_units_text_search ON memory_units
USING gin(search_vector)
""")
op.execute("""
CREATE MATERIALIZED VIEW memory_units_bm25 AS
@@ -1,70 +0,0 @@
"""Add file_storage table for BYTEA-based file storage
Revision ID: a1b2c3d4e5f6
Revises: y0t1u2v3w4x5
Create Date: 2026-02-16
Creates a dedicated table for storing uploaded files using BYTEA.
This provides zero-config file storage that "just works" for development
and small deployments. For production/scale, use S3-compatible storage.
Files are stored in a separate table to avoid bloating the documents table.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "a1b2c3d4e5f6"
down_revision: str | Sequence[str] | None = "y0t1u2v3w4x5"
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 (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Create file_storage table for BYTEA storage."""
schema = _get_schema_prefix()
# Create file_storage table (minimal: just key + data)
op.execute(
f"""
CREATE TABLE {schema}file_storage (
storage_key TEXT PRIMARY KEY,
data BYTEA NOT NULL
)
"""
)
# Add file tracking columns to documents table
op.execute(
f"""
ALTER TABLE {schema}documents
ADD COLUMN IF NOT EXISTS file_storage_key TEXT,
ADD COLUMN IF NOT EXISTS file_original_name TEXT,
ADD COLUMN IF NOT EXISTS file_content_type TEXT
"""
)
def downgrade() -> None:
"""Remove file_storage table and related columns."""
schema = _get_schema_prefix()
# Drop columns from documents table
op.execute(
f"""
ALTER TABLE {schema}documents
DROP COLUMN IF EXISTS file_storage_key,
DROP COLUMN IF EXISTS file_original_name,
DROP COLUMN IF EXISTS file_content_type
"""
)
# Drop file_storage table
op.execute(f"DROP TABLE IF EXISTS {schema}file_storage")
@@ -1,88 +0,0 @@
"""Add text_signals column to memory_units for enriched BM25 indexing.
text_signals stores a denormalized space-separated string of entity names
(and future signals) to improve full-text search recall without polluting
the stored fact text.
- vchord: text_signals included in tokenize() at insert time
- native: search_vector GENERATED column regenerated to include text_signals
- pg_textsearch: no change (index only supports a single base column)
Revision ID: a2b3c4d5e6f7
Revises: z1u2v3w4x5y6
Create Date: 2026-02-28
"""
import os
from collections.abc import Sequence
from alembic import context, op
revision: str = "a2b3c4d5e6f7"
down_revision: str | Sequence[str] | None = "aa2b3c4d5e6f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _detect_text_search_extension() -> str:
return os.getenv("HINDSIGHT_API_TEXT_SEARCH_EXTENSION", "native").lower()
def upgrade() -> None:
schema = _get_schema_prefix()
table = f"{schema}memory_units"
text_search_ext = _detect_text_search_extension()
# Add text_signals column (nullable TEXT, populated at retain time)
op.execute(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS text_signals TEXT")
if text_search_ext == "native":
# Native PostgreSQL: drop and recreate the GENERATED tsvector column to include text_signals
op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS search_vector")
op.execute(f"""
ALTER TABLE {table}
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english',
COALESCE(text, '') || ' ' ||
COALESCE(context, '') || ' ' ||
COALESCE(text_signals, '')
)
) STORED
""")
# Recreate GIN index (was dropped with the column)
op.execute(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_text_search
ON {table} USING gin(search_vector)
""")
# vchord: tokenize() call in fact_storage.py is updated to include text_signals at insert time
# pg_textsearch: no change — index operates on the base `text` column only
def downgrade() -> None:
schema = _get_schema_prefix()
table = f"{schema}memory_units"
text_search_ext = _detect_text_search_extension()
if text_search_ext == "native":
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_text_search")
op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS search_vector")
op.execute(f"""
ALTER TABLE {table}
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english', COALESCE(text, '') || ' ' || COALESCE(context, ''))
) STORED
""")
op.execute(f"""
CREATE INDEX idx_memory_units_text_search
ON {table} USING gin(search_vector)
""")
op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS text_signals")
@@ -1,36 +0,0 @@
"""Make event_date nullable in memory_units to support timestamp-free content
Revision ID: aa2b3c4d5e6f
Revises: z1u2v3w4x5y6
Create Date: 2026-03-02
When callers retain content without a timestamp (e.g. fictional documents, static text),
the event_date column should be allowed to be NULL rather than defaulting to utcnow().
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "aa2b3c4d5e6f"
down_revision: str | Sequence[str] | None = "z1u2v3w4x5y6"
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 (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}memory_units ALTER COLUMN event_date DROP NOT NULL")
def downgrade() -> None:
schema = _get_schema_prefix()
# Backfill NULLs with now() before restoring the NOT NULL constraint
op.execute(f"UPDATE {schema}memory_units SET event_date = now() WHERE event_date IS NULL")
op.execute(f"ALTER TABLE {schema}memory_units ALTER COLUMN event_date SET NOT NULL")
@@ -1,68 +0,0 @@
"""Add partial indexes on memory_units temporal date fields for fast temporal retrieval
Revision ID: b3c4d5e6f7g8
Revises: c1a2b3d4e5f6
Create Date: 2026-03-02
The temporal retrieval entry-point query filters memory_units by occurred_start,
occurred_end, and mentioned_at using OR conditions. Without dedicated indexes the
planner falls back to a sequential scan of all bank rows after applying the
(bank_id, fact_type) index, then re-checks each date field.
These three partial indexes give the planner bitmap-index scan options for the
three most common date predicates, dramatically reducing the row set before any
embedding computation is required.
All indexes are created CONCURRENTLY so the migration does not block writes on
memory_units during production deployments. CONCURRENTLY requires running outside
a transaction block; see migrations.py for how this is handled safely.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "b3c4d5e6f7g8"
down_revision: str | Sequence[str] | None = "c1a2b3d4e5f6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# Partial index on occurred_start (covers "occurred_start BETWEEN $4 AND $5")
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_units_bank_occurred_start "
f"ON {schema}memory_units(bank_id, fact_type, occurred_start) "
f"WHERE occurred_start IS NOT NULL"
)
# Partial index on occurred_end (covers "occurred_end BETWEEN $4 AND $5")
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_units_bank_occurred_end "
f"ON {schema}memory_units(bank_id, fact_type, occurred_end) "
f"WHERE occurred_end IS NOT NULL"
)
# Partial index on mentioned_at (covers "mentioned_at BETWEEN $4 AND $5")
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_units_bank_mentioned_at "
f"ON {schema}memory_units(bank_id, fact_type, mentioned_at) "
f"WHERE mentioned_at IS NOT NULL"
)
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_units_bank_mentioned_at")
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_units_bank_occurred_end")
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_units_bank_occurred_start")
@@ -1,34 +0,0 @@
"""Backfill observation_scopes column if missing.
This migration ensures observation_scopes exists even on databases that had
revision z1u2v3w4x5y6 applied when it referred to the old text_signals migration
(before it was renamed to a2b3c4d5e6f7). The ADD COLUMN IF NOT EXISTS makes this
a no-op on databases that already have the column.
Revision ID: b4c5d6e7f8a9
Revises: a2b3c4d5e6f7
Create Date: 2026-03-02
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "b4c5d6e7f8a9"
down_revision: str | Sequence[str] | None = "a2b3c4d5e6f7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}memory_units ADD COLUMN IF NOT EXISTS observation_scopes JSONB")
def downgrade() -> None:
pass # intentionally no-op — safe to leave the column in place
@@ -1,46 +0,0 @@
"""Enable pg_trgm extension and add GIN trigram index on entities.canonical_name
Revision ID: c1a2b3d4e5f6
Revises: b4c5d6e7f8a9
Create Date: 2026-03-02
Index is created CONCURRENTLY so the migration does not block writes on entities
during production deployments. CONCURRENTLY requires running outside a transaction
block; see migrations.py for how this is handled safely.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "c1a2b3d4e5f6"
down_revision: str | Sequence[str] | None = "b4c5d6e7f8a9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
# pg_trgm ships with every standard PostgreSQL installation as a contrib module.
# It enables fast similarity lookups via GIN indexes, used for entity name matching.
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
schema = _get_schema_prefix()
# GIN index on canonical_name enables sub-millisecond trigram similarity queries
# (% operator, similarity()) instead of full-table scans across all bank entities.
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS entities_canonical_name_trgm_idx "
f"ON {schema}entities USING GIN (canonical_name gin_trgm_ops)"
)
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}entities_canonical_name_trgm_idx")
# Note: not dropping pg_trgm extension as other indexes may depend on it
@@ -1,83 +0,0 @@
"""Add covering and composite indexes to speed up link expansion graph retrieval.
Two indexes target the two bottlenecks identified by EXPLAIN ANALYZE on a 17M-row
memory_links table:
1. idx_memory_links_to_type_weight (to_unit_id, link_type, weight DESC)
The semantic incoming direction — finding facts that consider seeds as their
nearest neighbour — currently hits an expensive BitmapAnd of two separate
bitmap scans (to_unit_id bitmap ∩ link_type bitmap). A composite index
on (to_unit_id, link_type) turns this into a single index scan and reduces
latency from ~36 ms to < 5 ms per query.
2. idx_memory_links_entity_covering (from_unit_id) INCLUDE (to_unit_id, entity_id)
WHERE link_type = 'entity'
The entity co-occurrence expansion uses COUNT(DISTINCT ml.entity_id) and
joins on ml.to_unit_id. Without a covering index the planner must read
~2 500 heap pages to fetch entity_id and to_unit_id after the bitmap index
scan, adding ~230 ms of random I/O. INCLUDE adds those two columns to the
index leaf pages so the entire query can be served from the index (index-only
scan), eliminating the heap reads entirely.
Partial index (WHERE link_type = 'entity') keeps index size ~40 % smaller.
Both indexes are created with CONCURRENTLY so the migration does not block
concurrent reads or writes on memory_links. CONCURRENTLY requires running
outside a transaction block, so the migration emits an explicit COMMIT before
each statement and uses IF NOT EXISTS for idempotency.
Revision ID: d2e3f4a5b6c7
Revises: b3c4d5e6f7g8
Create Date: 2026-03-02
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "d2e3f4a5b6c7"
down_revision: str | Sequence[str] | None = "b3c4d5e6f7g8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# CREATE INDEX CONCURRENTLY cannot run inside a transaction block.
# Commit the current Alembic transaction, then issue each CONCURRENTLY
# statement in its own implicit autocommit transaction.
# IF NOT EXISTS makes each statement idempotent if the migration is retried.
# Index for the semantic *incoming* direction in link_expansion_retrieval.py.
# Replaces the BitmapAnd of idx_memory_links_to_unit ∩ idx_memory_links_link_type
# with a single composite index scan.
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_links_to_type_weight "
f"ON {schema}memory_links(to_unit_id, link_type, weight DESC)"
)
# Covering index for entity co-occurrence expansion.
# Enables an index-only scan: entity_id and to_unit_id are read from the
# index leaf pages instead of the heap, eliminating ~2 500 random heap-page
# reads per expansion query.
op.execute("COMMIT")
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_memory_links_entity_covering "
f"ON {schema}memory_links(from_unit_id) "
f"INCLUDE (to_unit_id, entity_id) "
f"WHERE link_type = 'entity'"
)
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_links_entity_covering")
op.execute("COMMIT")
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}idx_memory_links_to_type_weight")
@@ -1,41 +0,0 @@
"""mental_model_id_to_text
Revision ID: m8h9i0j1k2l3
Revises: l7g8h9i0j1k2
Create Date: 2026-01-19 00:00:00.000000
This migration changes the mental_models.id column from VARCHAR(64) to TEXT
to support longer model IDs (e.g., entity names that exceed 64 characters).
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "m8h9i0j1k2l3"
down_revision: str | Sequence[str] | None = "l7g8h9i0j1k2"
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 (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Change mental_models.id from VARCHAR(64) to TEXT."""
schema = _get_schema_prefix()
# Alter the id column type from VARCHAR(64) to TEXT
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE TEXT")
def downgrade() -> None:
"""Revert mental_models.id from TEXT to VARCHAR(64)."""
schema = _get_schema_prefix()
# Note: This may fail if any id values exceed 64 characters
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE VARCHAR(64)")
@@ -1,317 +0,0 @@
"""learnings_and_pinned_reflections
Revision ID: n9i0j1k2l3m4
Revises: m8h9i0j1k2l3
Create Date: 2026-01-21 00:00:00.000000
This migration:
1. Creates the 'learnings' table for automatic bottom-up consolidation
2. Creates the 'pinned_reflections' table for user-curated living documents
3. Adds consolidation tracking columns to the 'banks' table
"""
import os
from collections.abc import Sequence
from alembic import context, op
from sqlalchemy import text
# revision identifiers, used by Alembic.
revision: str = "n9i0j1k2l3m4"
down_revision: str | Sequence[str] | None = "m8h9i0j1k2l3"
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 (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _detect_vector_extension() -> str:
"""
Detect or validate vector extension: 'pgvector', 'vchord', or 'pgvectorscale'.
Respects HINDSIGHT_API_VECTOR_EXTENSION env var if set.
"""
conn = op.get_bind()
vector_extension = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
# Validate configured extension is installed
if vector_extension == "pgvectorscale":
# pgvectorscale/DiskANN requires pgvector
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"DiskANN requires pgvector. Install with: CREATE EXTENSION vector; then vectorscale or pg_diskann CASCADE;"
)
# Check for either vectorscale (open source) or pg_diskann (Azure)
vectorscale_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")).scalar()
pg_diskann_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_diskann'")).scalar()
if vectorscale_check:
return "pgvectorscale"
elif pg_diskann_check:
return "pg_diskann"
else:
raise RuntimeError(
"Configured vector extension 'pgvectorscale' not found. Install either:\n"
" - pgvectorscale: CREATE EXTENSION vectorscale CASCADE;\n"
" - pg_diskann (Azure): CREATE EXTENSION pg_diskann CASCADE;"
)
elif vector_extension == "vchord":
vchord_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vchord'")).scalar()
if not vchord_check:
raise RuntimeError(
"Configured vector extension 'vchord' not found. Install it with: CREATE EXTENSION vchord CASCADE;"
)
return "vchord"
elif vector_extension == "pgvector":
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
if not pgvector_check:
raise RuntimeError(
"Configured vector extension 'pgvector' not found. Install it with: CREATE EXTENSION vector;"
)
return "pgvector"
else:
raise ValueError(
f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {vector_extension}. Must be 'pgvector', 'vchord', or 'pgvectorscale'"
)
def _detect_text_search_extension() -> str:
"""
Detect or validate text search extension: 'native', 'vchord', or 'pg_textsearch'.
Respects HINDSIGHT_API_TEXT_SEARCH_EXTENSION env var.
Creates the extension if needed.
"""
text_search_extension = os.getenv("HINDSIGHT_API_TEXT_SEARCH_EXTENSION", "native").lower()
if text_search_extension == "vchord":
# Create vchord_bm25 extension if not exists
try:
op.execute("CREATE EXTENSION IF NOT EXISTS vchord_bm25 CASCADE")
except Exception:
# Extension might already exist or user lacks permissions - verify it exists
conn = op.get_bind()
result = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vchord_bm25'")).fetchone()
if not result:
# Extension truly doesn't exist - re-raise the error
raise
return "vchord"
elif text_search_extension == "pg_textsearch":
# Create pg_textsearch extension if not exists
try:
op.execute("CREATE EXTENSION IF NOT EXISTS pg_textsearch CASCADE")
except Exception:
# Extension might already exist or user lacks permissions - verify it exists
conn = op.get_bind()
result = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_textsearch'")).fetchone()
if not result:
# Extension truly doesn't exist - re-raise the error
raise
return "pg_textsearch"
elif text_search_extension == "native":
return "native"
else:
raise ValueError(
f"Invalid HINDSIGHT_API_TEXT_SEARCH_EXTENSION: {text_search_extension}. Must be 'native', 'vchord', or 'pg_textsearch'"
)
def upgrade() -> None:
"""Create learnings and pinned_reflections tables."""
schema = _get_schema_prefix()
# Detect which vector extension is available
vector_ext = _detect_vector_extension()
# Detect which text search extension to use
text_search_ext = _detect_text_search_extension()
# 1. Create learnings table
op.execute(f"""
CREATE TABLE {schema}learnings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bank_id VARCHAR(64) NOT NULL,
text TEXT NOT NULL,
proof_count INT NOT NULL DEFAULT 1,
history JSONB DEFAULT '[]'::jsonb,
mission_context VARCHAR(64),
pre_mission_change BOOLEAN DEFAULT FALSE,
embedding vector(384),
tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
""")
# Add foreign key constraint
op.execute(f"""
ALTER TABLE {schema}learnings
ADD CONSTRAINT fk_learnings_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
# Indexes for learnings
op.execute(f"CREATE INDEX idx_learnings_bank_id ON {schema}learnings(bank_id)")
# Create vector index based on detected extension
if vector_ext == "pgvectorscale":
op.execute(f"""
CREATE INDEX idx_learnings_embedding ON {schema}learnings
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
""")
elif vector_ext == "pg_diskann":
op.execute(f"""
CREATE INDEX idx_learnings_embedding ON {schema}learnings
USING diskann (embedding vector_cosine_ops)
WITH (max_neighbors = 50)
""")
elif vector_ext == "vchord":
op.execute(f"""
CREATE INDEX idx_learnings_embedding ON {schema}learnings
USING vchordrq (embedding vector_l2_ops)
""")
else: # pgvector
op.execute(f"""
CREATE INDEX idx_learnings_embedding ON {schema}learnings
USING hnsw (embedding vector_cosine_ops)
""")
op.execute(f"CREATE INDEX idx_learnings_tags ON {schema}learnings USING GIN(tags)")
# Full-text search for learnings
if text_search_ext == "vchord":
# VectorChord BM25: bm25vector type (no GENERATED - tokenization happens on INSERT)
# Note: vchord_bm25 extension creates types in bm25_catalog schema
op.execute(f"""
ALTER TABLE {schema}learnings ADD COLUMN search_vector bm25_catalog.bm25vector
""")
op.execute(f"""
CREATE INDEX idx_learnings_text_search ON {schema}learnings
USING bm25 (search_vector bm25_catalog.bm25_ops)
""")
elif text_search_ext == "pg_textsearch":
# Timescale pg_textsearch: dummy TEXT column for consistency (indexes operate on base columns directly)
op.execute(f"""
ALTER TABLE {schema}learnings ADD COLUMN search_vector TEXT
""")
op.execute(f"""
CREATE INDEX idx_learnings_text_search ON {schema}learnings
USING bm25(text) WITH (text_config='english')
""")
else: # native
# Native PostgreSQL: tsvector with automatic generation
op.execute(f"""
ALTER TABLE {schema}learnings ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', text)) STORED
""")
op.execute(f"CREATE INDEX idx_learnings_text_search ON {schema}learnings USING gin(search_vector)")
# 2. Create pinned_reflections table
op.execute(f"""
CREATE TABLE {schema}pinned_reflections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bank_id VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
source_query TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(384),
tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
last_refreshed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
""")
# Add foreign key constraint
op.execute(f"""
ALTER TABLE {schema}pinned_reflections
ADD CONSTRAINT fk_pinned_reflections_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
# Indexes for pinned_reflections
op.execute(f"CREATE INDEX idx_pinned_reflections_bank_id ON {schema}pinned_reflections(bank_id)")
# Create vector index based on detected extension
if vector_ext == "pgvectorscale":
op.execute(f"""
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
USING diskann (embedding vector_cosine_ops)
WITH (num_neighbors = 50)
""")
elif vector_ext == "pg_diskann":
op.execute(f"""
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
USING diskann (embedding vector_cosine_ops)
WITH (max_neighbors = 50)
""")
elif vector_ext == "vchord":
op.execute(f"""
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
USING vchordrq (embedding vector_l2_ops)
""")
else: # pgvector
op.execute(f"""
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
USING hnsw (embedding vector_cosine_ops)
""")
op.execute(f"CREATE INDEX idx_pinned_reflections_tags ON {schema}pinned_reflections USING GIN(tags)")
# Full-text search for pinned_reflections
if text_search_ext == "vchord":
# VectorChord BM25: bm25vector type (no GENERATED - tokenization happens on INSERT/UPDATE)
# Note: vchord_bm25 extension creates types in bm25_catalog schema
op.execute(f"""
ALTER TABLE {schema}pinned_reflections ADD COLUMN search_vector bm25_catalog.bm25vector
""")
op.execute(f"""
CREATE INDEX idx_pinned_reflections_text_search ON {schema}pinned_reflections
USING bm25 (search_vector bm25_catalog.bm25_ops)
""")
elif text_search_ext == "pg_textsearch":
# Timescale pg_textsearch: dummy TEXT column for consistency (indexes operate on base columns directly)
op.execute(f"""
ALTER TABLE {schema}pinned_reflections ADD COLUMN search_vector TEXT
""")
op.execute(f"""
CREATE INDEX idx_pinned_reflections_text_search ON {schema}pinned_reflections
USING bm25(content)
WITH (text_config='english')
""")
else: # native
# Native PostgreSQL: tsvector with automatic generation
op.execute(f"""
ALTER TABLE {schema}pinned_reflections ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('english', COALESCE(name, '') || ' ' || content)) STORED
""")
op.execute(f"""
CREATE INDEX idx_pinned_reflections_text_search ON {schema}pinned_reflections
USING gin(search_vector)
""")
# 3. Add consolidation tracking columns to banks table
op.execute(f"""
ALTER TABLE {schema}banks
ADD COLUMN IF NOT EXISTS last_consolidated_at TIMESTAMP WITH TIME ZONE
""")
op.execute(f"""
ALTER TABLE {schema}banks
ADD COLUMN IF NOT EXISTS mission_changed_at TIMESTAMP WITH TIME ZONE
""")
def downgrade() -> None:
"""Drop learnings and pinned_reflections tables."""
schema = _get_schema_prefix()
# Drop tables
op.execute(f"DROP TABLE IF EXISTS {schema}learnings CASCADE")
op.execute(f"DROP TABLE IF EXISTS {schema}pinned_reflections CASCADE")
# Remove columns from banks
op.execute(f"ALTER TABLE {schema}banks DROP COLUMN IF EXISTS last_consolidated_at")
op.execute(f"ALTER TABLE {schema}banks DROP COLUMN IF EXISTS mission_changed_at")
@@ -1,113 +0,0 @@
"""migrate_mental_models_data
Revision ID: o0j1k2l3m4n5
Revises: n9i0j1k2l3m4
Create Date: 2026-01-21 00:00:00.000000
This migration:
1. Migrates existing 'pinned' mental models to the new 'pinned_reflections' table
2. Migrates existing 'learned' mental models to the new 'learnings' table
3. Deletes non-directive mental models (structural, emergent, pinned, learned)
4. Drops the mental_model_versions table (no longer used)
5. Adds a CHECK constraint that only 'directive' subtype is allowed
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "o0j1k2l3m4n5"
down_revision: str | Sequence[str] | None = "n9i0j1k2l3m4"
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 (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Migrate data and clean up old mental models."""
schema = _get_schema_prefix()
# 1. Migrate 'pinned' mental models to pinned_reflections
# For pinned models, the first observation's content becomes the pinned reflection content
op.execute(f"""
INSERT INTO {schema}pinned_reflections (bank_id, name, source_query, content, tags, created_at)
SELECT
bank_id,
name,
description AS source_query,
COALESCE(
observations->'observations'->0->>'content',
description,
''
) AS content,
tags,
created_at
FROM {schema}mental_models
WHERE subtype = 'pinned'
ON CONFLICT DO NOTHING
""")
# 2. Migrate 'learned' mental models to learnings
# Each observation in a learned model becomes a separate learning
op.execute(f"""
INSERT INTO {schema}learnings (bank_id, text, proof_count, tags, created_at)
SELECT
mm.bank_id,
obs->>'content' AS text,
GREATEST(1, COALESCE(jsonb_array_length(obs->'evidence'), 1)) AS proof_count,
mm.tags,
mm.created_at
FROM {schema}mental_models mm,
LATERAL jsonb_array_elements(mm.observations->'observations') AS obs
WHERE mm.subtype = 'learned'
AND obs->>'content' IS NOT NULL
AND obs->>'content' != ''
ON CONFLICT DO NOTHING
""")
# 3. Delete all non-directive mental models (they've been migrated or are obsolete)
op.execute(f"""
DELETE FROM {schema}mental_models
WHERE subtype != 'directive'
""")
# 4. Drop the mental_model_versions table (no longer used)
op.execute(f"DROP TABLE IF EXISTS {schema}mental_model_versions CASCADE")
# 5. Drop old constraints and add new one that only allows 'directive'
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype")
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD CONSTRAINT ck_mental_models_subtype CHECK (subtype = 'directive')
""")
def downgrade() -> None:
"""Reverse the migration (data migration is one-way, so this just removes constraints)."""
schema = _get_schema_prefix()
# Remove the directive-only constraint
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype")
# Re-create mental_model_versions table
op.execute(f"""
CREATE TABLE IF NOT EXISTS {schema}mental_model_versions (
id SERIAL PRIMARY KEY,
bank_id VARCHAR(64) NOT NULL,
model_id VARCHAR(128) NOT NULL,
version INT NOT NULL,
observations JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
""")
op.execute(
f"CREATE INDEX IF NOT EXISTS idx_mm_versions_lookup ON {schema}mental_model_versions(bank_id, model_id, version DESC)"
)
# Note: Data migration cannot be reversed - pinned_reflections and learnings data remains
@@ -1,194 +0,0 @@
"""new_knowledge_architecture
Revision ID: p1k2l3m4n5o6
Revises: o0j1k2l3m4n5
Create Date: 2026-01-21 00:00:00.000000
This migration implements the new knowledge architecture:
1. Drops the 'learnings' table (mental models are now in memory_units)
2. Renames 'pinned_reflections' to 'reflections'
3. Drops the 'mental_models' table completely
4. Creates 'directives' table for hard rules
5. Adds mental model support columns to 'memory_units' (proof_count, source_memory_ids, history)
The new architecture:
- Directives: Hard rules in their own table
- Mental Models: Stored in memory_units with fact_type='mental_model'
- Reflections: User-curated documents (renamed from pinned_reflections)
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "p1k2l3m4n5o6"
down_revision: str | Sequence[str] | None = "o0j1k2l3m4n5"
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 (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Implement new knowledge architecture."""
schema = _get_schema_prefix()
# 1. Drop the learnings table (mental models will be in memory_units)
op.execute(f"DROP TABLE IF EXISTS {schema}learnings CASCADE")
# 2. Rename pinned_reflections to reflections
op.execute(f"ALTER TABLE IF EXISTS {schema}pinned_reflections RENAME TO reflections")
# Rename indexes for reflections
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_bank_id RENAME TO idx_reflections_bank_id")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_embedding RENAME TO idx_reflections_embedding")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_tags RENAME TO idx_reflections_tags")
op.execute(
f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_text_search RENAME TO idx_reflections_text_search"
)
# Rename foreign key constraint
op.execute(f"""
ALTER TABLE {schema}reflections
DROP CONSTRAINT IF EXISTS fk_pinned_reflections_bank_id
""")
op.execute(f"""
ALTER TABLE {schema}reflections
ADD CONSTRAINT fk_reflections_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
# 3. Drop the mental_models table completely
op.execute(f"DROP TABLE IF EXISTS {schema}mental_models CASCADE")
# 4. Create directives table
op.execute(f"""
CREATE TABLE {schema}directives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bank_id VARCHAR(64) NOT NULL,
name VARCHAR(256) NOT NULL,
content TEXT NOT NULL,
priority INT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
""")
# Add foreign key and indexes for directives
op.execute(f"""
ALTER TABLE {schema}directives
ADD CONSTRAINT fk_directives_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
op.execute(f"CREATE INDEX idx_directives_bank_id ON {schema}directives(bank_id)")
op.execute(f"CREATE INDEX idx_directives_bank_active ON {schema}directives(bank_id, is_active)")
op.execute(f"CREATE INDEX idx_directives_tags ON {schema}directives USING GIN(tags)")
# 5. Add mental model support columns to memory_units
# proof_count: Number of memories that support this mental model
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD COLUMN IF NOT EXISTS proof_count INT DEFAULT 1
""")
# source_memory_ids: Array of memory IDs that consolidated into this mental model
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD COLUMN IF NOT EXISTS source_memory_ids UUID[] DEFAULT ARRAY[]::UUID[]
""")
# history: JSONB array tracking changes to mental models
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD COLUMN IF NOT EXISTS history JSONB DEFAULT '[]'::jsonb
""")
# Add index for finding mental models
op.execute(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_mental_models
ON {schema}memory_units(bank_id, fact_type)
WHERE fact_type = 'mental_model'
""")
# 6. Update fact_type check constraint to include 'mental_model'
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD CONSTRAINT memory_units_fact_type_check
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation', 'mental_model'))
""")
def downgrade() -> None:
"""Reverse the migration."""
schema = _get_schema_prefix()
# Restore original fact_type check constraint (without 'mental_model')
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD CONSTRAINT memory_units_fact_type_check
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation'))
""")
# Drop mental model columns from memory_units
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS proof_count")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS source_memory_ids")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS history")
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_mental_models")
# Drop directives table
op.execute(f"DROP TABLE IF EXISTS {schema}directives CASCADE")
# Rename reflections back to pinned_reflections
op.execute(f"ALTER TABLE IF EXISTS {schema}reflections RENAME TO pinned_reflections")
# Restore indexes
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_bank_id RENAME TO idx_pinned_reflections_bank_id")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_embedding RENAME TO idx_pinned_reflections_embedding")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_tags RENAME TO idx_pinned_reflections_tags")
op.execute(
f"ALTER INDEX IF EXISTS {schema}idx_reflections_text_search RENAME TO idx_pinned_reflections_text_search"
)
# Restore foreign key
op.execute(f"""
ALTER TABLE {schema}pinned_reflections
DROP CONSTRAINT IF EXISTS fk_reflections_bank_id
""")
op.execute(f"""
ALTER TABLE {schema}pinned_reflections
ADD CONSTRAINT fk_pinned_reflections_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
# Re-create learnings table
op.execute(f"""
CREATE TABLE IF NOT EXISTS {schema}learnings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bank_id VARCHAR(64) NOT NULL,
text TEXT NOT NULL,
proof_count INT NOT NULL DEFAULT 1,
history JSONB DEFAULT '[]'::jsonb,
mission_context VARCHAR(64),
pre_mission_change BOOLEAN DEFAULT FALSE,
embedding vector(384),
tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
)
""")
op.execute(f"""
ALTER TABLE {schema}learnings
ADD CONSTRAINT fk_learnings_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
# Note: mental_models table recreation is complex and would need separate handling
@@ -1,50 +0,0 @@
"""fix_mental_model_fact_type
Revision ID: q2l3m4n5o6p7
Revises: p1k2l3m4n5o6
Create Date: 2026-01-21 13:30:00.000000
Fix the fact_type check constraint to include 'mental_model'.
This is a fix for p1k2l3m4n5o6 which should have included this change.
"""
from collections.abc import Sequence
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "q2l3m4n5o6p7"
down_revision: str | Sequence[str] | None = "p1k2l3m4n5o6"
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 (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Add 'mental_model' to the fact_type check constraint."""
schema = _get_schema_prefix()
# Drop the old constraint and add the new one with mental_model included
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD CONSTRAINT memory_units_fact_type_check
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation', 'mental_model'))
""")
def downgrade() -> None:
"""Remove 'mental_model' from the fact_type check constraint."""
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD CONSTRAINT memory_units_fact_type_check
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation'))
""")
@@ -1,47 +0,0 @@
"""Add reflect_response JSONB column to reflections
Revision ID: r3m4n5o6p7q8
Revises: q2l3m4n5o6p7
Create Date: 2026-01-21
This migration adds a reflect_response JSONB column to store the full
reflect API response payload, including based_on facts and trace data.
Note: Table was renamed from pinned_reflections to reflections in p1k2l3m4n5o6.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "r3m4n5o6p7q8"
down_revision: str | Sequence[str] | None = "q2l3m4n5o6p7"
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 (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Add reflect_response JSONB column to reflections."""
schema = _get_schema_prefix()
# Add reflect_response column to store the full reflect API response
op.execute(f"""
ALTER TABLE {schema}reflections
ADD COLUMN IF NOT EXISTS reflect_response JSONB
""")
def downgrade() -> None:
"""Remove reflect_response column from reflections."""
schema = _get_schema_prefix()
op.execute(f"""
ALTER TABLE {schema}reflections
DROP COLUMN IF EXISTS reflect_response
""")
@@ -1,53 +0,0 @@
"""Add consolidated_at column to memory_units for incremental consolidation tracking.
This allows consolidation to track progress at the memory level rather than
using a bank-level watermark. If consolidation crashes, already-processed
memories won't be reprocessed.
Revision ID: s4n5o6p7q8r9
Revises: r3m4n5o6p7q8
Create Date: 2025-01-22
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "s4n5o6p7q8r9"
down_revision: str | Sequence[str] | None = "r3m4n5o6p7q8"
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 (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
# Add consolidated_at column to memory_units
op.execute(
f"""
ALTER TABLE {schema}memory_units
ADD COLUMN IF NOT EXISTS consolidated_at TIMESTAMPTZ DEFAULT NULL
"""
)
# Create index for efficient querying of unconsolidated memories
op.execute(
f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_unconsolidated
ON {schema}memory_units (bank_id, created_at)
WHERE consolidated_at IS NULL AND fact_type IN ('experience', 'world')
"""
)
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_unconsolidated")
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS consolidated_at")
@@ -1,134 +0,0 @@
"""Rename mental_model fact_type to observation and reflections table to mental_models
Revision ID: t5o6p7q8r9s0
Revises: s4n5o6p7q8r9
Create Date: 2026-01-26
This migration implements the terminology rename:
1. mental_model (fact_type in memory_units) -> observation
2. reflections table -> mental_models table
The new terminology:
- Observations: Consolidated knowledge synthesized from facts (was mental_model)
- Mental Models: Stored reflect responses (was reflections)
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "t5o6p7q8r9s0"
down_revision: str | Sequence[str] | None = "s4n5o6p7q8r9"
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 (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Rename mental_model -> observation and reflections -> mental_models."""
schema = _get_schema_prefix()
# 1. Update fact_type values: mental_model -> observation
op.execute(f"""
UPDATE {schema}memory_units
SET fact_type = 'observation'
WHERE fact_type = 'mental_model'
""")
# 2. Update the CHECK constraint - remove mental_model, keep observation
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD CONSTRAINT memory_units_fact_type_check
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation'))
""")
# 3. Rename the index for observations (was for mental_models)
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_mental_models")
op.execute(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_observations
ON {schema}memory_units(bank_id, fact_type)
WHERE fact_type = 'observation'
""")
# 4. Update the unconsolidated index to not filter by fact_type since observations
# are now the consolidated type
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_unconsolidated")
op.execute(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_unconsolidated
ON {schema}memory_units (bank_id, created_at)
WHERE consolidated_at IS NULL AND fact_type IN ('experience', 'world')
""")
# 5. Rename reflections table to mental_models
op.execute(f"ALTER TABLE IF EXISTS {schema}reflections RENAME TO mental_models")
# 6. Rename indexes for mental_models (was reflections)
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_bank_id RENAME TO idx_mental_models_bank_id")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_embedding RENAME TO idx_mental_models_embedding")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_tags RENAME TO idx_mental_models_tags")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_text_search RENAME TO idx_mental_models_text_search")
# 7. Rename foreign key constraint
op.execute(f"""
ALTER TABLE {schema}mental_models
DROP CONSTRAINT IF EXISTS fk_reflections_bank_id
""")
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD CONSTRAINT fk_mental_models_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
def downgrade() -> None:
"""Reverse: observation -> mental_model and mental_models -> reflections."""
schema = _get_schema_prefix()
# 1. Rename mental_models table back to reflections
op.execute(f"ALTER TABLE IF EXISTS {schema}mental_models RENAME TO reflections")
# 2. Rename indexes back
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_mental_models_bank_id RENAME TO idx_reflections_bank_id")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_mental_models_embedding RENAME TO idx_reflections_embedding")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_mental_models_tags RENAME TO idx_reflections_tags")
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_mental_models_text_search RENAME TO idx_reflections_text_search")
# 3. Rename foreign key back
op.execute(f"""
ALTER TABLE {schema}reflections
DROP CONSTRAINT IF EXISTS fk_mental_models_bank_id
""")
op.execute(f"""
ALTER TABLE {schema}reflections
ADD CONSTRAINT fk_reflections_bank_id
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
""")
# 4. Update fact_type values: observation -> mental_model
op.execute(f"""
UPDATE {schema}memory_units
SET fact_type = 'mental_model'
WHERE fact_type = 'observation'
""")
# 5. Update the CHECK constraint back
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
op.execute(f"""
ALTER TABLE {schema}memory_units
ADD CONSTRAINT memory_units_fact_type_check
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation', 'mental_model'))
""")
# 6. Rename index back
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_observations")
op.execute(f"""
CREATE INDEX IF NOT EXISTS idx_memory_units_mental_models
ON {schema}memory_units(bank_id, fact_type)
WHERE fact_type = 'mental_model'
""")
@@ -1,41 +0,0 @@
"""Change mental_models.id from UUID to TEXT
Revision ID: u6p7q8r9s0t1
Revises: t5o6p7q8r9s0
Create Date: 2026-01-27
This migration changes the mental_models.id column from UUID to TEXT
to support user-defined text identifiers like 'team-communication' instead of UUIDs.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "u6p7q8r9s0t1"
down_revision: str | Sequence[str] | None = "t5o6p7q8r9s0"
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 (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Change mental_models.id from UUID to TEXT."""
schema = _get_schema_prefix()
# Change the id column type from UUID to TEXT
# Existing UUIDs will be converted to their string representation
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE TEXT USING id::TEXT")
def downgrade() -> None:
"""Revert mental_models.id from TEXT to UUID."""
schema = _get_schema_prefix()
# Note: This will fail if any id values are not valid UUIDs
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE UUID USING id::UUID")
@@ -1,50 +0,0 @@
"""Add max_tokens and trigger columns to mental_models
Revision ID: v7q8r9s0t1u2
Revises: u6p7q8r9s0t1
Create Date: 2026-01-27
This migration adds:
- max_tokens column: token limit for content generation during refresh
- trigger column: JSONB for trigger settings (e.g., refresh_after_consolidation)
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "v7q8r9s0t1u2"
down_revision: str | Sequence[str] | None = "u6p7q8r9s0t1"
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 (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Add max_tokens and trigger columns to mental_models."""
schema = _get_schema_prefix()
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS max_tokens INT NOT NULL DEFAULT 2048
""")
# trigger column stores trigger settings as JSONB
# Default: refresh_after_consolidation = false (not "real time")
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD COLUMN IF NOT EXISTS trigger JSONB NOT NULL DEFAULT '{{"refresh_after_consolidation": false}}'::jsonb
""")
def downgrade() -> None:
"""Remove max_tokens and trigger columns from mental_models."""
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS max_tokens")
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS trigger")
@@ -1,60 +0,0 @@
"""Fix mental_models primary key to be scoped per bank
Revision ID: w8r9s0t1u2v3
Revises: v7q8r9s0t1u2
Create Date: 2026-02-05
This migration fixes a critical bank isolation bug where mental_models.id was
globally unique across all banks instead of being scoped per bank. This caused
conflicts when different banks tried to use the same custom ID.
CRITICAL FIX: Changes primary key from (id) to (bank_id, id) to ensure proper isolation.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "w8r9s0t1u2v3"
down_revision: str | Sequence[str] | None = "v7q8r9s0t1u2"
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 (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Change mental_models primary key from (id) to (bank_id, id) for proper bank isolation."""
schema = _get_schema_prefix()
# Drop the old primary key constraint (just id)
# Note: The constraint might be named differently on different DBs
# Try both old names (pinned_reflections_pkey from original, mental_models_pkey from rename)
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS pinned_reflections_pkey")
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS mental_models_pkey")
# Create the new composite primary key (bank_id, id)
# This ensures IDs are scoped per bank, not globally
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD CONSTRAINT mental_models_pkey PRIMARY KEY (bank_id, id)
""")
def downgrade() -> None:
"""Revert mental_models primary key from (bank_id, id) to (id)."""
schema = _get_schema_prefix()
# Drop the composite primary key
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS mental_models_pkey")
# Restore the old primary key (just id)
# WARNING: This downgrade will fail if there are duplicate IDs across banks
op.execute(f"""
ALTER TABLE {schema}mental_models
ADD CONSTRAINT mental_models_pkey PRIMARY KEY (id)
""")
@@ -1,64 +0,0 @@
"""Add config JSONB column to banks table for hierarchical configuration
Revision ID: x9s0t1u2v3w4
Revises: w8r9s0t1u2v3
Create Date: 2026-02-09
This migration adds a `config` JSONB column to the banks table to support
per-bank configuration overrides. This enables hierarchical configuration where:
- Global config is loaded from environment variables
- Tenant config is provided via TenantExtension
- Bank config overrides are stored in banks.config JSONB column
The config column stores overrides for hierarchical fields (LLM settings,
retention parameters, retrieval settings, etc.) in Python field name format.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import context, op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = "x9s0t1u2v3w4"
down_revision: str | Sequence[str] | None = "w8r9s0t1u2v3"
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 (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Add config JSONB column to banks table with GIN index."""
schema = _get_schema_prefix()
# Add config column to banks table
op.execute(f"""
ALTER TABLE {schema}banks
ADD COLUMN config JSONB NOT NULL DEFAULT '{{}}'::jsonb
""")
# Add GIN index for efficient JSONB queries
op.execute(f"""
CREATE INDEX idx_banks_config
ON {schema}banks
USING gin(config)
""")
def downgrade() -> None:
"""Remove config column and index from banks table."""
schema = _get_schema_prefix()
# Drop index first
op.execute(f"DROP INDEX IF EXISTS {schema}idx_banks_config")
# Drop column
op.execute(f"""
ALTER TABLE {schema}banks
DROP COLUMN IF EXISTS config
""")
@@ -1,49 +0,0 @@
"""Add GIN index on async_operations.result_metadata for parent_operation_id queries
Revision ID: y0t1u2v3w4x5
Revises: x9s0t1u2v3w4
Create Date: 2026-02-13
This migration adds a GIN index on the result_metadata JSONB column in the
async_operations table to support efficient queries for child operations by
parent_operation_id.
The index enables fast lookups when querying for child operations:
SELECT * FROM async_operations
WHERE result_metadata::jsonb @> '{"parent_operation_id": "uuid"}'::jsonb
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "y0t1u2v3w4x5"
down_revision: str | Sequence[str] | None = "x9s0t1u2v3w4"
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 (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
"""Add GIN index on result_metadata for efficient parent_operation_id queries."""
schema = _get_schema_prefix()
# Add GIN index for JSONB containment queries (@> operator)
op.execute(f"""
CREATE INDEX idx_async_operations_result_metadata
ON {schema}async_operations
USING gin(result_metadata)
""")
def downgrade() -> None:
"""Remove GIN index on result_metadata."""
schema = _get_schema_prefix()
# Drop index
op.execute(f"DROP INDEX IF EXISTS {schema}idx_async_operations_result_metadata")
@@ -1,35 +0,0 @@
"""Add observation_scopes column to memory_units table
Revision ID: z1u2v3w4x5y6
Revises: a1b2c3d4e5f6
Create Date: 2026-02-25
Adds observation_scopes JSONB column to memory_units to control how observations
are scoped during consolidation. Accepts "per_tag", "combined", or an explicit
list of tag-set lists for custom multi-pass consolidation.
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "z1u2v3w4x5y6"
down_revision: str | Sequence[str] | None = "a1b2c3d4e5f6"
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 (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}memory_units ADD COLUMN IF NOT EXISTS observation_scopes JSONB")
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS observation_scopes")
+19 -29
View File
@@ -6,6 +6,7 @@ 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
@@ -45,14 +46,14 @@ def create_app(
# Both HTTP and MCP
app = create_app(memory, mcp_api_enabled=True)
"""
mcp_servers = None
mcp_app = None
# Create MCP servers first if enabled (we need their lifespans for chaining)
# Create MCP app first if enabled (we need its lifespan for chaining)
if mcp_api_enabled:
try:
from .mcp import MCPMiddleware, create_mcp_servers
from .mcp import create_mcp_app
mcp_servers = create_mcp_servers(memory=memory)
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]")
@@ -69,41 +70,30 @@ def create_app(
app = FastAPI(title="Hindsight API", version="0.0.7")
logger.info("HTTP REST API disabled")
# Add MCP middleware and chain its lifespan if enabled
if mcp_servers is not None:
multi_bank_server, single_bank_server, multi_bank_starlette_app, single_bank_starlette_app = mcp_servers
# 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
# Store the original lifespan
original_lifespan = app.router.lifespan_context
@asynccontextmanager
async def chained_lifespan(app_instance: FastAPI):
"""Chain both MCP lifespans with the main app lifespan."""
# Start both MCP lifespans (multi-bank and single-bank)
async with multi_bank_starlette_app.router.lifespan_context(multi_bank_starlette_app):
async with single_bank_starlette_app.router.lifespan_context(single_bank_starlette_app):
logger.info("MCP lifespans started (multi-bank and single-bank)")
# Then start the original app lifespan
async with original_lifespan(app_instance):
yield
logger.info("MCP lifespans stopped")
"""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
# Add MCP as a wrapping middleware — intercepts /mcp* requests directly,
# passes everything else through to the FastAPI app. No Starlette Mount
# means no 307 redirect for /mcp (no trailing slash).
app.add_middleware(
MCPMiddleware,
memory=memory,
prefix=mcp_mount_path,
multi_bank_app=multi_bank_starlette_app,
single_bank_app=single_bank_starlette_app,
multi_bank_server=multi_bank_server,
single_bank_server=single_bank_server,
)
# Mount the MCP middleware
app.mount(mcp_mount_path, mcp_app)
logger.info(f"MCP server enabled at {mcp_mount_path}/")
return app
File diff suppressed because it is too large Load Diff
+239 -321
View File
@@ -1,4 +1,4 @@
"""Hindsight MCP Server implementation using FastMCP (HTTP transport)."""
"""Hindsight MCP Server implementation using FastMCP."""
import json
import logging
@@ -8,48 +8,9 @@ from contextvars import ContextVar
from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.memory_engine import _current_schema
from hindsight_api.extensions import MCPExtension, load_extension
from hindsight_api.extensions.tenant import AuthenticationError
from hindsight_api.mcp_tools import MCPToolsConfig, register_mcp_tools
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.models import RequestContext
# All tools available in the system (explicit list — no wildcards)
_ALL_TOOLS: frozenset[str] = frozenset(
{
"retain",
"recall",
"reflect",
"list_banks",
"create_bank",
"list_mental_models",
"get_mental_model",
"create_mental_model",
"update_mental_model",
"delete_mental_model",
"refresh_mental_model",
"list_directives",
"create_directive",
"delete_directive",
"list_memories",
"get_memory",
"delete_memory",
"list_documents",
"get_document",
"delete_document",
"list_operations",
"get_operation",
"cancel_operation",
"list_tags",
"get_bank",
"get_bank_stats",
"update_bank",
"delete_bank",
"clear_memories",
}
)
# Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower()
_log_level_map = {
@@ -69,213 +30,240 @@ logger = logging.getLogger(__name__)
# Default bank_id from environment variable
DEFAULT_BANK_ID = os.environ.get("HINDSIGHT_MCP_BANK_ID", "default")
# Legacy MCP authentication token (for backwards compatibility)
# If set, this token is checked first before TenantExtension auth
MCP_AUTH_TOKEN = os.environ.get("HINDSIGHT_API_MCP_AUTH_TOKEN")
# Context variable to hold the current bank_id
_current_bank_id: ContextVar[str | None] = ContextVar("current_bank_id", default=None)
# Context variable to hold the current API key (for tenant auth propagation)
_current_api_key: ContextVar[str | None] = ContextVar("current_api_key", default=None)
# Context variables for tenant_id and api_key_id (set by authenticate, used by usage metering)
_current_tenant_id: ContextVar[str | None] = ContextVar("current_tenant_id", default=None)
_current_api_key_id: ContextVar[str | None] = ContextVar("current_api_key_id", default=None)
def get_current_bank_id() -> str | None:
"""Get the current bank_id from context."""
return _current_bank_id.get()
def get_current_api_key() -> str | None:
"""Get the current API key from context."""
return _current_api_key.get()
def get_current_tenant_id() -> str | None:
"""Get the current tenant_id from context."""
return _current_tenant_id.get()
def get_current_api_key_id() -> str | None:
"""Get the current api_key_id from context."""
return _current_api_key_id.get()
def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
def create_mcp_server(memory: MemoryEngine) -> FastMCP:
"""
Create and configure the Hindsight MCP server.
Args:
memory: MemoryEngine instance (required)
multi_bank: If True, expose all tools with bank_id parameters (default).
If False, only expose bank-scoped tools without bank_id parameters.
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)
global_config = _get_raw_config()
@mcp.tool()
async def retain(
content: str,
context: str = "general",
async_processing: bool = True,
bank_id: str | None = None,
) -> str:
"""
Store important information to long-term memory.
# Tools available for this mode (multi-bank exposes all tools; single-bank excludes bank-management tools)
_SINGLE_BANK_TOOLS: frozenset[str] = frozenset(
{
"retain",
"recall",
"reflect",
"list_mental_models",
"get_mental_model",
"create_mental_model",
"update_mental_model",
"delete_mental_model",
"refresh_mental_model",
"list_directives",
"create_directive",
"delete_directive",
"list_memories",
"get_memory",
"delete_memory",
"list_documents",
"get_document",
"delete_document",
"list_operations",
"get_operation",
"cancel_operation",
"list_tags",
"get_bank",
"update_bank",
"delete_bank",
"clear_memories",
}
)
base_tools: frozenset[str] | None = None if multi_bank else _SINGLE_BANK_TOOLS
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
# Apply global mcp_enabled_tools filter (env-level allowlist)
if global_config.mcp_enabled_tools is not None:
allowed = frozenset(global_config.mcp_enabled_tools)
base_tools = (base_tools if base_tools is not None else _ALL_TOOLS) & allowed
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'
async_processing: If True, queue for background processing and return immediately. If False, wait for completion. Default: True
bank_id: Optional bank to store 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"
contents = [{"content": content, "context": context}]
if async_processing:
# Queue for background processing and return immediately
result = await memory.submit_async_retain(
bank_id=target_bank, contents=contents, request_context=RequestContext()
)
return f"Memory queued for background processing (operation_id: {result.get('operation_id', 'N/A')})"
else:
# Wait for completion
await memory.retain_batch_async(
bank_id=target_bank,
contents=contents,
request_context=RequestContext(),
)
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)}"
# Configure and register tools using shared module
config = MCPToolsConfig(
bank_id_resolver=get_current_bank_id,
api_key_resolver=get_current_api_key, # Propagate API key for tenant auth
tenant_id_resolver=get_current_tenant_id, # Propagate tenant_id for usage metering
api_key_id_resolver=get_current_api_key_id, # Propagate api_key_id for usage metering
include_bank_id_param=multi_bank,
tools=base_tools,
)
@mcp.tool()
async def recall(query: str, max_tokens: int = 4096, bank_id: str | None = None) -> str:
"""
Search memories to provide personalized, context-aware responses.
register_mcp_tools(mcp, memory, config)
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
# Load and register additional tools from MCP extension if configured
mcp_extension = load_extension("MCP", MCPExtension)
if mcp_extension:
logger.info(f"Loading MCP extension: {mcp_extension.__class__.__name__}")
mcp_extension.register_tools(mcp, memory)
Args:
query: Natural language search query (e.g., "user's food preferences", "what projects is user working on")
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:
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
# Make all tools tolerant of extra arguments from LLMs (e.g., "explanation")
_make_tools_tolerant(mcp)
recall_result = await memory.recall_async(
bank_id=target_bank,
query=query,
fact_type=list(VALID_RECALL_FACT_TYPES),
budget=Budget.HIGH,
max_tokens=max_tokens,
request_context=RequestContext(),
)
# 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 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 tool to discover what memory banks exist in the system.
Each bank is an isolated memory store (like a separate "brain").
Returns:
JSON list of banks with their IDs, names, dispositions, and missions.
"""
try:
banks = await memory.list_banks(request_context=RequestContext())
return json.dumps({"banks": banks}, 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, mission: str | None = None) -> str:
"""
Create a new memory bank or get an existing one.
Memory banks are isolated stores - each one is like a separate "brain" for a user/agent.
Banks are auto-created with default settings if they don't exist.
Args:
bank_id: Unique identifier for the bank (e.g., 'user-123', 'agent-alpha')
name: Optional human-friendly name for the bank
mission: Optional mission describing who the agent is and what they're trying to accomplish
"""
try:
# get_bank_profile auto-creates bank if it doesn't exist
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
# Update name/mission if provided
if name is not None or mission is not None:
await memory.update_bank(
bank_id,
name=name,
mission=mission,
request_context=RequestContext(),
)
# Fetch updated profile
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
# Serialize disposition if it's a Pydantic model
if "disposition" in profile and hasattr(profile["disposition"], "model_dump"):
profile["disposition"] = profile["disposition"].model_dump()
return json.dumps(profile, indent=2)
except Exception as e:
logger.error(f"Error creating bank: {e}", exc_info=True)
return f'{{"error": "{e}"}}'
return mcp
def _make_tools_tolerant(mcp: FastMCP) -> None:
"""Wrap all tool run methods to strip unknown arguments before validation.
LLMs frequently add extra fields like "explanation" or "reasoning" to tool calls.
FastMCP's Pydantic TypeAdapter rejects these with "Unexpected keyword argument".
This wraps each tool's run() to filter arguments to only known parameters.
"""
try:
for name, tool in mcp._tool_manager._tools.items():
if hasattr(tool, "parameters") and tool.parameters:
allowed = set(tool.parameters.get("properties", {}).keys())
original_run = tool.run
async def _tolerant_run(arguments, _allowed=allowed, _orig=original_run):
extra_keys = set(arguments.keys()) - _allowed
if extra_keys:
logger.debug(f"Stripping unknown arguments from tool call: {extra_keys}")
arguments = {k: v for k, v in arguments.items() if k in _allowed}
return await _orig(arguments)
# FunctionTool is a Pydantic model with extra='forbid', so use
# object.__setattr__ to bypass Pydantic's setter validation.
object.__setattr__(tool, "run", _tolerant_run)
except (AttributeError, KeyError) as e:
logger.warning(f"Could not make tools tolerant of extra arguments: {e}")
class MCPMiddleware:
"""ASGI middleware that intercepts MCP requests and routes to appropriate MCP server.
"""ASGI middleware that extracts bank_id from header or path and sets context.
This middleware wraps the main FastAPI app and intercepts requests matching the
configured prefix (default: /mcp). Non-MCP requests pass through to the inner app.
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)
Authentication:
1. If HINDSIGHT_API_MCP_AUTH_TOKEN is set (legacy), validates against that token
2. Otherwise, uses TenantExtension.authenticate_mcp() from the MemoryEngine
- DefaultTenantExtension: no auth required (local dev)
- ApiKeyTenantExtension: validates against env var
Two modes based on URL structure:
1. Multi-bank mode (for /mcp/ root endpoint):
- Exposes all tools: retain, recall, reflect, list_banks, create_bank
- All tools include optional bank_id parameter for cross-bank operations
- Bank ID from: X-Bank-Id header or HINDSIGHT_MCP_BANK_ID env var
2. Single-bank mode (for /mcp/{bank_id}/ endpoints):
- Exposes bank-scoped tools only: retain, recall, reflect
- No bank_id parameter (comes from URL)
- No bank management tools (list_banks, create_bank)
- Recommended for agent isolation
Bank ID resolution priority:
1. URL path (e.g., /mcp/{bank_id}/) → single-bank mode
2. X-Bank-Id header → multi-bank mode
3. HINDSIGHT_MCP_BANK_ID env var → multi-bank mode (default: "default")
Examples:
# Single-bank mode (recommended for agent isolation)
claude mcp add --transport http my-agent http://localhost:8888/mcp/my-agent-bank/ \\
--header "Authorization: Bearer <token>"
# Multi-bank mode (for cross-bank operations)
For Claude Code, configure with:
claude mcp add --transport http hindsight http://localhost:8888/mcp \\
--header "X-Bank-Id: my-bank" --header "Authorization: Bearer <token>"
--header "X-Bank-Id: my-bank"
"""
def __init__(
self,
app,
memory: MemoryEngine,
prefix: str = "/mcp",
multi_bank_app=None,
single_bank_app=None,
multi_bank_server=None,
single_bank_server=None,
):
def __init__(self, app, memory: MemoryEngine):
self.app = app
self.prefix = prefix
self.memory = memory
self.tenant_extension = memory._tenant_extension
if multi_bank_app and single_bank_app:
# Pre-created servers (used when called via add_middleware from create_app)
self.multi_bank_app = multi_bank_app
self.single_bank_app = single_bank_app
self.multi_bank_server = multi_bank_server
self.single_bank_server = single_bank_server
else:
# Create servers internally (for direct construction / tests)
self.multi_bank_server = create_mcp_server(memory, multi_bank=True)
self.multi_bank_app = self.multi_bank_server.http_app(path="/", stateless_http=True)
self.single_bank_server = create_mcp_server(memory, multi_bank=False)
self.single_bank_app = self.single_bank_server.http_app(path="/", stateless_http=True)
self.mcp_server = create_mcp_server(memory)
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."""
@@ -287,113 +275,54 @@ class MCPMiddleware:
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
await self.mcp_app(scope, receive, send)
return
path = scope.get("path", "")
# Check if this is an MCP request (matches prefix)
if not (path == self.prefix or path.startswith(self.prefix + "/")):
# Not an MCP request — pass through to the inner app
await self.app(scope, receive, send)
return
# 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 "/"
# Strip prefix from path
path = path[len(self.prefix) :] 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 auth token from header (for tenant auth propagation)
auth_header = self._get_header(scope, "Authorization")
auth_token: str | None = None
if auth_header:
# Support both "Bearer <token>" and direct token
auth_token = auth_header[7:].strip() if auth_header.startswith("Bearer ") else auth_header.strip()
# Try to get bank_id from header first (for Claude Code compatibility)
bank_id = self._get_header(scope, "X-Bank-Id")
# Authenticate: check legacy MCP_AUTH_TOKEN first, then TenantExtension
tenant_context = None
auth_tenant_id: str | None = None
auth_api_key_id: str | None = None
if MCP_AUTH_TOKEN:
# Legacy authentication mode - validate against static token
if not auth_token:
await self._send_error(send, 401, "Authorization header required")
return
if auth_token != MCP_AUTH_TOKEN:
await self._send_error(send, 401, "Invalid authentication token")
return
# Legacy mode doesn't use tenant schemas
tenant_context = None
else:
# Use TenantExtension.authenticate_mcp() for auth
try:
auth_context = RequestContext(api_key=auth_token)
tenant_context = await self.tenant_extension.authenticate_mcp(auth_context)
# Capture tenant_id and api_key_id set by authenticate() for usage metering
auth_tenant_id = auth_context.tenant_id
auth_api_key_id = auth_context.api_key_id
except AuthenticationError as e:
await self._send_error(send, 401, str(e), extra_headers=e.headers)
return
# MCP endpoint paths that should not be treated as bank_ids
MCP_ENDPOINTS = {"sse", "messages"}
# Set schema from tenant context so downstream DB queries use the correct schema
schema_token = (
_current_schema.set(tenant_context.schema_name) if tenant_context and tenant_context.schema_name else None
)
# Resolve bank_id: path takes priority over header.
# Path = user's explicit connection endpoint (e.g., /mcp/my-bank/).
# X-Bank-Id header = per-request override for multi-bank mode only.
bank_id = None
bank_id_from_path = False
# If no header, try to extract from path: /{bank_id}/...
new_path = path
# First, try to extract from path: /{bank_id}/...
if path.startswith("/") and len(path) > 1:
if not bank_id and path.startswith("/") and len(path) > 1:
parts = path[1:].split("/", 1)
if parts[0]:
# 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]
bank_id_from_path = True
new_path = "/" + parts[1] if len(parts) > 1 else "/"
# If no path-based bank_id, try X-Bank-Id header (multi-bank mode)
if not bank_id:
bank_id = self._get_header(scope, "X-Bank-Id")
# Fall back to default bank_id
if not bank_id:
bank_id = DEFAULT_BANK_ID
logger.debug(f"Using default bank_id: {bank_id}")
# Select the appropriate MCP app based on how bank_id was provided:
# - Path-based bank_id → single-bank app (no bank_id param, scoped tools)
# - Header/env bank_id → multi-bank app (bank_id param, all tools)
target_app = self.single_bank_app if bank_id_from_path else self.multi_bank_app
# Set bank_id, api_key, tenant_id, and api_key_id context
bank_id_token = _current_bank_id.set(bank_id)
# Store the auth token for tenant extension to validate
api_key_token = _current_api_key.set(auth_token) if auth_token else None
# Store tenant_id and api_key_id from authentication for usage metering
tenant_id_token = _current_tenant_id.set(auth_tenant_id) if auth_tenant_id else None
api_key_id_token = _current_api_key_id.set(auth_api_key_id) if auth_api_key_id else None
# 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 if using path-based routing.
# Only rewrite SSE (text/event-stream) responses to avoid corrupting tool results
# that might contain the literal string "data: /messages".
is_sse_response = False
# Wrap send to rewrite the SSE endpoint URL to include bank_id if using path-based routing
async def send_wrapper(message):
nonlocal is_sse_response
if message["type"] == "http.response.start":
for header_name, header_value in message.get("headers", []):
if header_name == b"content-type" and b"text/event-stream" in header_value:
is_sse_response = True
break
if message["type"] == "http.response.body" and bank_id_from_path and is_sse_response:
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
@@ -401,29 +330,18 @@ class MCPMiddleware:
message = {**message, "body": body}
await send(message)
await target_app(new_scope, receive, send_wrapper)
await self.mcp_app(new_scope, receive, send_wrapper)
finally:
_current_bank_id.reset(bank_id_token)
if api_key_token is not None:
_current_api_key.reset(api_key_token)
if tenant_id_token is not None:
_current_tenant_id.reset(tenant_id_token)
if api_key_id_token is not None:
_current_api_key_id.reset(api_key_id_token)
if schema_token is not None:
_current_schema.reset(schema_token)
_current_bank_id.reset(token)
async def _send_error(self, send, status: int, message: str, extra_headers: dict[str, str] | None = None):
async def _send_error(self, send, status: int, message: str):
"""Send an error response."""
body = json.dumps({"error": message}).encode()
headers = [(b"content-type", b"application/json")]
for key, value in (extra_headers or {}).items():
headers.append((key.encode(), value.encode()))
await send(
{
"type": "http.response.start",
"status": status,
"headers": headers,
"headers": [(b"content-type", b"application/json")],
}
)
await send(
@@ -434,19 +352,19 @@ class MCPMiddleware:
)
def create_mcp_servers(memory: MemoryEngine):
"""Create multi-bank and single-bank MCP servers and their Starlette apps.
def create_mcp_app(memory: MemoryEngine):
"""
Create an ASGI app that handles MCP requests.
Returns the servers and apps separately so lifespans can be chained before
the middleware wraps the main app.
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
Returns:
Tuple of (multi_bank_server, single_bank_server, multi_bank_app, single_bank_app)
ASGI application
"""
multi_bank_server = create_mcp_server(memory, multi_bank=True)
multi_bank_app = multi_bank_server.http_app(path="/", stateless_http=True)
single_bank_server = create_mcp_server(memory, multi_bank=False)
single_bank_app = single_bank_server.http_app(path="/", stateless_http=True)
return multi_bank_server, single_bank_server, multi_bank_app, single_bank_app
return MCPMiddleware(None, memory)
+1 -10
View File
@@ -4,8 +4,6 @@ Banner display for Hindsight API startup.
Shows the logo and tagline with gradient colors.
"""
from .utils import mask_network_location
# Gradient colors: #0074d9 -> #009296
GRADIENT_START = (0, 116, 217) # #0074d9
GRADIENT_END = (0, 146, 150) # #009296
@@ -85,21 +83,14 @@ def print_startup_info(
embeddings_provider: str,
reranker_provider: str,
mcp_enabled: bool = False,
version: str | None = None,
vector_extension: str | None = None,
text_search_extension: str | None = None,
):
"""Print styled startup information."""
print(color_start("Starting Hindsight API..."))
if version:
print(f" {dim('Version:')} {color(f'v{version}', 0.1)}")
print(f" {dim('URL:')} {color(f'http://{host}:{port}', 0.2)}")
print(f" {dim('Database:')} {color(mask_network_location(database_url), 0.4)}")
print(f" {dim('Database:')} {color(database_url, 0.4)}")
print(f" {dim('LLM:')} {color(f'{llm_provider} / {llm_model}', 0.6)}")
print(f" {dim('Embeddings:')} {color(embeddings_provider, 0.8)}")
print(f" {dim('Reranker:')} {color(reranker_provider, 1.0)}")
extensions = f"{vector_extension or 'default'} (vector) / {text_search_extension or 'default'} (text)"
print(f" {dim('Extensions:')} {color(extensions, 0.4)}")
if mcp_enabled:
print(f" {dim('MCP:')} {color_end('enabled at /mcp')}")
print()
File diff suppressed because it is too large Load Diff
@@ -1,275 +0,0 @@
"""
Configuration resolution with hierarchical overrides.
Resolves config values through the hierarchy:
Global (env vars) → Tenant config (via extension) → Bank config (database)
Config values are resolved on every request to ensure consistency across
multiple API servers.
"""
import json
import logging
from dataclasses import asdict
from typing import Any
import asyncpg
from hindsight_api.config import HindsightConfig, _get_raw_config, normalize_config_dict
from hindsight_api.engine.memory_engine import fq_table
from hindsight_api.extensions.tenant import TenantExtension
from hindsight_api.models import RequestContext
logger = logging.getLogger(__name__)
class ConfigResolver:
"""Resolves hierarchical configuration with tenant/bank overrides."""
def __init__(self, pool: asyncpg.Pool, tenant_extension: TenantExtension | None = None):
"""
Initialize config resolver.
Args:
pool: Database connection pool
tenant_extension: Optional tenant extension for tenant-level config and permissions
"""
self.pool = pool
self.tenant_extension = tenant_extension
self._global_config = _get_raw_config()
self._configurable_fields = HindsightConfig.get_configurable_fields()
self._credential_fields = HindsightConfig.get_credential_fields()
async def resolve_full_config(self, bank_id: str, context: RequestContext | None = None) -> HindsightConfig:
"""
Resolve full HindsightConfig for a bank with hierarchical overrides applied.
This is for INTERNAL USE ONLY. Returns the complete config object with all fields
including credentials and static fields. Use get_bank_config() for API responses.
Resolution order:
1. Global config (from environment variables)
2. Tenant config overrides (from TenantExtension.get_tenant_config())
3. Bank config overrides (from banks.config JSONB)
Args:
bank_id: Bank identifier
context: Request context for tenant config resolution
Returns:
Complete HindsightConfig with hierarchical overrides applied
"""
# Start with global config (all fields)
config_dict = asdict(self._global_config)
# Load tenant config overrides (if tenant extension available)
if self.tenant_extension and context:
try:
tenant_overrides = await self.tenant_extension.get_tenant_config(context)
if tenant_overrides:
# Normalize keys and filter to configurable fields only
normalized_tenant = normalize_config_dict(tenant_overrides)
configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields}
config_dict.update(configurable_tenant)
logger.debug(
f"Applied tenant config overrides for bank {bank_id}: {list(configurable_tenant.keys())}"
)
except Exception as e:
logger.warning(f"Failed to load tenant config for bank {bank_id}: {e}")
# Load bank config overrides
bank_overrides = await self._load_bank_config(bank_id)
if bank_overrides:
config_dict.update(bank_overrides)
logger.debug(f"Applied bank config overrides for bank {bank_id}: {list(bank_overrides.keys())}")
# Return full config object (dataclass doesn't have __init__ that accepts kwargs, so we update the object)
# Create a new config instance by copying the global config and updating fields
resolved_config = HindsightConfig(**config_dict)
return resolved_config
async def get_bank_config(self, bank_id: str, context: RequestContext | None = None) -> dict[str, Any]:
"""
Get fully resolved config for a bank (filtered by permissions).
Resolution order:
1. Global config (from environment variables)
2. Tenant config overrides (from TenantExtension.get_tenant_config())
3. Bank config overrides (from banks.config JSONB)
Note: Config is resolved on every call (not cached) to ensure consistency
across multiple API servers.
SECURITY:
- Only returns configurable fields (excludes static/infrastructure fields)
- Filters out ALL credential fields (API keys, base URLs, etc.)
- Further filtered by tenant/bank permissions if extension provides them
Args:
bank_id: Bank identifier
context: Request context for tenant config resolution and permissions
Returns:
Dict of allowed configurable fields only (never includes credentials or static fields)
"""
# Resolve full config with all hierarchical overrides
resolved_config = await self.resolve_full_config(bank_id, context)
config_dict = asdict(resolved_config)
# SECURITY: Filter to only configurable fields (exclude static/infrastructure)
filtered = {k: v for k, v in config_dict.items() if k in self._configurable_fields}
# SECURITY: Remove ALL credential fields (API keys, base URLs, etc.)
filtered = {k: v for k, v in filtered.items() if k not in self._credential_fields}
# PERMISSIONS: Further filter based on tenant/bank permissions
if self.tenant_extension and context:
try:
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
if allowed_fields is not None: # None means "allow all"
filtered = {k: v for k, v in filtered.items() if k in allowed_fields}
logger.debug(
f"Applied permission filter for bank {bank_id}: allowed={len(allowed_fields)} fields, "
f"returned={len(filtered)} fields"
)
except Exception as e:
logger.warning(f"Failed to load permissions for bank {bank_id}: {e}")
return filtered
async def _load_bank_config(self, bank_id: str) -> dict[str, Any]:
"""
Load bank config overrides from banks.config JSONB column.
Args:
bank_id: Bank identifier
Returns:
Dict of config overrides (only configurable fields, normalized keys)
"""
try:
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
f"""
SELECT config FROM {fq_table("banks")} WHERE bank_id = $1
""",
bank_id,
)
if row and row["config"]:
config_data = row["config"]
# Handle case where JSONB is returned as JSON string
if isinstance(config_data, str):
config_data = json.loads(config_data)
# Normalize keys (handle both env var format and Python field format)
normalized = normalize_config_dict(config_data)
# Only return overrides for configurable fields
return {k: v for k, v in normalized.items() if k in self._configurable_fields}
except Exception as e:
logger.error(f"Failed to load bank config for {bank_id}: {e}")
return {}
async def update_bank_config(
self, bank_id: str, updates: dict[str, Any], context: RequestContext | None = None
) -> None:
"""
Update bank configuration overrides (with permission checking).
Args:
bank_id: Bank identifier
updates: Dict of config field names to new values.
Keys can be in env var format (HINDSIGHT_API_LLM_PROVIDER)
or Python field format (llm_provider).
Only configurable fields are allowed.
context: Request context for permission checking
Raises:
ValueError: If attempting to override invalid/disallowed fields
"""
# Normalize keys
normalized_updates = normalize_config_dict(updates)
# SECURITY: Reject credential fields explicitly
credential_attempts = set(normalized_updates.keys()) & self._credential_fields
if credential_attempts:
raise ValueError(
f"Cannot set credential fields via API: {sorted(credential_attempts)}. "
f"Credentials (API keys, base URLs) must be set at server level only."
)
# Validate all fields are configurable
invalid_fields = set(normalized_updates.keys()) - self._configurable_fields
if invalid_fields:
static_fields = HindsightConfig.get_static_fields()
invalid_static = invalid_fields & static_fields
if invalid_static:
raise ValueError(
f"Cannot override static (server-level) fields: {sorted(invalid_static)}. "
f"Only configurable fields can be overridden per-bank. "
f"Configurable fields include: {sorted(list(self._configurable_fields)[:10])}... "
f"(total: {len(self._configurable_fields)} fields)"
)
else:
raise ValueError(
f"Unknown configuration fields: {sorted(invalid_fields)}. "
f"Valid configurable fields: {sorted(list(self._configurable_fields)[:10])}..."
)
# PERMISSIONS: Check tenant/bank permissions
if self.tenant_extension and context:
try:
allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id)
if allowed_fields is not None: # None means "allow all"
disallowed = set(normalized_updates.keys()) - allowed_fields
if disallowed:
raise ValueError(
f"Not allowed to modify fields: {sorted(disallowed)}. "
f"Your permissions allow: {sorted(list(allowed_fields)[:10])}..."
if allowed_fields
else "Not allowed to modify fields: {sorted(disallowed)}. "
"Your permissions do not allow any config modifications."
)
except ValueError:
raise # Re-raise permission errors
except Exception as e:
logger.warning(f"Failed to check permissions for bank {bank_id}: {e}")
# Continue without permission check (fail open for backward compatibility)
# Merge with existing config (JSONB || operator)
async with self.pool.acquire() as conn:
await conn.execute(
f"""
UPDATE {fq_table("banks")}
SET config = config || $1::jsonb,
updated_at = now()
WHERE bank_id = $2
""",
json.dumps(normalized_updates),
bank_id,
)
logger.info(f"Updated bank config for {bank_id}: {list(normalized_updates.keys())}")
async def reset_bank_config(self, bank_id: str) -> None:
"""
Reset bank configuration to defaults (remove all overrides).
Args:
bank_id: Bank identifier
"""
async with self.pool.acquire() as conn:
await conn.execute(
f"""
UPDATE {fq_table("banks")}
SET config = '{{}}'::jsonb,
updated_at = now()
WHERE bank_id = $1
""",
bank_id,
)
logger.info(f"Reset bank config for {bank_id} to defaults")
+111 -20
View File
@@ -1,10 +1,11 @@
"""
Daemon mode support for Hindsight API.
Provides idle timeout for running as a background daemon.
Provides idle timeout and lockfile management for running as a background daemon.
"""
import asyncio
import fcntl
import logging
import os
import sys
@@ -14,11 +15,10 @@ from pathlib import Path
logger = logging.getLogger(__name__)
# Default daemon configuration
DEFAULT_DAEMON_PORT = 8888
DEFAULT_DAEMON_PORT = 8889
DEFAULT_IDLE_TIMEOUT = 0 # 0 = no auto-exit (hindsight-embed passes its own timeout)
# Allow override via environment variable for profile-specific logs
DAEMON_LOG_PATH = Path(os.getenv("HINDSIGHT_API_DAEMON_LOG", str(Path.home() / ".hindsight" / "daemon.log")))
LOCKFILE_PATH = Path.home() / ".hindsight" / "daemon.lock"
DAEMON_LOG_PATH = Path.home() / ".hindsight" / "daemon.log"
class IdleTimeoutMiddleware:
@@ -52,10 +52,82 @@ class IdleTimeoutMiddleware:
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)
# Send SIGTERM to ourselves to trigger graceful shutdown
import signal
os._exit(0)
os.kill(os.getpid(), signal.SIGTERM)
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():
@@ -64,21 +136,16 @@ def daemonize():
Uses double-fork technique to properly detach from terminal.
"""
# First fork - detach from parent
try:
pid = os.fork()
if pid > 0:
sys.exit(0)
except OSError as e:
sys.stderr.write(f"fork #1 failed: {e}\n")
sys.exit(1)
# First fork
pid = os.fork()
if pid > 0:
# Parent exits
sys.exit(0)
# Decouple from parent environment
os.chdir("/")
# Create new session
os.setsid()
os.umask(0)
# Second fork - prevent zombie
# Second fork to prevent zombie processes
pid = os.fork()
if pid > 0:
sys.exit(0)
@@ -111,3 +178,27 @@ def check_daemon_running(port: int = DEFAULT_DAEMON_PORT) -> bool:
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
@@ -1,5 +0,0 @@
"""Consolidation engine for automatic learning creation from memories."""
from .consolidator import run_consolidation_job
__all__ = ["run_consolidation_job"]
File diff suppressed because it is too large Load Diff
@@ -1,66 +0,0 @@
"""Prompts for the consolidation engine."""
# Default mission when no bank-specific mission is set
_DEFAULT_MISSION = "Track every detail: names, numbers, dates, places, and relationships. Prefer specifics over abstractions, never generalise."
# Processing rules — always present regardless of mission
_PROCESSING_RULES = """Processing rules (always apply):
- REDUNDANT: same info worded differently → UPDATE the existing observation.
- CONTRADICTION/UPDATE: capture both states with temporal markers ("used to X, now Y").
- RESOLVE REFERENCES: when a new fact provides a concrete value resolving a vague placeholder in an existing observation (e.g. "home country", "hometown", "birthplace", "native language", "her ex", "that city"), UPDATE the observation to embed the resolved value explicitly. Example: new fact says "grandma in Sweden" + existing observation says "moved from her home country" → update to "home country is Sweden".
- NEVER merge observations about different people or unrelated topics."""
# Data section — format placeholders {facts_text} and {observations_text} are substituted at call time
_BATCH_DATA_SECTION = """
NEW FACTS:
{facts_text}
EXISTING OBSERVATIONS (JSON array, pooled from recalls across all facts above):
{observations_text}
Each observation includes:
- id: unique identifier for updating
- text: the observation content
- proof_count: number of supporting memories
- occurred_start/occurred_end: temporal range of source facts
- source_memories: array of supporting facts with their text and dates
Compare the facts against existing observations:
- Same topic as an existing observation → UPDATE it (observation_id + source_fact_ids)
- New topic with durable knowledge → CREATE a new observation (source_fact_ids)
- Cross-reference facts within the batch: a later fact may resolve a vague reference in an earlier one
- Purely ephemeral facts → omit them (no create/update needed)"""
# Output format — JSON braces escaped as {{ }} so .format() leaves them literal
_BATCH_OUTPUT_FORMAT = """
Output a JSON object with three arrays.
Example (showing the required UUID format for all IDs):
{{"creates": [{{"text": "Alice lives in Berlin", "source_fact_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f6a7-8901-bcde-f12345678901"]}}],
"updates": [{{"text": "Alice works at Acme Corp as a senior engineer", "observation_id": "c3d4e5f6-a7b8-9012-cdef-123456789012", "source_fact_ids": ["d4e5f6a7-b8c9-0123-defa-234567890123"]}}],
"deletes": [{{"observation_id": "e5f6a7b8-c9d0-1234-efab-345678901234"}}]}}
Rules:
- "source_fact_ids": copy the EXACT UUID strings shown in brackets [uuid] from NEW FACTS — never use integers or positions.
- "observation_id": copy the EXACT "id" UUID string from EXISTING OBSERVATIONS.
- One create/update may reference multiple facts when they jointly support the observation.
- "deletes": only when an observation is directly superseded or contradicted by new facts.
- Do NOT include "tags" — handled automatically.
- Return {{"creates": [], "updates": [], "deletes": []}} if nothing durable is found."""
def build_batch_consolidation_prompt(observations_mission: str | None = None) -> str:
"""
Build the consolidation prompt for batch mode (multiple facts per LLM call).
The mission defines *what* to track (customisable per bank).
Processing rules and output format are always present regardless of mission.
"""
mission = observations_mission or _DEFAULT_MISSION
return (
"You are a memory consolidation system. Synthesize facts into observations "
"and merge with existing observations when appropriate.\n\n"
f"## MISSION\n{mission}\n\n"
f"{_PROCESSING_RULES}" + _BATCH_DATA_SECTION + _BATCH_OUTPUT_FORMAT
)
@@ -9,7 +9,6 @@ Configuration via environment variables - see hindsight_api.config for all env v
import asyncio
import logging
import os
import warnings
from abc import ABC, abstractmethod
from concurrent.futures import ThreadPoolExecutor
@@ -21,29 +20,25 @@ from ..config import (
DEFAULT_RERANKER_FLASHRANK_CACHE_DIR,
DEFAULT_RERANKER_FLASHRANK_MODEL,
DEFAULT_RERANKER_LITELLM_MODEL,
DEFAULT_RERANKER_LITELLM_SDK_MODEL,
DEFAULT_RERANKER_LOCAL_FORCE_CPU,
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
DEFAULT_RERANKER_LOCAL_MODEL,
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE,
DEFAULT_RERANKER_PROVIDER,
DEFAULT_RERANKER_TEI_BATCH_SIZE,
DEFAULT_RERANKER_TEI_MAX_CONCURRENT,
DEFAULT_RERANKER_ZEROENTROPY_MODEL,
ENV_RERANKER_COHERE_API_KEY,
ENV_COHERE_API_KEY,
ENV_LITELLM_API_BASE,
ENV_LITELLM_API_KEY,
ENV_RERANKER_COHERE_BASE_URL,
ENV_RERANKER_COHERE_MODEL,
ENV_RERANKER_FLASHRANK_CACHE_DIR,
ENV_RERANKER_FLASHRANK_MODEL,
ENV_RERANKER_LITELLM_SDK_API_KEY,
ENV_RERANKER_LOCAL_FORCE_CPU,
ENV_RERANKER_LITELLM_MODEL,
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
ENV_RERANKER_LOCAL_MODEL,
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE,
ENV_RERANKER_PROVIDER,
ENV_RERANKER_TEI_BATCH_SIZE,
ENV_RERANKER_TEI_MAX_CONCURRENT,
ENV_RERANKER_TEI_URL,
ENV_RERANKER_ZEROENTROPY_API_KEY,
)
logger = logging.getLogger(__name__)
@@ -104,13 +99,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
_executor: ThreadPoolExecutor | None = None
_max_concurrent: int = 4 # Limit concurrent CPU-bound reranking calls
def __init__(
self,
model_name: str | None = None,
max_concurrent: int = 4,
force_cpu: bool = False,
trust_remote_code: bool = False,
):
def __init__(self, model_name: str | None = None, max_concurrent: int = 4):
"""
Initialize local SentenceTransformers cross-encoder.
@@ -119,15 +108,8 @@ class LocalSTCrossEncoder(CrossEncoderModel):
Default: cross-encoder/ms-marco-MiniLM-L-6-v2
max_concurrent: Maximum concurrent reranking calls (default: 2).
Higher values may cause CPU thrashing under load.
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
Default: False
trust_remote_code: Allow loading models with custom code (security risk).
Required for some models like jina-reranker-v2-base-multilingual.
Default: False (disabled for security)
"""
self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL
self.force_cpu = force_cpu
self.trust_remote_code = trust_remote_code
self._model = None
LocalSTCrossEncoder._max_concurrent = max_concurrent
@@ -148,56 +130,18 @@ class LocalSTCrossEncoder(CrossEncoderModel):
"Install it with: pip install sentence-transformers"
)
# Note: We use CPU even when GPU/MPS is available because:
# 1. The reranker model (MiniLM) is tiny (~22M params)
# 2. Batch sizes are small (~100-200 pairs)
# 3. Data transfer overhead to GPU outweighs compute benefit
# 4. CPU inference is actually faster for this workload
logger.info(f"Reranker: initializing local provider with model {self.model_name}")
# Determine device based on hardware availability.
# We always set low_cpu_mem_usage=False to prevent lazy loading (meta tensors)
# which can cause issues when accelerate is installed but no GPU is available.
# Note: We do NOT use device_map because CrossEncoder internally calls .to(device)
# after loading, which conflicts with accelerate's device_map handling.
import torch
# Force CPU mode if configured (used in daemon mode to avoid MPS/XPC issues on macOS)
if self.force_cpu:
device = "cpu"
logger.info("Reranker: forcing CPU mode (HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=1)")
else:
# Check for GPU (CUDA) or Apple Silicon (MPS)
# Wrap in try-except to gracefully handle any device detection issues
# (e.g., in CI environments or when PyTorch is built without GPU support)
device = "cpu" # Default to CPU
try:
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
# Suppress verbose transformers warnings during model loading
# This suppresses the "UNEXPECTED" warnings from CrossEncoder which are harmless
# but look alarming to users (e.g., "embeddings.position_ids | UNEXPECTED")
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=UserWarning)
warnings.filterwarnings("ignore", message=".*was not found in model state dict.*")
warnings.filterwarnings("ignore", message=".*UNEXPECTED.*")
# Also suppress transformers library logging temporarily
transformers_logger = logging.getLogger("transformers")
original_level = transformers_logger.level
transformers_logger.setLevel(logging.ERROR)
try:
self._model = CrossEncoder(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
trust_remote_code=self.trust_remote_code,
)
finally:
# Restore original logging level
transformers_logger.setLevel(original_level)
# Disable lazy loading (meta tensors) which causes issues with newer transformers/accelerate.
# Setting low_cpu_mem_usage=False and device_map=None ensures tensors are fully materialized.
self._model = CrossEncoder(
self.model_name,
model_kwargs={"low_cpu_mem_usage": False, "device_map": None},
)
# Initialize shared executor (limited workers naturally limits concurrency)
if LocalSTCrossEncoder._executor is None:
@@ -209,11 +153,6 @@ class LocalSTCrossEncoder(CrossEncoderModel):
else:
logger.info("Reranker: local provider initialized (using existing executor)")
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous prediction wrapper for thread pool execution."""
scores = self._model.predict(pairs, show_progress_bar=False)
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Score query-document pairs for relevance.
@@ -231,11 +170,11 @@ class LocalSTCrossEncoder(CrossEncoderModel):
# Use dedicated executor - limited workers naturally limits concurrency
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
scores = await loop.run_in_executor(
LocalSTCrossEncoder._executor,
self._predict_sync,
pairs,
lambda: self._model.predict(pairs, show_progress_bar=False),
)
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
class RemoteTEICrossEncoder(CrossEncoderModel):
@@ -558,104 +497,6 @@ class CohereCrossEncoder(CrossEncoderModel):
return all_scores
class ZeroEntropyCrossEncoder(CrossEncoderModel):
"""
ZeroEntropy cross-encoder implementation using the ZeroEntropy Rerank API.
Supports zerank-2 (flagship) and zerank-2-small models.
See: https://docs.zeroentropy.dev/models
"""
RERANK_URL = "https://api.zeroentropy.dev/v1/models/rerank"
def __init__(
self,
api_key: str,
model: str = DEFAULT_RERANKER_ZEROENTROPY_MODEL,
timeout: float = 60.0,
):
"""
Initialize ZeroEntropy cross-encoder client.
Args:
api_key: ZeroEntropy API key
model: ZeroEntropy rerank model name (default: zerank-2)
timeout: Request timeout in seconds (default: 60.0)
"""
self.api_key = api_key
self.model = model
self.timeout = timeout
self._async_client: httpx.AsyncClient | None = None
@property
def provider_name(self) -> str:
return "zeroentropy"
async def initialize(self) -> None:
"""Initialize the async HTTP client."""
if self._async_client is not None:
return
logger.info(f"Reranker: initializing ZeroEntropy provider with model {self.model}")
self._async_client = httpx.AsyncClient(
timeout=self.timeout,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
)
logger.info("Reranker: ZeroEntropy provider initialized")
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Score query-document pairs using the ZeroEntropy Rerank API.
Args:
pairs: List of (query, document) tuples to score
Returns:
List of relevance scores
"""
if self._async_client is None:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
if not pairs:
return []
# Group pairs by query for efficient batching
query_groups: dict[str, list[tuple[int, str]]] = {}
for idx, (query, text) in enumerate(pairs):
if query not in query_groups:
query_groups[query] = []
query_groups[query].append((idx, text))
all_scores = [0.0] * len(pairs)
for query, indexed_texts in query_groups.items():
texts = [text for _, text in indexed_texts]
indices = [idx for idx, _ in indexed_texts]
response = await self._async_client.post(
self.RERANK_URL,
json={
"model": self.model,
"query": query,
"documents": texts,
"top_n": len(texts),
},
)
response.raise_for_status()
result = response.json()
# Map scores back to original positions
for item in result.get("results", []):
original_idx = item["index"]
score = item["relevance_score"]
all_scores[indices[original_idx]] = score
return all_scores
class RRFPassthroughCrossEncoder(CrossEncoderModel):
"""
Passthrough cross-encoder that preserves RRF scores without neural reranking.
@@ -743,7 +584,7 @@ class FlashRankCrossEncoder(CrossEncoderModel):
return
try:
from flashrank import Ranker
from flashrank import Ranker # type: ignore[import-untyped]
except ImportError:
raise ImportError("flashrank is required for FlashRankCrossEncoder. Install it with: pip install flashrank")
@@ -770,7 +611,7 @@ class FlashRankCrossEncoder(CrossEncoderModel):
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
"""Synchronous predict - processes each query group."""
from flashrank import RerankRequest
from flashrank import RerankRequest # type: ignore[import-untyped]
if not pairs:
return []
@@ -930,199 +771,50 @@ class LiteLLMCrossEncoder(CrossEncoderModel):
return all_scores
class LiteLLMSDKCrossEncoder(CrossEncoderModel):
"""
LiteLLM SDK cross-encoder for direct API integration.
Supports reranking via LiteLLM SDK without requiring a proxy server.
Supported providers: Cohere, DeepInfra, Together AI, HuggingFace, Jina AI, Voyage AI, AWS Bedrock.
Example model names:
- cohere/rerank-english-v3.0
- deepinfra/Qwen3-reranker-8B
- together_ai/Salesforce/Llama-Rank-V1
- huggingface/BAAI/bge-reranker-v2-m3
"""
def __init__(
self,
api_key: str,
model: str = DEFAULT_RERANKER_LITELLM_SDK_MODEL,
api_base: str | None = None,
timeout: float = 60.0,
):
"""
Initialize LiteLLM SDK cross-encoder client.
Args:
api_key: API key for the reranking provider
model: Model name with provider prefix (e.g., "deepinfra/Qwen3-reranker-8B")
api_base: Custom base URL for API (optional)
timeout: Request timeout in seconds (default: 60.0)
"""
self.api_key = api_key
self.model = model
self.api_base = api_base
self.timeout = timeout
self._initialized = False
self._litellm = None # Will be set during initialization
@property
def provider_name(self) -> str:
return "litellm-sdk"
async def initialize(self) -> None:
"""Initialize the LiteLLM SDK client."""
if self._initialized:
return
try:
import litellm
self._litellm = litellm # Store reference
except ImportError:
raise ImportError("litellm is required for LiteLLMSDKCrossEncoder. Install it with: pip install litellm")
api_base_msg = f" at {self.api_base}" if self.api_base else ""
logger.info(f"Reranker: initializing LiteLLM SDK provider with model {self.model}{api_base_msg}")
self._initialized = True
logger.info("Reranker: LiteLLM SDK provider initialized")
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
"""
Score query-document pairs using the LiteLLM SDK.
Args:
pairs: List of (query, document) tuples to score
Returns:
List of relevance scores
"""
if not self._initialized:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
if not pairs:
return []
# Group pairs by query for efficient batching
# LiteLLM rerank expects one query with multiple documents
query_groups: dict[str, list[tuple[int, str]]] = {}
for idx, (query, text) in enumerate(pairs):
if query not in query_groups:
query_groups[query] = []
query_groups[query].append((idx, text))
all_scores = [0.0] * len(pairs)
for query, indexed_texts in query_groups.items():
texts = [text for _, text in indexed_texts]
indices = [idx for idx, _ in indexed_texts]
# Build kwargs for rerank call
rerank_kwargs = {
"model": self.model,
"query": query,
"documents": texts,
"api_key": self.api_key,
}
if self.api_base:
rerank_kwargs["api_base"] = self.api_base
response = await self._litellm.arerank(**rerank_kwargs)
# Map scores back to original positions
# Response format: RerankResponse with results list
# Each result is a TypedDict with "index" and "relevance_score"
if hasattr(response, "results") and response.results:
for result in response.results:
# Results are TypedDicts, use dict-style access
original_idx = result["index"]
score = result.get("relevance_score", result.get("score", 0.0))
all_scores[indices[original_idx]] = score
elif isinstance(response, list):
# Direct list of scores (unlikely but defensive)
for i, score in enumerate(response):
all_scores[indices[i]] = score
else:
logger.warning(f"Unexpected response format from LiteLLM rerank: {type(response)}")
return all_scores
def create_cross_encoder_from_env() -> CrossEncoderModel:
"""
Create a CrossEncoderModel instance based on configuration.
Create a CrossEncoderModel instance based on environment variables.
Reads configuration via get_config() to ensure consistency across the codebase.
See hindsight_api.config for environment variable names and defaults.
Returns:
Configured CrossEncoderModel instance
"""
from ..config import get_config
config = get_config()
provider = config.reranker_provider.lower()
provider = os.environ.get(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER).lower()
if provider == "tei":
url = config.reranker_tei_url
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'")
return RemoteTEICrossEncoder(
base_url=url,
batch_size=config.reranker_tei_batch_size,
max_concurrent=config.reranker_tei_max_concurrent,
)
batch_size = int(os.environ.get(ENV_RERANKER_TEI_BATCH_SIZE, str(DEFAULT_RERANKER_TEI_BATCH_SIZE)))
max_concurrent = int(os.environ.get(ENV_RERANKER_TEI_MAX_CONCURRENT, str(DEFAULT_RERANKER_TEI_MAX_CONCURRENT)))
return RemoteTEICrossEncoder(base_url=url, batch_size=batch_size, max_concurrent=max_concurrent)
elif provider == "local":
return LocalSTCrossEncoder(
model_name=config.reranker_local_model,
max_concurrent=config.reranker_local_max_concurrent,
force_cpu=config.reranker_local_force_cpu,
trust_remote_code=config.reranker_local_trust_remote_code,
model = os.environ.get(ENV_RERANKER_LOCAL_MODEL)
model_name = model or DEFAULT_RERANKER_LOCAL_MODEL
max_concurrent = int(
os.environ.get(ENV_RERANKER_LOCAL_MAX_CONCURRENT, str(DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT))
)
return LocalSTCrossEncoder(model_name=model_name, max_concurrent=max_concurrent)
elif provider == "cohere":
api_key = config.reranker_cohere_api_key
api_key = os.environ.get(ENV_COHERE_API_KEY)
if not api_key:
raise ValueError(f"{ENV_RERANKER_COHERE_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'cohere'")
return CohereCrossEncoder(
api_key=api_key,
model=config.reranker_cohere_model,
base_url=config.reranker_cohere_base_url,
)
raise ValueError(f"{ENV_COHERE_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'cohere'")
model = os.environ.get(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL)
base_url = os.environ.get(ENV_RERANKER_COHERE_BASE_URL) or None
return CohereCrossEncoder(api_key=api_key, model=model, base_url=base_url)
elif provider == "flashrank":
model = os.environ.get(ENV_RERANKER_FLASHRANK_MODEL, DEFAULT_RERANKER_FLASHRANK_MODEL)
cache_dir = os.environ.get(ENV_RERANKER_FLASHRANK_CACHE_DIR, DEFAULT_RERANKER_FLASHRANK_CACHE_DIR)
return FlashRankCrossEncoder(model_name=model, cache_dir=cache_dir)
elif provider == "litellm":
return LiteLLMCrossEncoder(
api_base=config.reranker_litellm_api_base,
api_key=config.reranker_litellm_api_key,
model=config.reranker_litellm_model,
)
elif provider == "litellm-sdk":
api_key = config.reranker_litellm_sdk_api_key
if not api_key:
raise ValueError(
f"{ENV_RERANKER_LITELLM_SDK_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'litellm-sdk'"
)
return LiteLLMSDKCrossEncoder(
api_key=api_key,
model=config.reranker_litellm_sdk_model,
api_base=config.reranker_litellm_sdk_api_base,
)
elif provider == "zeroentropy":
api_key = config.reranker_zeroentropy_api_key
if not api_key:
raise ValueError(
f"{ENV_RERANKER_ZEROENTROPY_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'zeroentropy'"
)
return ZeroEntropyCrossEncoder(
api_key=api_key,
model=config.reranker_zeroentropy_model,
)
api_base = os.environ.get(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE)
api_key = os.environ.get(ENV_LITELLM_API_KEY)
model = os.environ.get(ENV_RERANKER_LITELLM_MODEL, DEFAULT_RERANKER_LITELLM_MODEL)
return LiteLLMCrossEncoder(api_base=api_base, api_key=api_key, model=model)
elif provider == "rrf":
return RRFPassthroughCrossEncoder()
else:
raise ValueError(
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'zeroentropy', 'flashrank', 'litellm', 'litellm-sdk', 'rrf'"
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'flashrank', 'litellm', 'rrf'"
)
@@ -20,7 +20,6 @@ RETRYABLE_EXCEPTIONS = (
asyncpg.exceptions.InterfaceError,
asyncpg.exceptions.ConnectionDoesNotExistError,
asyncpg.exceptions.TooManyConnectionsError,
asyncpg.exceptions.DeadlockDetectedError,
OSError,
ConnectionError,
asyncio.TimeoutError,
@@ -1,5 +0,0 @@
"""Directives module for hard rules injected into prompts."""
from .models import Directive
__all__ = ["Directive"]
@@ -1,37 +0,0 @@
"""Pydantic models for directives."""
from datetime import datetime, timezone
from uuid import UUID
from pydantic import BaseModel, Field
class Directive(BaseModel):
"""A directive is a hard rule injected into prompts.
Directives are user-defined rules that guide agent behavior. Unlike mental models
which are automatically consolidated from memories, directives are explicit
instructions that are always included in relevant prompts.
Examples:
- "Always respond in formal English"
- "Never share personal data with third parties"
- "Prefer conservative investment recommendations"
"""
id: UUID = Field(description="Unique identifier")
bank_id: str = Field(description="Bank this directive belongs to")
name: str = Field(description="Human-readable name")
content: str = Field(description="The directive text to inject into prompts")
priority: int = Field(default=0, description="Higher priority directives are injected first")
is_active: bool = Field(default=True, description="Whether this directive is currently active")
tags: list[str] = Field(default_factory=list, description="Tags for filtering")
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), description="When this directive was created"
)
updated_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), description="When this directive was last updated"
)
class Config:
from_attributes = True
+31 -246
View File
@@ -11,7 +11,6 @@ Configuration via environment variables - see hindsight_api.config for all env v
import logging
import os
import warnings
from abc import ABC, abstractmethod
import httpx
@@ -19,23 +18,22 @@ import httpx
from ..config import (
DEFAULT_EMBEDDINGS_COHERE_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_MODEL,
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU,
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
DEFAULT_EMBEDDINGS_OPENAI_MODEL,
DEFAULT_EMBEDDINGS_PROVIDER,
DEFAULT_LITELLM_API_BASE,
ENV_EMBEDDINGS_COHERE_API_KEY,
ENV_EMBEDDINGS_LITELLM_SDK_API_KEY,
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
ENV_COHERE_API_KEY,
ENV_EMBEDDINGS_COHERE_BASE_URL,
ENV_EMBEDDINGS_COHERE_MODEL,
ENV_EMBEDDINGS_LITELLM_MODEL,
ENV_EMBEDDINGS_LOCAL_MODEL,
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
ENV_EMBEDDINGS_OPENAI_API_KEY,
ENV_EMBEDDINGS_OPENAI_BASE_URL,
ENV_EMBEDDINGS_OPENAI_MODEL,
ENV_EMBEDDINGS_PROVIDER,
ENV_EMBEDDINGS_TEI_URL,
ENV_LITELLM_API_BASE,
ENV_LITELLM_API_KEY,
ENV_LLM_API_KEY,
)
@@ -94,22 +92,15 @@ class LocalSTEmbeddings(Embeddings):
The embedding dimension is auto-detected from the model.
"""
def __init__(self, model_name: str | None = None, force_cpu: bool = False, trust_remote_code: bool = False):
def __init__(self, model_name: str | None = None):
"""
Initialize local SentenceTransformers embeddings.
Args:
model_name: Name of the SentenceTransformer model to use.
Default: BAAI/bge-small-en-v1.5
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
Default: False
trust_remote_code: Allow loading models with custom code (security risk).
Required for some models with custom architectures.
Default: False (disabled for security)
"""
self.model_name = model_name or DEFAULT_EMBEDDINGS_LOCAL_MODEL
self.force_cpu = force_cpu
self.trust_remote_code = trust_remote_code
self._model = None
self._dimension: int | None = None
@@ -137,53 +128,12 @@ class LocalSTEmbeddings(Embeddings):
)
logger.info(f"Embeddings: initializing local provider with model {self.model_name}")
# Determine device based on hardware availability.
# We always set low_cpu_mem_usage=False to prevent lazy loading (meta tensors)
# which can cause issues when accelerate is installed but no GPU is available.
import torch
# Force CPU mode if configured (used in daemon mode to avoid MPS/XPC issues on macOS)
if self.force_cpu:
device = "cpu"
logger.info("Embeddings: forcing CPU mode")
else:
# Check for GPU (CUDA) or Apple Silicon (MPS)
# Wrap in try-except to gracefully handle any device detection issues
# (e.g., in CI environments or when PyTorch is built without GPU support)
device = "cpu" # Default to CPU
try:
has_gpu = torch.cuda.is_available() or (
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
)
if has_gpu:
device = None # Let sentence-transformers auto-detect GPU/MPS
except Exception as e:
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
# Suppress verbose transformers warnings during model loading
# This suppresses the "UNEXPECTED" warnings from BertModel which are harmless
# but look alarming to users (e.g., "embeddings.position_ids | UNEXPECTED")
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=UserWarning)
warnings.filterwarnings("ignore", message=".*was not found in model state dict.*")
warnings.filterwarnings("ignore", message=".*UNEXPECTED.*")
# Also suppress transformers library logging temporarily
transformers_logger = logging.getLogger("transformers")
original_level = transformers_logger.level
transformers_logger.setLevel(logging.ERROR)
try:
self._model = SentenceTransformer(
self.model_name,
device=device,
model_kwargs={"low_cpu_mem_usage": False},
trust_remote_code=self.trust_remote_code,
)
finally:
# Restore original logging level
transformers_logger.setLevel(original_level)
# Disable lazy loading (meta tensors) which causes issues with newer transformers/accelerate
# Setting low_cpu_mem_usage=False and device_map=None ensures tensors are fully materialized
self._model = SentenceTransformer(
self.model_name,
model_kwargs={"low_cpu_mem_usage": False, "device_map": None},
)
self._dimension = self._model.get_sentence_embedding_dimension()
logger.info(f"Embeddings: local provider initialized (dim: {self._dimension})")
@@ -200,7 +150,6 @@ class LocalSTEmbeddings(Embeddings):
"""
if self._model is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
return [emb.tolist() for emb in embeddings]
@@ -567,7 +516,7 @@ class CohereEmbeddings(Embeddings):
model=self.model,
input_type=self.input_type,
)
if response.embeddings and isinstance(response.embeddings, list):
if response.embeddings:
self._dimension = len(response.embeddings[0])
logger.info(f"Embeddings: Cohere provider initialized (model: {self.model}, dim: {self._dimension})")
@@ -722,175 +671,26 @@ class LiteLLMEmbeddings(Embeddings):
return all_embeddings
class LiteLLMSDKEmbeddings(Embeddings):
"""
LiteLLM SDK embeddings for direct API integration.
Supports embeddings via LiteLLM SDK without requiring a proxy server.
Supported providers: Cohere, OpenAI, Azure OpenAI, HuggingFace, Voyage AI, Together AI, etc.
Example model names:
- cohere/embed-english-v3.0
- openai/text-embedding-3-small
- together_ai/togethercomputer/m2-bert-80M-8k-retrieval
- voyage/voyage-2
"""
def __init__(
self,
api_key: str,
model: str = DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL,
api_base: str | None = None,
batch_size: int = 100,
timeout: float = 60.0,
):
"""
Initialize LiteLLM SDK embeddings client.
Args:
api_key: API key for the embedding provider
model: Model name with provider prefix (e.g., "cohere/embed-english-v3.0")
api_base: Custom base URL for API (optional)
batch_size: Maximum batch size for embedding requests (default: 100)
timeout: Request timeout in seconds (default: 60.0)
"""
self.api_key = api_key
self.model = model
self.api_base = api_base
self.batch_size = batch_size
self.timeout = timeout
self._litellm = None # Will be set during initialization
self._dimension: int | None = None
@property
def provider_name(self) -> str:
return "litellm-sdk"
@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 LiteLLM SDK client and detect dimension."""
if self._litellm is not None:
return
try:
import litellm
self._litellm = litellm # Store reference
except ImportError:
raise ImportError("litellm is required for LiteLLMSDKEmbeddings. Install it with: pip install litellm")
api_base_msg = f" at {self.api_base}" if self.api_base else ""
logger.info(f"Embeddings: initializing LiteLLM SDK provider with model {self.model}{api_base_msg}")
# Do a test embedding to detect dimension
try:
# Build kwargs for embedding call
embed_kwargs = {
"model": self.model,
"input": ["test"],
"api_key": self.api_key,
"encoding_format": "float",
}
if self.api_base:
embed_kwargs["api_base"] = self.api_base
# Use async embedding method (standard in litellm)
response = await self._litellm.aembedding(**embed_kwargs)
# Extract dimension from response
if response.data and len(response.data) > 0:
self._dimension = len(response.data[0]["embedding"])
else:
raise RuntimeError(f"Unable to detect embedding dimension for model {self.model}")
except Exception as e:
raise RuntimeError(f"Failed to initialize LiteLLM SDK embeddings: {e}")
logger.info(f"Embeddings: LiteLLM SDK provider initialized (model: {self.model}, dim: {self._dimension})")
def encode(self, texts: list[str]) -> list[list[float]]:
"""
Generate embeddings using the LiteLLM SDK.
Args:
texts: List of text strings to encode
Returns:
List of embedding vectors (one per input text)
"""
if self._litellm 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]
try:
# Build kwargs for embedding call
embed_kwargs = {
"model": self.model,
"input": batch,
"api_key": self.api_key,
"encoding_format": "float",
}
if self.api_base:
embed_kwargs["api_base"] = self.api_base
# Use sync embedding (litellm doesn't have async in thread-safe way)
response = self._litellm.embedding(**embed_kwargs)
# Extract embeddings from response
# Sort by index to ensure correct order
batch_embeddings = sorted(response.data, key=lambda x: x.get("index", 0))
all_embeddings.extend([e["embedding"] for e in batch_embeddings])
except Exception as e:
import traceback
logger.error(
f"Error in LiteLLM embedding for batch starting at index {i}: {e}\n"
f"Traceback: {traceback.format_exc()}"
)
raise
return all_embeddings
def create_embeddings_from_env() -> Embeddings:
"""
Create an Embeddings instance based on configuration.
Create an Embeddings instance based on environment variables.
Reads configuration via get_config() to ensure consistency across the codebase.
See hindsight_api.config for environment variable names and defaults.
Returns:
Configured Embeddings instance
"""
from ..config import get_config
config = get_config()
provider = config.embeddings_provider.lower()
provider = os.environ.get(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER).lower()
if provider == "tei":
url = config.embeddings_tei_url
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'")
return RemoteTEIEmbeddings(base_url=url)
elif provider == "local":
return LocalSTEmbeddings(
model_name=config.embeddings_local_model,
force_cpu=config.embeddings_local_force_cpu,
trust_remote_code=config.embeddings_local_trust_remote_code,
)
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)
@@ -903,33 +703,18 @@ def create_embeddings_from_env() -> Embeddings:
base_url = os.environ.get(ENV_EMBEDDINGS_OPENAI_BASE_URL) or None
return OpenAIEmbeddings(api_key=api_key, model=model, base_url=base_url)
elif provider == "cohere":
api_key = config.embeddings_cohere_api_key
api_key = os.environ.get(ENV_COHERE_API_KEY)
if not api_key:
raise ValueError(f"{ENV_EMBEDDINGS_COHERE_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'cohere'")
return CohereEmbeddings(
api_key=api_key,
model=config.embeddings_cohere_model,
base_url=config.embeddings_cohere_base_url,
)
raise ValueError(f"{ENV_COHERE_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'cohere'")
model = os.environ.get(ENV_EMBEDDINGS_COHERE_MODEL, DEFAULT_EMBEDDINGS_COHERE_MODEL)
base_url = os.environ.get(ENV_EMBEDDINGS_COHERE_BASE_URL) or None
return CohereEmbeddings(api_key=api_key, model=model, base_url=base_url)
elif provider == "litellm":
return LiteLLMEmbeddings(
api_base=config.embeddings_litellm_api_base,
api_key=config.embeddings_litellm_api_key,
model=config.embeddings_litellm_model,
)
elif provider == "litellm-sdk":
api_key = config.embeddings_litellm_sdk_api_key
if not api_key:
raise ValueError(
f"{ENV_EMBEDDINGS_LITELLM_SDK_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'litellm-sdk'"
)
return LiteLLMSDKEmbeddings(
api_key=api_key,
model=config.embeddings_litellm_sdk_model,
api_base=config.embeddings_litellm_sdk_api_base,
)
api_base = os.environ.get(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE)
api_key = os.environ.get(ENV_LITELLM_API_KEY)
model = os.environ.get(ENV_EMBEDDINGS_LITELLM_MODEL, DEFAULT_EMBEDDINGS_LITELLM_MODEL)
return LiteLLMEmbeddings(api_base=api_base, api_key=api_key, model=model)
else:
raise ValueError(
f"Unknown embeddings provider: {provider}. "
f"Supported: 'local', 'tei', 'openai', 'cohere', 'litellm', 'litellm-sdk'"
f"Unknown embeddings provider: {provider}. Supported: 'local', 'tei', 'openai', 'cohere', 'litellm'"
)
@@ -5,10 +5,6 @@ Uses spaCy for entity extraction and implements resolution logic
to disambiguate entities across memory units.
"""
import asyncio
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from datetime import UTC, datetime
from difflib import SequenceMatcher
@@ -16,43 +12,6 @@ import asyncpg
from .db_utils import acquire_with_retry
from .memory_engine import fq_table
from .retain.entity_labels import build_labels_lookup as _build_labels_lookup_from_config
logger = logging.getLogger(__name__)
@dataclass
class _EntityToCreate:
"""An entity that needs to be inserted (no matching candidate found)."""
idx: int
name: str
event_date: datetime | None
@dataclass
class _EntityStat:
"""Stat accumulation entry for a resolved entity (post-transaction update)."""
entity_id: str
event_date: datetime | None
@dataclass
class _EntityStatAgg:
"""Aggregated stats used when flushing pending updates."""
count: int = 0
max_date: datetime | None = None
@dataclass
class _CooccurrencePair:
"""A (entity_id_1, entity_id_2) pair observed in a retain batch (for post-txn flush)."""
entity_id_1: str
entity_id_2: str
# Load spaCy model (singleton)
_nlp = None
@@ -63,95 +22,14 @@ class EntityResolver:
Resolves entities to canonical IDs with disambiguation.
"""
def __init__(self, pool: asyncpg.Pool, entity_lookup: str = "full"):
def __init__(self, pool: asyncpg.Pool):
"""
Initialize entity resolver.
Args:
pool: asyncpg connection pool
entity_lookup: Lookup strategy — "full" loads all bank entities then
matches in Python; "trigram" uses pg_trgm GIN index to fetch only
similar candidates per entity name (much faster for large banks).
"""
self.pool = pool
self.entity_lookup = entity_lookup
# Keyed by asyncio task id so concurrent retain batches never mix their
# pending updates. flush_pending_stats() pops only the calling task's items.
self._pending_stats: dict[int, list[_EntityStat]] = {}
self._pending_cooccurrences: dict[int, list[_CooccurrencePair]] = {}
def _task_key(self) -> int:
"""Return a unique key for the current asyncio task (or 0 for non-task context)."""
task = asyncio.current_task()
return id(task) if task is not None else 0
async def flush_pending_stats(self) -> None:
"""
Flush accumulated entity stats and co-occurrence counts for the current task.
Must be called AFTER the retain transaction commits. Pops only the items
accumulated by the calling asyncio task so concurrent retain batches never
flush each other's uncommitted entity IDs.
"""
if self.pool is None:
return
key = self._task_key()
stats = self._pending_stats.pop(key, [])
cooccurrences = self._pending_cooccurrences.pop(key, [])
if not stats and not cooccurrences:
return
async with acquire_with_retry(self.pool) as conn:
if stats:
# Aggregate: sum counts and find max date per entity_id.
agg: dict[str, _EntityStatAgg] = defaultdict(_EntityStatAgg)
for s in stats:
entry = agg[s.entity_id]
entry.count += 1
if s.event_date is not None:
entry.max_date = s.event_date if entry.max_date is None else max(entry.max_date, s.event_date)
# Sort by entity_id so all concurrent workers acquire row locks in
# the same order — prevents circular lock dependencies (deadlocks).
rows = sorted((eid, a.count, a.max_date) for eid, a in agg.items())
await conn.executemany(
f"""
UPDATE {fq_table("entities")} SET
mention_count = mention_count + $2,
last_seen = GREATEST(last_seen, $3)
WHERE id = $1::uuid
""",
rows,
)
if cooccurrences:
# Aggregate: count occurrences per (entity_id_1, entity_id_2) pair.
coo_agg: dict[tuple[str, str], int] = {}
for c in cooccurrences:
pair = (c.entity_id_1, c.entity_id_2)
coo_agg[pair] = coo_agg.get(pair, 0) + 1
now = datetime.now(UTC)
# Sort by (entity_id_1, entity_id_2) for consistent lock ordering.
await conn.executemany(
f"""
INSERT INTO {fq_table("entity_cooccurrences")}
(entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
VALUES ($1, $2, $3, $4)
ON CONFLICT (entity_id_1, entity_id_2)
DO UPDATE SET
cooccurrence_count = {fq_table("entity_cooccurrences")}.cooccurrence_count + EXCLUDED.cooccurrence_count,
last_cooccurred = GREATEST({fq_table("entity_cooccurrences")}.last_cooccurred, EXCLUDED.last_cooccurred)
""",
sorted((e1, e2, count, now) for (e1, e2), count in coo_agg.items()),
)
@staticmethod
def _build_labels_lookup(entity_labels: list | None) -> set[str]:
"""Build a set of valid 'key:value' entity label strings for fast lookup."""
return _build_labels_lookup_from_config(entity_labels)
async def resolve_entities_batch(
self,
@@ -160,7 +38,6 @@ class EntityResolver:
context: str,
unit_event_date,
conn=None,
entity_labels: list | None = None,
) -> list[str]:
"""
Resolve multiple entities in batch (MUCH faster than sequential).
@@ -181,34 +58,15 @@ class EntityResolver:
if not entities_data:
return []
taxonomy_lookup = self._build_labels_lookup(entity_labels)
if conn is None:
async with acquire_with_retry(self.pool) as conn:
return await self._resolve_entities_batch_impl(
conn, bank_id, entities_data, context, unit_event_date, taxonomy_lookup
)
return await self._resolve_entities_batch_impl(conn, bank_id, entities_data, context, unit_event_date)
else:
return await self._resolve_entities_batch_impl(
conn, bank_id, entities_data, context, unit_event_date, taxonomy_lookup
)
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,
taxonomy_lookup: set[str] | None = None,
self, conn, bank_id: str, entities_data: list[dict], context: str, unit_event_date
) -> list[str]:
if self.entity_lookup == "trigram":
return await self._resolve_entities_batch_trigram(conn, bank_id, entities_data, unit_event_date)
return await self._resolve_entities_batch_full(conn, bank_id, entities_data, unit_event_date)
async def _resolve_entities_batch_full(
self, conn, bank_id: str, entities_data: list[dict], unit_event_date
) -> list[str]:
"""Original strategy: load all bank entities then match in Python."""
# Query ALL candidates for this bank
all_entities = await conn.fetch(
f"""
@@ -272,103 +130,10 @@ class EntityResolver:
matching.append((ent_id, canonical_name, metadata, last_seen, mention_count))
all_candidates[entity_text] = matching
return await self._resolve_from_candidates(
conn, bank_id, entities_data, unit_event_date, all_candidates, cooccurrence_map
)
async def _resolve_entities_batch_trigram(
self, conn, bank_id: str, entities_data: list[dict], unit_event_date
) -> list[str]:
"""
Trigram strategy: fetch only similar candidates per entity name using pg_trgm.
Instead of loading all bank entities (O(N)), uses a GIN trigram index to fetch
only the small set of candidates that are textually similar to each input name.
Reduces DB data transfer from 165K rows to ~5-20 rows per entity.
"""
entity_texts = list(set(e["text"] for e in entities_data))
# Fetch candidates for all unique entity texts in a single batched query.
# The trigram % operator uses the GIN index; the substring conditions cover
# exact prefix/suffix matches that trigrams might miss at low similarity.
rows = await conn.fetch(
f"""
SELECT DISTINCT ON (e.id)
e.id, e.canonical_name, e.metadata, e.last_seen, e.mention_count,
q.query_text
FROM unnest($2::text[]) AS q(query_text)
JOIN {fq_table("entities")} e ON (
e.bank_id = $1
AND (
e.canonical_name % q.query_text
OR LOWER(e.canonical_name) LIKE '%' || LOWER(q.query_text) || '%'
OR LOWER(q.query_text) LIKE '%' || LOWER(e.canonical_name) || '%'
)
)
""",
bank_id,
entity_texts,
)
# Group candidates by query_text
all_candidates: dict[str, list] = {t: [] for t in entity_texts}
candidate_ids: set = set()
for row in rows:
query_text = row["query_text"]
all_candidates[query_text].append(
(row["id"], row["canonical_name"], row["metadata"], row["last_seen"], row["mention_count"])
)
candidate_ids.add(row["id"])
# Fetch co-occurrences only for the candidate entities (not all bank entities)
cooccurrence_map: dict[str, set[str]] = {}
if candidate_ids:
candidate_id_list = list(candidate_ids)
cooc_rows = await conn.fetch(
f"""
SELECT ec.entity_id_1, ec.entity_id_2
FROM {fq_table("entity_cooccurrences")} ec
WHERE ec.entity_id_1 = ANY($1::uuid[])
OR ec.entity_id_2 = ANY($1::uuid[])
""",
candidate_id_list,
)
# Build name lookup for co-occurrence mapping
id_to_name = {
row["id"]: row["canonical_name"].lower()
for cands in all_candidates.values()
for row in [{"id": c[0], "canonical_name": c[1]} for c in cands]
}
for row in cooc_rows:
eid1, eid2 = row["entity_id_1"], row["entity_id_2"]
if eid1 not in cooccurrence_map:
cooccurrence_map[eid1] = set()
if eid2 not in cooccurrence_map:
cooccurrence_map[eid2] = set()
if eid2 in id_to_name:
cooccurrence_map[eid1].add(id_to_name[eid2])
if eid1 in id_to_name:
cooccurrence_map[eid2].add(id_to_name[eid1])
return await self._resolve_from_candidates(
conn, bank_id, entities_data, unit_event_date, all_candidates, cooccurrence_map
)
async def _resolve_from_candidates(
self,
conn,
bank_id: str,
entities_data: list[dict],
unit_event_date,
all_candidates: dict[str, list],
cooccurrence_map: dict[str, set[str]],
) -> list[str]:
"""Shared scoring + upsert logic used by both lookup strategies."""
# Resolve each entity using pre-fetched candidates
entity_ids = [None] * len(entities_data)
entities_to_update: list[_EntityStat] = []
entities_to_create: list[_EntityToCreate] = []
entities_to_update = [] # (entity_id, event_date)
entities_to_create = [] # (idx, entity_data, event_date)
for idx, entity_data in enumerate(entities_data):
entity_text = entity_data["text"]
@@ -380,7 +145,7 @@ class EntityResolver:
if not candidates:
# Will create new entity
entities_to_create.append(_EntityToCreate(idx=idx, name=entity_text, event_date=entity_event_date))
entities_to_create.append((idx, entity_data, entity_event_date))
continue
# Score candidates
@@ -424,83 +189,73 @@ class EntityResolver:
if best_score > threshold:
entity_ids[idx] = best_candidate
entities_to_update.append(_EntityStat(entity_id=best_candidate, event_date=entity_event_date))
entities_to_update.append((best_candidate, entity_event_date))
else:
entities_to_create.append(
_EntityToCreate(idx=idx, name=entity_data["text"], event_date=entity_event_date)
)
entities_to_create.append((idx, entity_data, entity_event_date))
# Existing entities: IDs already known from the candidate SELECT above.
# No in-transaction UPDATE — mention_count/last_seen are stats deferred to
# flush_pending_stats() which the orchestrator calls after the transaction.
pending: list[_EntityStat] = list(entities_to_update)
# Batch update existing entities
if entities_to_update:
await conn.executemany(
f"""
UPDATE {fq_table("entities")} SET
mention_count = mention_count + 1,
last_seen = $2
WHERE id = $1::uuid
""",
entities_to_update,
)
# New entities: INSERT with DO NOTHING to avoid row locks on concurrent races.
# ON CONFLICT DO NOTHING returns nothing for rows that conflicted; we handle
# that rare case with a fallback SELECT.
# Batch create new entities using COPY + INSERT for maximum speed
# This handles duplicates via ON CONFLICT and returns all IDs
if entities_to_create:
# Group by lowercase name — deduplicate within the batch.
@dataclass
class _NameGroup:
name: str
event_date: datetime | None
indices: list[int] = field(default_factory=list)
# Group entities by canonical name (lowercase) to handle duplicates within batch
# For duplicates, we only insert once and reuse the ID, but track the count
unique_entities = {} # lowercase_name -> (entity_data, event_date, [indices])
for idx, entity_data, event_date in entities_to_create:
name_lower = entity_data["text"].lower()
if name_lower not in unique_entities:
unique_entities[name_lower] = (entity_data, event_date, [idx])
else:
# Same entity appears multiple times - add index to list
unique_entities[name_lower][2].append(idx)
groups: dict[str, _NameGroup] = {}
for e in entities_to_create:
name_lower = e.name.lower()
if name_lower not in groups:
groups[name_lower] = _NameGroup(name=e.name, event_date=e.event_date)
groups[name_lower].indices.append(e.idx)
# Batch insert unique entities and get their IDs
# Use a single query with unnest for speed
entity_names = []
entity_dates = []
entity_counts = [] # Track how many times each entity appears in this batch
indices_map = [] # Maps result index -> list of original indices
# Sort by lowercase name for deterministic ordering.
sorted_groups = sorted(groups.items())
entity_names = [g.name for _, g in sorted_groups]
entity_dates = [g.event_date for _, g in sorted_groups]
for name_lower, (entity_data, event_date, indices) in unique_entities.items():
entity_names.append(entity_data["text"])
entity_dates.append(event_date)
entity_counts.append(len(indices)) # Count of occurrences in this batch
indices_map.append(indices)
# INSERT ... ON CONFLICT DO NOTHING — no row lock on already-existing entities.
inserted_rows = await conn.fetch(
# Batch INSERT ... ON CONFLICT with RETURNING
# Uses the batch count for mention_count instead of always 1
rows = await conn.fetch(
f"""
INSERT INTO {fq_table("entities")} (bank_id, canonical_name, first_seen, last_seen, mention_count)
SELECT $1, name, COALESCE(event_date, now()), COALESCE(event_date, now()), 1
FROM unnest($2::text[], $3::timestamptz[]) AS t(name, event_date)
SELECT $1, name, event_date, event_date, cnt
FROM unnest($2::text[], $3::timestamptz[], $4::int[]) AS t(name, event_date, cnt)
ON CONFLICT (bank_id, LOWER(canonical_name))
DO NOTHING
RETURNING id, LOWER(canonical_name) AS name_lower
DO UPDATE SET
mention_count = {fq_table("entities")}.mention_count + EXCLUDED.mention_count,
last_seen = EXCLUDED.last_seen
RETURNING id
""",
bank_id,
entity_names,
entity_dates,
entity_counts,
)
id_by_name: dict[str, str] = {row["name_lower"]: row["id"] for row in inserted_rows}
# Fallback SELECT for names that conflicted (another worker won the race).
missing = [n for n, _ in sorted_groups if n not in id_by_name]
if missing:
existing_rows = await conn.fetch(
f"""
SELECT id, LOWER(canonical_name) AS name_lower
FROM {fq_table("entities")}
WHERE bank_id = $1 AND LOWER(canonical_name) = ANY($2::text[])
""",
bank_id,
missing,
)
for row in existing_rows:
id_by_name[row["name_lower"]] = row["id"]
# Assign entity IDs back and queue for post-txn stats flush.
for name_lower, g in sorted_groups:
entity_id = id_by_name.get(name_lower)
if entity_id:
for original_idx in g.indices:
entity_ids[original_idx] = entity_id
pending.append(_EntityStat(entity_id=entity_id, event_date=g.event_date))
# Accumulate into the resolver's pending list; the orchestrator flushes
# these with await entity_resolver.flush_pending_stats() after the txn.
key = self._task_key()
self._pending_stats.setdefault(key, []).extend(pending)
# Map returned IDs back to original indices
for result_idx, row in enumerate(rows):
entity_id = row["id"]
for original_idx in indices_map[result_idx]:
entity_ids[original_idx] = entity_id
return entity_ids
@@ -653,7 +408,7 @@ class EntityResolver:
entity_id = await conn.fetchval(
f"""
INSERT INTO {fq_table("entities")} (bank_id, canonical_name, first_seen, last_seen, mention_count)
VALUES ($1, $2, COALESCE($3, now()), COALESCE($4, now()), 1)
VALUES ($1, $2, $3, $4, 1)
ON CONFLICT (bank_id, LOWER(canonical_name))
DO UPDATE SET
mention_count = {fq_table("entities")}.mention_count + 1,
@@ -786,14 +541,19 @@ class EntityResolver:
entity_id_1, entity_id_2 = entity_id_2, entity_id_1
cooccurrence_pairs.add((entity_id_1, entity_id_2))
# Accumulate co-occurrence pairs for post-transaction flush.
# The actual INSERT/UPDATE is deferred to flush_pending_stats() to avoid
# row-level lock contention (ON CONFLICT DO UPDATE inside a long transaction
# serialises concurrent writers on popular entity pairs).
# Batch update co-occurrences
if cooccurrence_pairs:
key = self._task_key()
self._pending_cooccurrences.setdefault(key, []).extend(
_CooccurrencePair(entity_id_1=e1, entity_id_2=e2) for e1, e2 in cooccurrence_pairs
now = datetime.now(UTC)
await conn.executemany(
f"""
INSERT INTO {fq_table("entity_cooccurrences")} (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
VALUES ($1, $2, $3, $4)
ON CONFLICT (entity_id_1, entity_id_2)
DO UPDATE SET
cooccurrence_count = {fq_table("entity_cooccurrences")}.cooccurrence_count + 1,
last_cooccurred = EXCLUDED.last_cooccurred
""",
[(e1, e2, 1, now) for e1, e2 in cooccurrence_pairs],
)
async def get_units_by_entity(self, entity_id: str, limit: int = 100) -> list[str]:
+45 -11
View File
@@ -12,7 +12,6 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import RecallResult, ReflectResult
from hindsight_api.engine.search.tags import TagsMatch
from hindsight_api.models import RequestContext
@@ -49,7 +48,6 @@ class MemoryEngineInterface(ABC):
contents: list[dict[str, Any]],
*,
request_context: "RequestContext",
document_tags: list[str] | None = None,
) -> dict[str, Any]:
"""
Retain a batch of memory items.
@@ -57,9 +55,8 @@ class MemoryEngineInterface(ABC):
Args:
bank_id: The memory bank ID.
contents: List of content dicts with 'content', optional 'event_date',
'context', 'metadata', 'document_id', and per-item 'tags'.
'context', 'metadata', 'document_id'.
request_context: Request context for authentication.
document_tags: Optional tags applied to all items in the batch.
Returns:
Dict with processing results.
@@ -338,8 +335,6 @@ class MemoryEngineInterface(ABC):
bank_id: str,
*,
search_query: str | None = None,
tags: list[str] | None = None,
tags_match: "TagsMatch" = "any_strict",
limit: int = 100,
offset: int = 0,
request_context: "RequestContext",
@@ -349,9 +344,7 @@ class MemoryEngineInterface(ABC):
Args:
bank_id: The memory bank ID.
search_query: Case-insensitive substring filter on document ID.
tags: Filter by tags.
tags_match: How to match tags (any, all, any_strict, all_strict).
search_query: Search query.
limit: Maximum results.
offset: Pagination offset.
request_context: Request context for authentication.
@@ -449,6 +442,49 @@ class MemoryEngineInterface(ABC):
"""
...
@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
# =========================================================================
@@ -568,7 +604,6 @@ class MemoryEngineInterface(ABC):
contents: list[dict[str, Any]],
*,
request_context: "RequestContext",
document_tags: list[str] | None = None,
) -> dict[str, Any]:
"""
Submit a batch retain operation to run asynchronously.
@@ -577,7 +612,6 @@ class MemoryEngineInterface(ABC):
bank_id: The memory bank ID.
contents: List of content dicts to retain.
request_context: Request context for authentication.
document_tags: Optional tags applied to all items in the async batch.
Returns:
Dict with operation_id and items_count.
@@ -1,207 +0,0 @@
"""
Abstract interface for LLM providers.
This module defines the interface that all LLM providers must implement,
enabling support for multiple LLM backends (OpenAI, Anthropic, Gemini, Codex, etc.)
"""
from abc import ABC, abstractmethod
from typing import Any
from .response_models import LLMToolCallResult, TokenUsage
class LLMInterface(ABC):
"""
Abstract interface for LLM providers.
All LLM provider implementations must inherit from this class and implement
the required methods.
"""
def __init__(
self,
provider: str,
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
**kwargs: Any,
):
"""
Initialize LLM provider.
Args:
provider: Provider name (e.g., "openai", "codex", "anthropic", "gemini").
api_key: API key or authentication token.
base_url: Base URL for the API.
model: Model name.
reasoning_effort: Reasoning effort level for supported providers.
**kwargs: Additional provider-specific parameters.
"""
self.provider = provider.lower()
self.api_key = api_key
self.base_url = base_url
self.model = model
self.reasoning_effort = reasoning_effort
@abstractmethod
async def verify_connection(self) -> None:
"""
Verify that the LLM provider is configured correctly by making a simple test call.
Raises:
RuntimeError: If the connection test fails.
"""
pass
@abstractmethod
async def call(
self,
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.
Args:
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
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).
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
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.
Raises:
OutputTooLongError: If output exceeds token limits.
Exception: Re-raises API errors after retries exhausted.
"""
pass
@abstractmethod
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or specific function.
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
pass
async def supports_batch_api(self) -> bool:
"""
Check if this provider supports batch API operations.
Returns:
True if provider supports submit_batch/get_batch_status/retrieve_batch_results
"""
return False
async def submit_batch(
self,
requests: list[dict[str, Any]],
endpoint: str = "/v1/chat/completions",
completion_window: str = "24h",
) -> dict[str, Any]:
"""
Submit a batch of requests to the provider's batch API.
Args:
requests: List of request dicts in JSONL format (custom_id, method, url, body)
endpoint: API endpoint for the batch (e.g., "/v1/chat/completions")
completion_window: Completion window (e.g., "24h")
Returns:
Dict with batch metadata: {"batch_id": str, "status": str, ...}
Raises:
NotImplementedError: If provider doesn't support batch API
"""
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
async def get_batch_status(self, batch_id: str) -> dict[str, Any]:
"""
Get the status of a batch job.
Args:
batch_id: Batch identifier returned from submit_batch
Returns:
Dict with status info: {"batch_id": str, "status": str, "completed_at": str, ...}
Raises:
NotImplementedError: If provider doesn't support batch API
"""
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
async def retrieve_batch_results(self, batch_id: str) -> list[dict[str, Any]]:
"""
Retrieve completed batch results.
Args:
batch_id: Batch identifier returned from submit_batch
Returns:
List of result dicts (one per request, matched by custom_id)
Raises:
NotImplementedError: If provider doesn't support batch API
"""
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
@abstractmethod
async def cleanup(self) -> None:
"""Clean up resources (close connections, etc.)."""
pass
class OutputTooLongError(Exception):
"""
Bridge exception raised when LLM output exceeds token limits.
This wraps provider-specific errors (e.g., OpenAI's LengthFinishReasonError)
to allow callers to handle output length issues without depending on
provider-specific implementations.
"""
pass
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,16 @@
"""
Mental models module for Hindsight.
Mental models contain directives - hard rules that are injected into reflect prompts.
Directives are user-defined and their observations are user-provided (not LLM-generated).
Mental models are synthesized summaries that represent understanding. They come
in different subtypes based on how they were created:
Other types of consolidated knowledge are handled by:
- Learnings: Automatic bottom-up consolidation from facts
- Pinned Reflections: User-curated living documents
- Structural: Derived from the bank's mission (e.g., "Be a PM for engineering team")
These are created upfront based on what any agent with this role would need.
- Emergent: Discovered from data patterns (named entities, temporal clusters, etc.)
These surface organically as facts are retained.
- Pinned: User-defined models that persist across refreshes.
"""
from .models import MentalModel, MentalModelSubtype
@@ -0,0 +1,311 @@
"""
Emergent mental model detection and promotion.
Emergent models are discovered from data patterns:
- Named entity extraction (people, projects, systems)
- Temporal clustering (events with multiple references)
- Causal patterns ("Because X, we do Y")
- Behavioral anchors ("After X, we started Y")
- Reference frequency (anything mentioned repeatedly)
When a pattern is detected, it goes through a mission filter to check relevance,
and if relevant, is promoted to a mental model.
"""
import logging
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field
from .models import EmergentCandidate
if TYPE_CHECKING:
from ..llm_wrapper import LLMConfig
logger = logging.getLogger(__name__)
class MissionFilterCandidate(BaseModel):
"""Result of mission filtering for a single candidate."""
name: str
promote: bool = Field(description="True if this is a specific named entity worth tracking")
reason: str = Field(description="Brief explanation for the decision")
class MissionFilterResponse(BaseModel):
"""Response from LLM for mission filtering."""
candidates: list[MissionFilterCandidate] = Field(description="Filtering decision for each candidate")
def build_mission_filter_prompt(mission: str, candidates: list[EmergentCandidate]) -> str:
"""Build the prompt for filtering candidates by mission relevance."""
candidate_list = "\n".join(
[f"- {c.name} (mentions: {c.mention_count}, method: {c.detection_method})" for c in candidates]
)
return f"""Filter these detected entities. For each one, decide: promote=true or promote=false.
MISSION: {mission}
DETECTED ENTITIES:
{candidate_list}
=== DECISION RULES ===
Set promote=true ONLY for specific, named entities:
- Person names: "John", "Maria", "Alice Chen", "Dr. Smith"
- Named organizations: "Google", "Acme Corp", "Frontend Team"
- Named places: "Central Park Zoo", "NYC Office", "Building A"
- Named projects: "Project Phoenix", "Auth Service v2"
Set promote=false for EVERYTHING ELSE, including:
- Common English words: user, support, help, family, kids, parents, friends, people, team, photo, nature, park, office, home, work, school, joy, love, hope, fear, anger, gratitude, kindness, passion, motivation, inspiration, encouragement, positivity, energy, community, connection, commitment, collaboration, growth, impact, difference, success, progress, change, education, volunteering, veterans, homeless, shelter, meeting, project, system, process, event
- Generic categories (even capitalized): Users, Customers, Team, Family, Kids, Veterans, Community
- Abstract concepts: motivation, inspiration, gratitude, commitment, resilience
THE TEST: Is this a specific name you'd find in a contact list or org chart?
- "John" → YES (promote=true)
- "kids" → NO (promote=false)
- "community" → NO (promote=false)
- "Maria" → YES (promote=true)
- "park" → NO (promote=false)
When in doubt, set promote=false."""
def get_mission_filter_system_message() -> str:
"""System message for mission filtering."""
return """You filter entities for promotion. Output JSON with 'candidates' array.
Rules:
- promote=true ONLY for specific names (people, organizations, named places/projects)
- promote=false for common words, generic categories, abstract concepts
Examples:
- "John" → promote=true (person name)
- "kids" → promote=false (generic category)
- "community" → promote=false (abstract concept)
- "Google" → promote=true (organization name)
- "motivation" → promote=false (abstract concept)
When in doubt, promote=false. Most entities should be rejected."""
async def filter_candidates_by_mission(
llm_config: "LLMConfig",
mission: str,
candidates: list[EmergentCandidate],
) -> list[EmergentCandidate]:
"""
Filter emergent candidates to keep only specific, named entities.
Args:
llm_config: LLM configuration
mission: The bank's mission (used for context)
candidates: List of detected candidates
Returns:
Filtered list of candidates that are specific named entities
"""
if not candidates:
return []
if not mission:
# No mission = no filtering, keep all candidates
logger.debug("[EMERGENT] No mission set, skipping filter")
return candidates
prompt = build_mission_filter_prompt(mission, candidates)
try:
result = await llm_config.call(
messages=[
{"role": "system", "content": get_mission_filter_system_message()},
{"role": "user", "content": prompt},
],
response_format=MissionFilterResponse,
scope="mental_model_mission_filter",
)
# Build name -> promote map
promote_map = {c.name: c.promote for c in result.candidates}
# Filter candidates
filtered = []
for candidate in candidates:
if candidate.name in promote_map:
if promote_map[candidate.name]:
filtered.append(candidate)
logger.debug(f"[EMERGENT] Promoting '{candidate.name}'")
else:
logger.debug(f"[EMERGENT] Rejecting '{candidate.name}'")
else:
# Candidate not in response - reject by default
logger.debug(f"[EMERGENT] '{candidate.name}' not in response, rejecting")
logger.info(f"[EMERGENT] Mission filter: {len(filtered)}/{len(candidates)} candidates promoted")
return filtered
except Exception as e:
logger.warning(f"[EMERGENT] Mission filter failed, rejecting all candidates: {e}")
return []
async def evaluate_emergent_models(
llm_config: "LLMConfig",
models: list[dict],
) -> list[str]:
"""
Evaluate existing emergent models to check if they should be kept.
This re-evaluates emergent models using the same filtering criteria
as new candidates. Models that are generic/abstract will be removed.
Args:
llm_config: LLM configuration
models: List of existing emergent model dicts with 'name', 'id'
Returns:
List of model IDs that should be REMOVED (no longer valid)
"""
if not models:
return []
# Convert existing models to candidates for evaluation
candidates = [
EmergentCandidate(
name=m["name"],
detection_method="existing_emergent_model",
mention_count=0,
)
for m in models
]
# Build a simple prompt for re-evaluation
names_list = "\n".join([f"- {m['name']}" for m in models])
prompt = f"""Re-evaluate these existing mental models. For each one, decide: promote=true (keep) or promote=false (remove).
EXISTING MODELS:
{names_list}
=== DECISION RULES ===
Set promote=true ONLY for specific, named entities:
- Person names: "John", "Maria", "Alice Chen", "Dr. Smith"
- Named organizations: "Google", "Acme Corp", "Frontend Team"
- Named places: "Central Park Zoo", "NYC Office", "Building A"
- Named projects: "Project Phoenix", "Auth Service v2"
Set promote=false for EVERYTHING ELSE, including:
- Common English words: user, support, help, family, kids, parents, friends, people, team, photo, nature, park, office, home, work, school, joy, love, hope, fear, anger, gratitude, kindness, passion, motivation, inspiration, encouragement, positivity, energy, community, connection, commitment, collaboration, growth, impact, difference, success, progress, change, education, volunteering, veterans, homeless, shelter, meeting, project, system, process, event
- Generic categories (even capitalized): Users, Customers, Team, Family, Kids, Veterans, Community
- Abstract concepts: motivation, inspiration, gratitude, commitment, resilience
THE TEST: Is this a specific name you'd find in a contact list or org chart?
- "John" → YES (promote=true)
- "kids" → NO (promote=false)
- "community" → NO (promote=false)
When in doubt, set promote=false."""
try:
result = await llm_config.call(
messages=[
{"role": "system", "content": get_mission_filter_system_message()},
{"role": "user", "content": prompt},
],
response_format=MissionFilterResponse,
scope="mental_model_emergent_evaluation",
)
# Build name -> promote map
promote_map = {c.name: c.promote for c in result.candidates}
# Find models to remove
models_to_remove = []
for model in models:
name = model["name"]
if name in promote_map:
if not promote_map[name]:
models_to_remove.append(model["id"])
else:
logger.debug(f"[EMERGENT] Keeping '{name}'")
else:
# Model not in response - remove to be safe
logger.info(f"[EMERGENT] '{name}' not in evaluation response, marking for removal")
models_to_remove.append(model["id"])
logger.info(f"[EMERGENT] Evaluation: {len(models_to_remove)}/{len(models)} emergent models marked for removal")
return models_to_remove
except Exception as e:
logger.warning(f"[EMERGENT] Evaluation failed, keeping all models: {e}")
return []
async def detect_entity_candidates(
pool,
bank_id: str,
min_mentions: int = 5,
top_percent: int = 20,
) -> list[EmergentCandidate]:
"""
Detect entities that are candidates for promotion to mental models.
Args:
pool: Database connection pool
bank_id: Bank identifier
min_mentions: Minimum mention count to consider
top_percent: Only consider top X% by mention count
Returns:
List of entity candidates
"""
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
candidates = []
async with acquire_with_retry(pool) as conn:
# Get entities that meet criteria and don't already have mental models
rows = await conn.fetch(
f"""
WITH ranked AS (
SELECT
e.id,
e.canonical_name,
e.mention_count,
PERCENT_RANK() OVER (ORDER BY e.mention_count DESC) as rank_pct
FROM {fq_table("entities")} e
LEFT JOIN {fq_table("mental_models")} mm
ON mm.entity_id = e.id AND mm.bank_id = e.bank_id
WHERE e.bank_id = $1
AND e.mention_count >= $2
AND mm.id IS NULL -- Not already a mental model
)
SELECT id, canonical_name, mention_count
FROM ranked
WHERE rank_pct <= $3
ORDER BY mention_count DESC
LIMIT 50
""",
bank_id,
min_mentions,
top_percent / 100.0,
)
for row in rows:
candidates.append(
EmergentCandidate(
name=row["canonical_name"],
detection_method="named_entity_extraction",
mention_count=row["mention_count"],
entity_id=str(row["id"]),
relevance_score=0.0,
)
)
logger.debug(f"[EMERGENT] Detected {len(candidates)} entity candidates")
return candidates
@@ -9,14 +9,12 @@ from pydantic import BaseModel, Field
class MentalModelSubtype(str, Enum):
"""Subtype of mental model.
Currently only DIRECTIVE is supported. Other types of consolidated knowledge
are handled by:
- Learnings: Automatic bottom-up consolidation from facts
- Pinned Reflections: User-curated living documents
"""
"""Subtype of mental model - how it was created."""
STRUCTURAL = "structural" # Derived from mission, created upfront
EMERGENT = "emergent" # Discovered from data patterns
LEARNED = "learned" # Formed through reflection
PINNED = "pinned" # User-defined topic, observations LLM-generated
DIRECTIVE = "directive" # User-defined hard rules, observations user-provided
@@ -51,3 +49,50 @@ class MentalModel(BaseModel):
created_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc), description="When this model was created"
)
class StructuralModelTemplate(BaseModel):
"""
A template for a structural mental model.
Generated by LLM based on the bank's mission. Represents what any agent
with this role would need to track.
"""
id: str = Field(default="", description="Existing model ID to keep, or empty for new models")
name: str = Field(description="Human-readable name")
description: str = Field(description="What this model should track")
initial_probes: list[str] = Field(default_factory=list, description="Initial search queries to populate this model")
class StructuralModelDerivationResponse(BaseModel):
"""Response from LLM for structural model derivation."""
templates: list[StructuralModelTemplate] = Field(description="Structural model templates derived from the mission")
class EmergentCandidate(BaseModel):
"""
A candidate for promotion to emergent mental model.
Detected through pattern analysis of facts.
"""
name: str = Field(description="Name of the detected pattern/entity")
detection_method: str = Field(description="How this candidate was detected")
mention_count: int = Field(default=0, description="How many times referenced")
entity_id: str | None = Field(default=None, description="Entity ID if detected as entity")
relevance_score: float = Field(default=0.0, description="Score from mission filter (0-1)")
class ResearchResult(BaseModel):
"""
Result from the research endpoint.
Contains the answer along with the mental models and facts used.
"""
answer: str = Field(description="The synthesized answer")
mental_models_used: list[str] = Field(default_factory=list, description="IDs of mental models that contributed")
facts_used: list[str] = Field(default_factory=list, description="Fact IDs that contributed")
question_type: str | None = Field(default=None, description="Detected question type (WHO, WHAT, HOW, etc.)")
@@ -0,0 +1,228 @@
"""
Structural mental model derivation from bank mission.
Structural models are derived from the bank's mission - they represent what
any agent with this role would need to track. For example:
Mission: "Be a PM for engineering team"
Structural models:
- Team Structure (who's on the team, roles)
- Project Overview (current projects, status)
- Processes (how releases work, how decisions are made)
- Key Systems (what we own, dependencies)
"""
import logging
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field
from .models import StructuralModelTemplate
if TYPE_CHECKING:
from ..llm_wrapper import LLMConfig
logger = logging.getLogger(__name__)
class StructuralDerivationResponse(BaseModel):
"""Response from LLM for structural model derivation."""
templates: list[StructuralModelTemplate] = Field(description="Structural model templates derived from the mission")
class StructuralRelevanceResult(BaseModel):
"""Result of evaluating a structural model's relevance to the mission."""
name: str
relevant: bool
reason: str
class StructuralRelevanceResponse(BaseModel):
"""Response from LLM for structural model relevance evaluation."""
models: list[StructuralRelevanceResult] = Field(description="Relevance evaluation for each model")
def build_structural_derivation_prompt(mission: str, existing_models: list[dict] | None = None) -> str:
"""Build the prompt for deriving structural models from a mission."""
existing_section = ""
if existing_models:
model_list = "\n".join([f"- id='{m['id']}' name='{m['name']}': {m['description']}" for m in existing_models])
existing_section = f"""
EXISTING STRUCTURAL MODELS:
{model_list}
IMPORTANT: If keeping an existing model, you MUST return its EXACT 'id' value.
Models not included in your output will be REMOVED.
"""
return f"""Given this agent mission, identify the KEY THINGS to track to achieve it.
MISSION: {mission}
{existing_section}
IMPORTANT CONSTRAINTS:
- Return 0-3 structural models MAXIMUM (less is better!)
- Only include models for SPECIFIC, CONCRETE things the agent needs to track
- Each model must be DIRECTLY tied to achieving the mission
- If the mission is simple, return 0 models (empty array is fine)
- If existing models are provided and you want to keep one, use its EXACT id
- Do NOT create near-duplicates (e.g., don't create "topic-map" if "topic-connections" exists)
GOOD examples (specific, actionable):
- Mission: "Be a PM for engineering team""Team Members" (track who's on the team)
- Mission: "Track customer feedback""Customer Issues" (track specific complaints/requests)
- Mission: "Manage project X""Project X Milestones" (track progress)
BAD examples (too generic, don't create these):
- "Processes", "Workflows", "Key Systems", "Important Events"
- "Communication", "Collaboration", "Progress", "Status"
- Generic role-based models not tied to the specific mission
For each model:
1. id: Use EXACT existing id if keeping a model, or leave empty for new models
2. name: Short, specific name (e.g., "Team Members", "Sprint Goals")
3. description: One line describing what to track
4. initial_probes: 2-3 search queries to find relevant information
Return ONLY the models that should exist. Existing models not in your output will be deleted."""
def get_structural_derivation_system_message() -> str:
"""System message for structural model derivation."""
return """You identify the key things to track for a mission. Be VERY selective.
Rules:
- Maximum 3 models (prefer fewer)
- Only SPECIFIC, CONCRETE things - not generic categories
- Each must DIRECTLY help achieve the mission
- Empty array is valid if no models are truly needed
- If existing models are shown and you want to keep one, return its EXACT id
- Never create duplicates - if a similar model exists, keep the existing one
Output JSON with 'templates' array (can be empty)."""
def _normalize_id(text: str) -> str:
"""Normalize a string to a canonical form for comparison.
Removes common suffixes, pluralization, and normalizes separators.
"""
# Lowercase and normalize separators
normalized = text.lower().replace(" ", "-").replace("_", "-")
# Remove common suffixes that indicate the same concept
suffixes_to_remove = ["-map", "-list", "-overview", "-tracker", "-s"]
for suffix in suffixes_to_remove:
if normalized.endswith(suffix) and len(normalized) > len(suffix):
normalized = normalized[: -len(suffix)]
return normalized
def _find_similar_existing_id(new_id: str, existing_models: list[dict]) -> str | None:
"""Find an existing model ID that is similar to the new ID.
Returns the existing ID if a similar one is found, None otherwise.
"""
if not existing_models:
return None
new_normalized = _normalize_id(new_id)
for model in existing_models:
existing_id = model.get("id", "")
existing_normalized = _normalize_id(existing_id)
# Check if one is a prefix of the other (normalized)
if new_normalized.startswith(existing_normalized) or existing_normalized.startswith(new_normalized):
return existing_id
# Check if they're the same when normalized
if new_normalized == existing_normalized:
return existing_id
return None
async def derive_structural_models(
llm_config: "LLMConfig",
mission: str,
existing_models: list[dict] | None = None,
) -> tuple[list[StructuralModelTemplate], list[str]]:
"""
Derive structural model templates from a bank's mission.
This combines derivation and evaluation in one call. The LLM sees existing
models and decides which to keep. Any existing model not in the output
will be marked for removal.
Args:
llm_config: LLM configuration for calling the model
mission: The bank's mission (e.g., "Be a PM for engineering team")
existing_models: Optional list of existing model dicts with 'name', 'description', 'id'
Returns:
Tuple of (templates to create/keep, IDs of existing models to remove)
Raises:
Exception: If LLM call fails
"""
prompt = build_structural_derivation_prompt(mission, existing_models)
result = await llm_config.call(
messages=[
{"role": "system", "content": get_structural_derivation_system_message()},
{"role": "user", "content": prompt},
],
response_format=StructuralDerivationResponse,
scope="mental_model_structural_derivation",
)
templates = result.templates
logger.info(f"[STRUCTURAL] LLM returned {len(templates)} structural models")
# Build set of existing IDs for quick lookup
existing_ids = {m["id"] for m in existing_models} if existing_models else set()
# Process templates: validate IDs, deduplicate, assign stable IDs
processed_templates: list[StructuralModelTemplate] = []
kept_existing_ids: set[str] = set()
for template in templates:
# If LLM returned an ID, check if it's a valid existing ID
if template.id and template.id in existing_ids:
# LLM is keeping an existing model
kept_existing_ids.add(template.id)
processed_templates.append(template)
logger.info(f"[STRUCTURAL] Keeping existing model: {template.id}")
else:
# New model or LLM didn't return a valid ID
# Generate ID from name
generated_id = template.name.lower().replace(" ", "-").replace("_", "-")
# Check for similar existing models to prevent near-duplicates
similar_id = _find_similar_existing_id(generated_id, existing_models)
if similar_id and similar_id not in kept_existing_ids:
# Use the existing similar model instead of creating a new one
logger.info(f"[STRUCTURAL] Detected near-duplicate: '{generated_id}' matches existing '{similar_id}'")
template.id = similar_id
kept_existing_ids.add(similar_id)
else:
template.id = generated_id
processed_templates.append(template)
# Find existing models to remove (not kept in LLM output)
models_to_remove = []
if existing_models:
for model in existing_models:
if model["id"] not in kept_existing_ids:
logger.info(f"[STRUCTURAL] Marking '{model['name']}' (id={model['id']}) for removal")
models_to_remove.append(model["id"])
if models_to_remove:
logger.info(f"[STRUCTURAL] {len(models_to_remove)} existing models will be removed")
return processed_templates, models_to_remove
@@ -1,69 +0,0 @@
"""
Typed metadata models for async operations.
These dataclasses define the structure of result_metadata for different operation types.
The metadata is exposed in the API for debugging purposes and may change without notice.
"""
from dataclasses import asdict, dataclass
from typing import Any
@dataclass
class BatchRetainParentMetadata:
"""Metadata for parent batch_retain operations (when split into sub-batches)."""
items_count: int
total_tokens: int
num_sub_batches: int
is_parent: bool = True
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for JSON serialization."""
return asdict(self)
@dataclass
class BatchRetainChildMetadata:
"""Metadata for child batch_retain operations (individual sub-batches)."""
items_count: int
parent_operation_id: str
sub_batch_index: int
total_sub_batches: int
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for JSON serialization."""
return asdict(self)
@dataclass
class RetainMetadata:
"""Metadata for regular retain operations (non-batched, deprecated async path)."""
items_count: int
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for JSON serialization."""
return asdict(self)
@dataclass
class ConsolidationMetadata:
"""Metadata for consolidation operations."""
# Currently empty, but structure for future fields
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for JSON serialization."""
return asdict(self)
@dataclass
class RefreshMentalModelMetadata:
"""Metadata for mental model refresh operations."""
mental_model_id: str
def to_dict(self) -> dict[str, Any]:
"""Convert to dict for JSON serialization."""
return asdict(self)
@@ -1,62 +0,0 @@
"""File parser implementations."""
from .base import FileParser, UnsupportedFileTypeError
from .iris import IrisParser
from .markitdown import MarkitdownParser
__all__ = ["FileParser", "UnsupportedFileTypeError", "IrisParser", "MarkitdownParser", "FileParserRegistry"]
class FileParserRegistry:
"""Registry for file parsers with auto-detection."""
def __init__(self):
"""Initialize empty parser registry."""
self._parsers: dict[str, FileParser] = {}
def register(self, parser: FileParser):
"""
Register a parser.
Args:
parser: FileParser instance
"""
self._parsers[parser.name()] = parser
def get_parser(
self,
name: str | None,
filename: str,
content_type: str | None = None,
) -> FileParser:
"""
Get parser by name or auto-detect.
Args:
name: Parser name (e.g., "markitdown") or None for auto-detect
filename: File name for auto-detection
content_type: MIME type (optional)
Returns:
FileParser instance
Raises:
ValueError: If no suitable parser found
"""
if name:
# Explicit parser requested — return it directly, let the parser
# raise UnsupportedFileTypeError from convert() if needed
if name not in self._parsers:
raise ValueError(f"Parser '{name}' not found. Available: {list(self._parsers.keys())}")
return self._parsers[name]
# Auto-detect parser
for parser in self._parsers.values():
if parser.supports(filename, content_type):
return parser
raise ValueError(f"No parser found for {filename}. Available parsers: {list(self._parsers.keys())}")
def list_parsers(self) -> list[str]:
"""Get list of registered parser names."""
return list(self._parsers.keys())
@@ -1,58 +0,0 @@
"""Abstract base class for file parsers."""
from abc import ABC, abstractmethod
class UnsupportedFileTypeError(Exception):
"""Raised by a parser when it does not support the given file type."""
pass
class FileParser(ABC):
"""Abstract base for file to markdown parsers."""
@abstractmethod
async def convert(self, file_data: bytes, filename: str) -> str:
"""
Parse file to markdown.
Args:
file_data: Raw file bytes
filename: Original filename (used for format detection)
Returns:
Markdown content as string
Raises:
UnsupportedFileTypeError: If the file type is not supported by this parser
RuntimeError: If parsing fails for another reason
"""
pass
def supports(self, filename: str, content_type: str | None = None) -> bool:
"""
Check if parser supports this file type.
Override this for local/static extension-based filtering.
Parsers that delegate to a remote service should leave this as True
and raise UnsupportedFileTypeError from convert() instead.
Args:
filename: File name (used for extension check)
content_type: MIME type (optional)
Returns:
True if this parser can handle the file (default: True)
"""
return True
@abstractmethod
def name(self) -> str:
"""
Get parser name.
Returns:
Parser name (e.g., "markitdown")
"""
pass
@@ -1,137 +0,0 @@
"""Iris parser implementation using the Vectorize Iris HTTP API."""
import asyncio
import logging
import mimetypes
import time
import httpx
from .base import FileParser, UnsupportedFileTypeError
logger = logging.getLogger(__name__)
_IRIS_BASE_URL = "https://api.vectorize.io/v1"
_DEFAULT_POLL_INTERVAL = 2.0 # seconds
_DEFAULT_TIMEOUT = 300.0 # seconds
class IrisParser(FileParser):
"""
Iris file parser using the Vectorize Iris cloud extraction service.
Uploads files to the Vectorize Iris API, starts an extraction job,
and polls until the text is ready. The API determines which file types
are supported — UnsupportedFileTypeError is raised if the file is rejected.
Authentication:
Requires HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN and
HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID environment variables,
or pass them explicitly via the constructor.
"""
def __init__(
self,
token: str,
org_id: str,
poll_interval: float = _DEFAULT_POLL_INTERVAL,
timeout: float = _DEFAULT_TIMEOUT,
):
"""
Initialize iris parser.
Args:
token: Vectorize API token
org_id: Vectorize organization ID
poll_interval: Seconds between status poll requests (default: 2)
timeout: Maximum seconds to wait for extraction (default: 300)
"""
self._token = token
self._org_id = org_id
self._poll_interval = poll_interval
self._timeout = timeout
self._auth_headers = {"Authorization": f"Bearer {token}"}
async def convert(self, file_data: bytes, filename: str) -> str:
"""
Parse file to text using the Vectorize Iris API.
Raises:
UnsupportedFileTypeError: If the Iris API rejects the file type (4xx)
RuntimeError: If extraction fails for another reason
"""
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
async with httpx.AsyncClient() as client:
# Step 1: Request a presigned upload URL
init_resp = await client.post(
f"{_IRIS_BASE_URL}/org/{self._org_id}/files",
headers=self._auth_headers,
json={"name": filename, "contentType": content_type},
)
_raise_for_status(init_resp, filename, "file upload init")
init_data = init_resp.json()
file_id: str = init_data["fileId"]
upload_url: str = init_data["uploadUrl"]
# Step 2: Upload the file bytes to the presigned URL (no auth header)
upload_resp = await client.put(
upload_url,
content=file_data,
headers={"Content-Type": content_type},
)
_raise_for_status(upload_resp, filename, "file upload")
# Step 3: Start extraction
extract_resp = await client.post(
f"{_IRIS_BASE_URL}/org/{self._org_id}/extraction",
headers=self._auth_headers,
json={"fileId": file_id},
)
_raise_for_status(extract_resp, filename, "start extraction")
extraction_id: str = extract_resp.json()["extractionId"]
# Step 4: Poll until ready or timeout
deadline = time.monotonic() + self._timeout
while True:
status_resp = await client.get(
f"{_IRIS_BASE_URL}/org/{self._org_id}/extraction/{extraction_id}",
headers=self._auth_headers,
)
_raise_for_status(status_resp, filename, "poll extraction status")
status_data = status_resp.json()
if status_data.get("ready"):
data = status_data.get("data", {})
if not data.get("success"):
error = data.get("error", "unknown error")
raise RuntimeError(f"Iris extraction failed for '{filename}': {error}")
text = data.get("text")
if not text:
raise RuntimeError(f"No content extracted from '{filename}'")
return text
if time.monotonic() >= deadline:
raise RuntimeError(f"Iris extraction timed out after {self._timeout}s for '{filename}'")
await asyncio.sleep(self._poll_interval)
def name(self) -> str:
"""Get parser name."""
return "iris"
def _raise_for_status(response: httpx.Response, filename: str, step: str) -> None:
"""
Raise an appropriate error including the response body on HTTP errors.
Raises UnsupportedFileTypeError for 4xx responses (file rejected by the API),
RuntimeError for other HTTP errors.
"""
if not response.is_error:
return
body = response.text or "<empty>"
msg = f"Iris API error during {step} for '{filename}': {response.status_code} {response.reason_phrase}{body}"
if response.is_client_error:
raise UnsupportedFileTypeError(msg)
raise RuntimeError(msg)
@@ -1,109 +0,0 @@
"""Markitdown parser implementation."""
import asyncio
import logging
import tempfile
from pathlib import Path
from .base import FileParser
logger = logging.getLogger(__name__)
class MarkitdownParser(FileParser):
"""
Markitdown file parser.
Uses Microsoft's markitdown library to convert various file formats
to markdown including PDF, Office docs, images (via OCR), audio, HTML.
Supported formats:
- PDF (.pdf)
- Word (.docx, .doc)
- PowerPoint (.pptx, .ppt)
- Excel (.xlsx, .xls)
- Images (.jpg, .jpeg, .png) - with OCR
- HTML (.html, .htm)
- Text (.txt, .md)
- Audio (.mp3, .wav) - with transcription
"""
def __init__(self):
"""Initialize markitdown parser."""
# Lazy import to avoid requiring markitdown for all users
try:
from markitdown import MarkItDown
self._markitdown = MarkItDown()
except ImportError as e:
raise ImportError(
"markitdown package is required for file parsing. Install with: pip install markitdown"
) from e
async def convert(self, file_data: bytes, filename: str) -> str:
"""Parse file to markdown using markitdown."""
# markitdown is synchronous, so we run it in executor to avoid blocking
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self._convert_sync, file_data, filename)
def _convert_sync(self, file_data: bytes, filename: str) -> str:
"""Synchronous parsing (runs in thread pool)."""
# Write to temp file (markitdown requires file path)
with tempfile.NamedTemporaryFile(suffix=Path(filename).suffix, delete=False) as tmp:
tmp.write(file_data)
tmp_path = tmp.name
try:
# Parse using markitdown
result = self._markitdown.convert(tmp_path)
if not result or not result.text_content:
raise RuntimeError(f"No content extracted from '{filename}'")
return result.text_content
except Exception as e:
logger.error(f"Markitdown parsing failed for {filename}: {e}")
raise RuntimeError(f"Failed to parse '{filename}': {e}") from e
finally:
# Clean up temp file
try:
Path(tmp_path).unlink()
except Exception:
pass
def supports(self, filename: str, content_type: str | None = None) -> bool:
"""Check if markitdown supports this file type."""
# Supported extensions (from markitdown docs)
supported_extensions = {
# Documents
".pdf",
".docx",
".doc",
".pptx",
".ppt",
".xlsx",
".xls",
# Images (with OCR)
".jpg",
".jpeg",
".png",
# Web
".html",
".htm",
# Text
".txt",
".md",
".csv",
# Audio (with transcription)
".mp3",
".wav",
}
ext = Path(filename).suffix.lower()
return ext in supported_extensions
def name(self) -> str:
"""Get parser name."""
return "markitdown"
@@ -1,14 +0,0 @@
"""
LLM provider implementations.
This package contains concrete implementations of the LLMInterface for various providers.
"""
from .anthropic_llm import AnthropicLLM
from .claude_code_llm import ClaudeCodeLLM
from .codex_llm import CodexLLM
from .gemini_llm import GeminiLLM
from .mock_llm import MockLLM
from .openai_compatible_llm import OpenAICompatibleLLM
__all__ = ["AnthropicLLM", "ClaudeCodeLLM", "CodexLLM", "GeminiLLM", "MockLLM", "OpenAICompatibleLLM"]
@@ -1,477 +0,0 @@
"""
Anthropic LLM provider using the Anthropic Python SDK.
This provider enables using Claude models from Anthropic with support for:
- Structured JSON output
- Tool/function calling with proper format conversion
- Extended thinking mode
- Retry logic with exponential backoff
"""
import asyncio
import json
import logging
import time
from typing import Any
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
class AnthropicLLM(LLMInterface):
"""
LLM provider using Anthropic's Claude models.
Supports structured output, tool calling, and extended thinking mode.
Handles format conversion between OpenAI-style messages and Anthropic's format.
"""
def __init__(
self,
provider: str,
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
timeout: float = 300.0,
**kwargs: Any,
):
"""
Initialize Anthropic LLM provider.
Args:
provider: Provider name (should be "anthropic").
api_key: Anthropic API key.
base_url: Base URL for the API (optional, uses Anthropic default if empty).
model: Model name (e.g., "claude-sonnet-4-20250514").
reasoning_effort: Reasoning effort level (not used by Anthropic).
timeout: Request timeout in seconds.
**kwargs: Additional provider-specific parameters.
"""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
if not self.api_key:
raise ValueError("API key is required for Anthropic provider")
# Import and initialize Anthropic client
try:
from anthropic import AsyncAnthropic
client_kwargs: dict[str, Any] = {"api_key": self.api_key}
if self.base_url:
client_kwargs["base_url"] = self.base_url
if timeout:
client_kwargs["timeout"] = timeout
self._client = AsyncAnthropic(**client_kwargs)
logger.info(f"Anthropic client initialized for model: {self.model}")
except ImportError as e:
raise RuntimeError("Anthropic SDK not installed. Run: uv add anthropic or pip install anthropic") from e
async def verify_connection(self) -> None:
"""
Verify that the Anthropic provider is configured correctly by making a simple test call.
Raises:
RuntimeError: If the connection test fails.
"""
try:
test_messages = [{"role": "user", "content": "test"}]
await self.call(
messages=test_messages,
max_completion_tokens=10,
temperature=0.0,
scope="verification",
max_retries=0,
)
logger.info("Anthropic connection verified successfully")
except Exception as e:
logger.error(f"Anthropic connection verification failed: {e}")
raise RuntimeError(f"Failed to verify Anthropic connection: {e}") from e
async def call(
self,
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.
Args:
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
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 (not supported by Anthropic).
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
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.
Raises:
OutputTooLongError: If output exceeds token limits.
Exception: Re-raises API errors after retries exhausted.
"""
from anthropic import APIConnectionError, APIStatusError, RateLimitError
start_time = time.time()
# 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: dict[str, Any] = {
"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
if temperature is not None:
call_params["temperature"] = temperature
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._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 metrics and log slow calls
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
# Record LLM metrics
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# Record trace span
from hindsight_api.tracing import _serialize_for_span, get_span_recorder
finish_reason = response.stop_reason if hasattr(response, "stop_reason") else None
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=_serialize_for_span(result),
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
)
# Log slow calls
if duration > 10.0:
logger.info(
f"slow llm call: scope={scope}, 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_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or specific function.
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
from anthropic import APIConnectionError, APIStatusError
start_time = time.time()
# Convert OpenAI tool format to Anthropic format
anthropic_tools = []
for tool in tools:
func = tool.get("function", {})
anthropic_tools.append(
{
"name": func.get("name", ""),
"description": func.get("description", ""),
"input_schema": func.get("parameters", {"type": "object", "properties": {}}),
}
)
# Convert messages - handle tool results
system_prompt = None
anthropic_messages = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_prompt = (system_prompt + "\n\n" + content) if system_prompt else content
elif role == "tool":
# Anthropic uses tool_result blocks
anthropic_messages.append(
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": msg.get("tool_call_id", ""), "content": content}
],
}
)
elif role == "assistant" and msg.get("tool_calls"):
# Convert assistant tool calls
tool_use_blocks = []
for tc in msg["tool_calls"]:
tool_use_blocks.append(
{
"type": "tool_use",
"id": tc.get("id", ""),
"name": tc.get("function", {}).get("name", ""),
"input": json.loads(tc.get("function", {}).get("arguments", "{}")),
}
)
anthropic_messages.append({"role": "assistant", "content": tool_use_blocks})
else:
anthropic_messages.append({"role": role, "content": content})
call_params: dict[str, Any] = {
"model": self.model,
"messages": anthropic_messages,
"tools": anthropic_tools,
"max_tokens": max_completion_tokens or 4096,
}
if system_prompt:
call_params["system"] = system_prompt
if temperature is not None:
call_params["temperature"] = temperature
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._client.messages.create(**call_params)
# Extract content and tool calls
content_parts = []
tool_calls: list[LLMToolCall] = []
for block in response.content:
if block.type == "text":
content_parts.append(block.text)
elif block.type == "tool_use":
tool_calls.append(LLMToolCall(id=block.id, name=block.name, arguments=block.input or {}))
content = "".join(content_parts) if content_parts else None
finish_reason = "tool_calls" if tool_calls else "stop"
# Extract token usage
input_tokens = response.usage.input_tokens or 0
output_tokens = response.usage.output_tokens or 0
# Record metrics
metrics = get_metrics_collector()
duration = time.time() - start_time
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
if tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
except (APIConnectionError, APIStatusError) as e:
if isinstance(e, APIStatusError) and e.status_code in (401, 403):
raise
last_exception = e
if attempt < max_retries:
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
raise
if last_exception:
raise last_exception
raise RuntimeError("Anthropic tool call failed")
async def cleanup(self) -> None:
"""Clean up resources (close Anthropic client connections)."""
if hasattr(self, "_client") and self._client:
await self._client.close()
@@ -1,513 +0,0 @@
"""
Claude Code LLM provider using Claude Agent SDK.
This provider enables using Claude Pro/Max subscriptions for API calls
via the Claude CLI authentication. It uses the Claude Agent SDK which
automatically handles authentication via `claude auth login` credentials.
"""
import asyncio
import json
import logging
import time
from typing import Any
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
class ClaudeCodeLLM(LLMInterface):
"""
LLM provider using Claude Code authentication.
Authenticates using Claude Pro/Max credentials via `claude auth login`
and makes API calls through the Claude Agent SDK.
"""
def __init__(
self,
provider: str,
api_key: str, # Will be ignored, uses CLI auth
base_url: str,
model: str,
reasoning_effort: str = "low",
**kwargs: Any,
):
"""Initialize Claude Code LLM provider."""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
# Verify Claude Agent SDK is available
try:
self._verify_claude_code_available()
logger.info("Claude Code: Using Claude Agent SDK (authentication via claude auth login)")
except Exception as e:
raise RuntimeError(
f"Failed to initialize Claude Code provider: {e}\n\n"
"To set up Claude Code authentication:\n"
"1. Install Claude Code CLI: npm install -g @anthropics/claude-code\n"
"2. Login with your Pro/Max plan: claude auth login\n"
"3. Verify authentication: claude --version\n\n"
"Or use a different provider (anthropic, openai, gemini) with API keys."
) from e
# Metrics collector is imported at module level
def _verify_claude_code_available(self) -> None:
"""
Verify that Claude Agent SDK can be imported and is properly configured.
Raises:
ImportError: If Claude Agent SDK is not installed.
RuntimeError: If Claude Code is not authenticated.
"""
try:
# Import Claude Agent SDK
# Reduce Claude Agent SDK logging verbosity
import logging as sdk_logging
from claude_agent_sdk import query # noqa: F401
sdk_logging.getLogger("claude_agent_sdk").setLevel(sdk_logging.WARNING)
sdk_logging.getLogger("claude_agent_sdk._internal").setLevel(sdk_logging.WARNING)
logger.debug("Claude Agent SDK imported successfully")
except ImportError as e:
raise ImportError(
"Claude Agent SDK not installed. Run: uv add claude-agent-sdk or pip install claude-agent-sdk"
) from e
# SDK will automatically check for authentication when first used
# No need to verify here - let it fail gracefully on first call with helpful error
async def verify_connection(self) -> None:
"""
Verify that the Claude Code provider is configured correctly by making a simple test call.
Raises:
RuntimeError: If the connection test fails.
"""
try:
test_messages = [{"role": "user", "content": "test"}]
await self.call(
messages=test_messages,
max_completion_tokens=10,
temperature=0.0,
scope="verification",
max_retries=0,
)
logger.info("Claude Code connection verified successfully")
except Exception as e:
logger.error(f"Claude Code connection verification failed: {e}")
raise RuntimeError(f"Failed to verify Claude Code connection: {e}") from e
async def call(
self,
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.
Args:
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Maximum tokens in response (ignored by Claude Agent SDK).
temperature: Sampling temperature (ignored by Claude Agent SDK).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
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 (not supported).
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
If return_usage=False: Parsed response if response_format is provided, otherwise text content.
If return_usage=True: Tuple of (result, TokenUsage) with estimated token counts.
Raises:
OutputTooLongError: If output exceeds token limits (not supported by Claude Agent SDK).
Exception: Re-raises API errors after retries exhausted.
"""
from claude_agent_sdk import AssistantMessage, ClaudeAgentOptions, TextBlock, query
start_time = time.time()
# Build system prompt
system_prompt = ""
user_content = ""
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_prompt += ("\n\n" + content) if system_prompt else content
elif role == "user":
user_content += ("\n\n" + content) if user_content else content
elif role == "assistant":
# Claude Agent SDK doesn't support multi-turn easily in query()
# For now, prepend assistant messages to user content
user_content += f"\n\n[Previous assistant response: {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_instruction = (
f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}\n\n"
"Respond with ONLY the JSON, no markdown formatting."
)
user_content += schema_instruction
# Configure SDK options
options = ClaudeAgentOptions(
system_prompt=system_prompt if system_prompt else None,
max_turns=1, # Single-turn for API-style interactions
allowed_tools=[], # Disable tools for standard LLM calls
)
# Call Claude Agent SDK
last_exception = None
for attempt in range(max_retries + 1):
try:
# Collect streaming response
full_text = ""
async for message in query(prompt=user_content, options=options):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
full_text += block.text
# Handle structured output
if response_format is not None:
# Models may wrap JSON in markdown
clean_text = full_text
if "```json" in full_text:
clean_text = full_text.split("```json")[1].split("```")[0].strip()
elif "```" in full_text:
clean_text = full_text.split("```")[1].split("```")[0].strip()
try:
json_data = json.loads(clean_text)
except json.JSONDecodeError as e:
logger.warning(f"Claude Code JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {e}")
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = e
continue
raise
if skip_validation:
result = json_data
else:
result = response_format.model_validate(json_data)
else:
result = full_text
# Record metrics
duration = time.time() - start_time
metrics = get_metrics_collector()
# Estimate token usage (Claude Agent SDK doesn't report exact counts)
# Use character count / 4 as rough estimate (1 token ≈ 4 characters)
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
estimated_output = len(full_text) // 4
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=estimated_input,
output_tokens=estimated_output,
success=True,
)
# Record trace span
try:
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=result if isinstance(result, str) else result.model_dump_json(),
input_tokens=estimated_input,
output_tokens=estimated_output,
duration=duration,
finish_reason=None,
error=None,
)
except Exception:
pass # logging failure must never affect the operation
# Log slow calls
if duration > 10.0:
logger.info(
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, time={duration:.3f}s"
)
if return_usage:
token_usage = TokenUsage(
input_tokens=estimated_input,
output_tokens=estimated_output,
total_tokens=estimated_input + estimated_output,
)
return result, token_usage
return result
except Exception as e:
last_exception = e
# Check for authentication errors
error_str = str(e).lower()
if "auth" in error_str or "login" in error_str or "credential" in error_str:
logger.error(f"Claude Code authentication error: {e}")
raise RuntimeError(
f"Claude Code authentication failed: {e}\n\n"
"Run 'claude auth login' to authenticate with Claude Pro/Max."
) from e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(f"Claude Code error (attempt {attempt + 1}/{max_retries + 1}): {e}")
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Claude Code error after {max_retries + 1} attempts: {e}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Claude Code call failed after all retries")
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support using Claude Agent SDK.
This implementation uses ClaudeSDKClient (not query()) because custom tools via
SDK MCP servers are only supported with the client. Tools are converted from OpenAI
format to SDK MCP tools, and tool names are formatted as mcp__hindsight_tools__{name}.
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Maximum tokens in response (not used by Claude Agent SDK).
temperature: Sampling temperature (not used by Claude Agent SDK).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools (not used by Claude Agent SDK).
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
SdkMcpTool,
TextBlock,
ToolUseBlock,
create_sdk_mcp_server,
)
start_time = time.time()
# Convert OpenAI tool format to Claude Agent SDK SdkMcpTool format
sdk_tools: list[SdkMcpTool] = []
tool_names: list[str] = []
for tool in tools:
func = tool.get("function", {})
tool_name = func.get("name", "")
tool_description = func.get("description", "")
parameters = func.get("parameters", {})
# Create a handler with proper closure to avoid transport issues
def make_handler(name: str):
async def handler(args: dict[str, Any]) -> dict[str, Any]:
# Return immediately with success - tool execution happens externally
return {
"content": [
{
"type": "text",
"text": f"[Tool {name} called successfully]",
}
]
}
return handler
sdk_tools.append(
SdkMcpTool(
name=tool_name,
description=tool_description,
input_schema=parameters,
handler=make_handler(tool_name),
)
)
tool_names.append(tool_name)
# Create an MCP server with the tools
mcp_server = create_sdk_mcp_server(
name="hindsight_tools",
version="1.0.0",
tools=sdk_tools if sdk_tools else None,
)
# Build system prompt and user content from messages
system_prompt = ""
user_content = ""
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_prompt += ("\n\n" + content) if system_prompt else content
elif role == "user":
user_content += ("\n\n" + content) if user_content else content
elif role == "assistant":
# Include previous assistant messages as context
user_content += f"\n\n[Previous assistant response: {content}]"
elif role == "tool":
# Tool results are already in tool_results_map, append to user context
tool_call_id = msg.get("tool_call_id", "")
user_content += f"\n\n[Tool result for {tool_call_id}: {content}]"
# Format tool names for SDK MCP servers: mcp__{server_name}__{tool_name}
# This is required by the Claude Agent SDK for MCP server tools
allowed_tool_names = [f"mcp__hindsight_tools__{name}" for name in tool_names]
# Configure SDK options with MCP server
options = ClaudeAgentOptions(
system_prompt=system_prompt if system_prompt else None,
max_turns=1, # Single-turn for API-style interactions
mcp_servers={"hindsight_tools": mcp_server} if sdk_tools else {},
allowed_tools=allowed_tool_names if allowed_tool_names else [],
)
# Call Claude Agent SDK with retry logic
last_exception = None
for attempt in range(max_retries + 1):
try:
full_text = ""
tool_calls: list[LLMToolCall] = []
# Use ClaudeSDKClient for tool calling support
# Note: query() does NOT support custom tools, only ClaudeSDKClient does
async with ClaudeSDKClient(options=options) as client:
# Send the query
await client.query(user_content)
# Receive response
async for message in client.receive_response():
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
full_text += block.text
elif isinstance(block, ToolUseBlock):
# SDK returns tool names with MCP prefix (mcp__hindsight_tools__{name})
# Strip the prefix to return original tool name expected by caller
tool_name = block.name
if tool_name.startswith("mcp__hindsight_tools__"):
tool_name = tool_name.replace("mcp__hindsight_tools__", "", 1)
tool_calls.append(
LLMToolCall(
id=block.id,
name=tool_name,
arguments=block.input,
)
)
# Record metrics
duration = time.time() - start_time
metrics = get_metrics_collector()
# Estimate token usage (Claude Agent SDK doesn't report exact counts)
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
estimated_output = len(full_text) // 4
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=estimated_input,
output_tokens=estimated_output,
success=True,
)
# Log slow calls
if duration > 10.0:
logger.info(
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, time={duration:.3f}s"
)
return LLMToolCallResult(
content=full_text if full_text else None,
tool_calls=tool_calls,
finish_reason="tool_calls" if tool_calls else "stop",
input_tokens=estimated_input,
output_tokens=estimated_output,
)
except Exception as e:
last_exception = e
# Check for authentication errors
error_str = str(e).lower()
if "auth" in error_str or "login" in error_str or "credential" in error_str:
logger.error(f"Claude Code authentication error: {e}")
raise RuntimeError(
f"Claude Code authentication failed: {e}\n\n"
"Run 'claude auth login' to authenticate with Claude Pro/Max."
) from e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(f"Claude Code tool call error (attempt {attempt + 1}/{max_retries + 1}): {e}")
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Claude Code tool call error after {max_retries + 1} attempts: {e}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Claude Code tool call failed after all retries")
async def cleanup(self) -> None:
"""Clean up resources (no HTTP client to close for Claude Agent SDK)."""
pass
@@ -1,621 +0,0 @@
"""
OpenAI Codex LLM provider using ChatGPT Plus/Pro OAuth authentication.
This provider enables using ChatGPT Plus/Pro subscriptions for API calls
without separate OpenAI Platform API credits. It uses OAuth tokens from
~/.codex/auth.json and communicates with the ChatGPT backend API.
"""
import asyncio
import json
import logging
import os
import time
import uuid
from pathlib import Path
from typing import Any
import httpx
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
class CodexLLM(LLMInterface):
"""
LLM provider using OpenAI Codex OAuth authentication.
Authenticates using ChatGPT Plus/Pro credentials stored in ~/.codex/auth.json
and makes API calls to chatgpt.com/backend-api/codex/responses.
"""
def __init__(
self,
provider: str,
api_key: str, # Will be ignored, reads from ~/.codex/auth.json
base_url: str,
model: str,
reasoning_effort: str = "low",
**kwargs: Any,
):
"""Initialize Codex LLM provider."""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
# Load Codex OAuth credentials
try:
self.access_token, self.account_id = self._load_codex_auth()
logger.info(f"Loaded Codex OAuth credentials for account: {self.account_id}")
except Exception as e:
raise RuntimeError(
f"Failed to load Codex OAuth credentials from ~/.codex/auth.json: {e}\n\n"
"To set up Codex authentication:\n"
"1. Install Codex CLI: npm install -g @openai/codex\n"
"2. Login: codex auth login\n"
"3. Verify: ls ~/.codex/auth.json\n\n"
"Or use a different provider (openai, anthropic, gemini) with API keys."
) from e
# Use ChatGPT backend API endpoint
if not self.base_url:
self.base_url = "https://chatgpt.com/backend-api"
# Normalize model name (strip openai/ prefix if present)
if self.model.startswith("openai/"):
self.model = self.model[len("openai/") :]
# Map reasoning effort to Codex reasoning summary format
# Codex supports: "auto", "concise", "detailed"
self.reasoning_summary = self._map_reasoning_effort(reasoning_effort)
# HTTP client for SSE streaming
self._client = httpx.AsyncClient(timeout=120.0)
def _load_codex_auth(self) -> tuple[str, str]:
"""
Load OAuth credentials from ~/.codex/auth.json.
Returns:
Tuple of (access_token, account_id).
Raises:
FileNotFoundError: If auth file doesn't exist.
ValueError: If auth file is invalid.
"""
auth_file = Path.home() / ".codex" / "auth.json"
if not auth_file.exists():
raise FileNotFoundError(
f"Codex auth file not found: {auth_file}\nRun 'codex auth login' to authenticate with ChatGPT Plus/Pro."
)
with open(auth_file) as f:
data = json.load(f)
# Validate auth structure
auth_mode = data.get("auth_mode")
if auth_mode != "chatgpt":
raise ValueError(f"Expected auth_mode='chatgpt', got: {auth_mode}")
tokens = data.get("tokens", {})
access_token = tokens.get("access_token")
account_id = tokens.get("account_id")
if not access_token:
raise ValueError("No access_token found in Codex auth file. Run 'codex auth login' again.")
return access_token, account_id
def _map_reasoning_effort(self, effort: str) -> str:
"""
Map standard reasoning effort to Codex reasoning summary format.
Args:
effort: Standard effort level ("low", "medium", "high", "xhigh").
Returns:
Codex reasoning summary: "concise", "detailed", or "auto".
"""
mapping = {
"low": "concise",
"medium": "auto",
"high": "detailed",
"xhigh": "detailed",
}
return mapping.get(effort.lower(), "auto")
async def verify_connection(self) -> None:
"""Verify Codex connection by making a simple test call."""
try:
logger.info(f"Verifying Codex LLM: model={self.model}, account={self.account_id}...")
await self.call(
messages=[{"role": "user", "content": "Say 'ok'"}],
max_completion_tokens=10,
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
scope="verification",
)
logger.info(f"Codex LLM verified: {self.model}")
except Exception as e:
raise RuntimeError(f"Codex LLM connection verification failed for {self.model}: {e}") from e
async def call(
self,
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 API call to Codex backend with SSE streaming."""
start_time = time.time()
# Prepare system instructions
system_instruction = ""
user_messages = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_instruction += ("\n\n" + content) if system_instruction else content
else:
user_messages.append(msg)
# 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)}"
system_instruction += schema_msg
# gpt-5.2-codex only supports "detailed" reasoning summary
reasoning_summary = "detailed" if "5.2" in self.model else self.reasoning_summary
# Build Codex request payload
payload = {
"model": self.model,
"instructions": system_instruction,
"input": [
{
"type": "message",
"role": msg.get("role", "user"),
"content": msg.get("content", ""),
}
for msg in user_messages
],
"tools": [],
"tool_choice": "auto",
"parallel_tool_calls": True,
"reasoning": {"summary": reasoning_summary},
"store": False, # Codex uses stateless mode
"stream": True, # SSE streaming
"include": ["reasoning.encrypted_content"],
"prompt_cache_key": str(uuid.uuid4()),
}
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
"OpenAI-Account-ID": self.account_id,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"Origin": "https://chatgpt.com",
}
url = f"{self.base_url}/codex/responses"
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
response.raise_for_status()
# Parse SSE stream
content = await self._parse_sse_stream(response)
# Handle structured output
if response_format is not None:
# Models may wrap JSON in markdown
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 as e:
logger.warning(f"Codex JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {e}")
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = e
continue
raise
if skip_validation:
result = json_data
else:
result = response_format.model_validate(json_data)
else:
result = content
# Record metrics
duration = time.time() - start_time
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=0, # Codex doesn't report token counts in SSE
output_tokens=0,
success=True,
)
# Record trace span
from hindsight_api.tracing import get_span_recorder
# Estimate tokens for tracing
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
estimated_output = len(content) // 4
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=result if isinstance(result, str) else json.dumps(result),
input_tokens=estimated_input,
output_tokens=estimated_output,
duration=duration,
finish_reason=None,
error=None,
)
if return_usage:
# Codex doesn't provide token counts, estimate based on content
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
estimated_output = len(content) // 4
token_usage = TokenUsage(
input_tokens=estimated_input,
output_tokens=estimated_output,
total_tokens=estimated_input + estimated_output,
)
return result, token_usage
return result
except httpx.HTTPStatusError as e:
last_exception = e
status_code = e.response.status_code
# Fast fail on auth errors
if status_code in (401, 403):
logger.error(f"Codex auth error (HTTP {status_code}): {e.response.text[:200]}")
raise RuntimeError(
"Codex authentication failed. Your OAuth token may have expired.\n"
"Run 'codex auth login' to re-authenticate."
) from e
# Log the actual error message from the API
error_detail = e.response.text[:500] if hasattr(e.response, "text") else str(e)
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(
f"Codex HTTP error {status_code} (attempt {attempt + 1}/{max_retries + 1}): {error_detail}"
)
await asyncio.sleep(backoff)
continue
else:
logger.error(
f"Codex HTTP error after {max_retries + 1} attempts: Status {status_code}, Detail: {error_detail}"
)
raise
except httpx.RequestError as e:
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(f"Codex connection error (attempt {attempt + 1}/{max_retries + 1}): {e}")
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Codex connection error after {max_retries + 1} attempts: {e}")
raise
except Exception as e:
logger.error(f"Unexpected Codex error: {type(e).__name__}: {e}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Codex call failed after all retries")
async def _parse_sse_stream(self, response: httpx.Response) -> str:
"""
Parse Server-Sent Events (SSE) stream from Codex API.
Args:
response: HTTP response with SSE stream.
Returns:
Extracted text content from stream.
"""
full_text = ""
event_type = None
async for line in response.aiter_lines():
if not line:
continue
# Track event type
if line.startswith("event: "):
event_type = line[7:]
# Parse data
elif line.startswith("data: "):
data_str = line[6:]
if data_str == "[DONE]":
break
try:
data = json.loads(data_str)
# Extract content based on event type
if event_type == "response.text.delta" and "delta" in data:
full_text += data["delta"]
elif event_type == "response.content_part.delta" and "delta" in data:
full_text += data["delta"]
# Check for item content
elif "item" in data:
item = data["item"]
if "content" in item:
content = item["content"]
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and "text" in part:
full_text += part["text"]
elif isinstance(content, str):
full_text += content
except json.JSONDecodeError:
# Skip malformed JSON events
pass
return full_text
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""
Make API call with tool calling support.
Parses Codex SSE stream to extract tool calls from response.output_item.done events.
Tools are converted from OpenAI format to Codex format (flat structure at top level).
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature.
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or specific function.
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
start_time = time.time()
# Prepare system instructions
system_instruction = ""
user_messages = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_instruction += ("\n\n" + content) if system_instruction else content
elif role == "tool":
# Handle tool results
user_messages.append(
{
"type": "message",
"role": "user",
"content": f"Tool result: {content}",
}
)
else:
user_messages.append(
{
"type": "message",
"role": role,
"content": content,
}
)
# Convert tools to Codex format
# Codex expects tools with type and name/description/parameters at top level
codex_tools = []
for tool in tools:
func = tool.get("function", {})
codex_tools.append(
{
"type": "function",
"name": func.get("name", ""),
"description": func.get("description", ""),
"parameters": func.get("parameters", {}),
}
)
# gpt-5.2-codex only supports "detailed" reasoning summary
reasoning_summary = "detailed" if "5.2" in self.model else self.reasoning_summary
payload = {
"model": self.model,
"instructions": system_instruction,
"input": user_messages,
"tools": codex_tools,
"tool_choice": tool_choice,
"parallel_tool_calls": True,
"reasoning": {"summary": reasoning_summary},
"store": False,
"stream": True,
"include": ["reasoning.encrypted_content"],
"prompt_cache_key": str(uuid.uuid4()),
}
headers = {
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
"OpenAI-Account-ID": self.account_id,
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
"Origin": "https://chatgpt.com",
}
url = f"{self.base_url}/codex/responses"
# Debug logging for troubleshooting
logger.debug(f"Codex tool call request: url={url}, model={payload['model']}, tools={len(codex_tools)}")
try:
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
# Log response details on error
if response.status_code != 200:
logger.error(f"Codex API error {response.status_code}: {response.text[:500]}")
response.raise_for_status()
# Parse SSE for tool calls and content
content, tool_calls = await self._parse_sse_tool_stream(response)
duration = time.time() - start_time
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=0,
output_tokens=0,
success=True,
)
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls] if tool_calls else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=0, # Codex doesn't provide token counts
output_tokens=0,
duration=duration,
finish_reason="tool_calls" if tool_calls else "stop",
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
finish_reason="tool_calls" if tool_calls else "stop",
input_tokens=0,
output_tokens=0,
)
except Exception as e:
logger.error(f"Codex tool call error: {e}")
raise
async def _parse_sse_tool_stream(self, response: httpx.Response) -> tuple[str | None, list[LLMToolCall]]:
"""
Parse SSE stream for tool calls and content.
Returns:
Tuple of (content, tool_calls).
"""
content = ""
tool_calls: list[LLMToolCall] = []
event_type = None
async for line in response.aiter_lines():
if not line:
continue
if line.startswith("event: "):
event_type = line[7:]
elif line.startswith("data: "):
data_str = line[6:]
if data_str == "[DONE]":
break
try:
data = json.loads(data_str)
# Extract text content
if event_type == "response.text.delta" and "delta" in data:
content += data["delta"]
# Extract completed tool calls from response.output_item.done
elif event_type == "response.output_item.done":
item = data.get("item", {})
if item.get("type") == "function_call" and item.get("status") == "completed":
tool_name = item.get("name", "")
arguments_str = item.get("arguments", "{}")
call_id = item.get("call_id", "")
try:
arguments = json.loads(arguments_str)
except json.JSONDecodeError:
logger.warning(f"Failed to parse tool arguments: {arguments_str}")
arguments = {}
tool_calls.append(
LLMToolCall(
id=call_id,
name=tool_name,
arguments=arguments,
)
)
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse SSE data: {e}, data_str: {data_str[:200]}")
return content if content else None, tool_calls
async def cleanup(self) -> None:
"""Clean up HTTP client."""
await self._client.aclose()
@@ -1,635 +0,0 @@
"""
Google Gemini/VertexAI LLM provider.
This provider supports both:
1. Gemini API (api.generativeai.google.com) with API key authentication
2. Vertex AI with service account or Application Default Credentials (ADC)
"""
import asyncio
import json
import logging
import os
import time
from contextvars import ContextVar
from typing import Any
from google import genai
from google.genai import errors as genai_errors
from google.genai import types as genai_types
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.llm_wrapper import parse_llm_json
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
# Per-request Gemini safety settings override.
# Set exclusively by ConfiguredLLMProvider.call() / call_with_tools() via token-based
# set/reset, so it is properly scoped to each individual LLM call and never leaks.
_safety_settings_ctx: ContextVar[list | None] = ContextVar("gemini_safety_settings", default=None)
# Vertex AI imports (optional)
try:
import google.auth
from google.oauth2 import service_account
VERTEXAI_AVAILABLE = True
except ImportError:
VERTEXAI_AVAILABLE = False
class GeminiLLM(LLMInterface):
"""
LLM provider for Google Gemini and Vertex AI.
Supports:
- Gemini API: provider="gemini", requires api_key
- Vertex AI: provider="vertexai", requires project_id and region, uses ADC or service account
"""
def __init__(
self,
provider: str,
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
**kwargs: Any,
):
"""Initialize Gemini/VertexAI LLM provider."""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
self._client = None
self._is_vertexai = self.provider == "vertexai"
# Safety settings: None means use Gemini's defaults
self._safety_settings: list | None = kwargs.get("gemini_safety_settings")
if self._is_vertexai:
self._init_vertexai(**kwargs)
else:
self._init_gemini()
def _init_gemini(self) -> None:
"""Initialize Gemini API client."""
if not self.api_key:
raise ValueError("Gemini provider requires api_key")
self._client = genai.Client(api_key=self.api_key)
logger.info(f"Gemini API: model={self.model}")
def _init_vertexai(self, **kwargs: Any) -> None:
"""Initialize Vertex AI client with project, region, and credentials."""
# Extract Vertex AI config from kwargs
project_id = kwargs.get("vertexai_project_id")
region = kwargs.get("vertexai_region", "us-central1")
service_account_key = kwargs.get("vertexai_service_account_key")
credentials = kwargs.get("vertexai_credentials") # Pre-loaded credentials object
if not project_id:
raise ValueError(
"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID is required for Vertex AI provider. "
"Set it to your GCP project ID."
)
auth_method = "ADC"
# Use pre-loaded credentials if provided (passed from LLMProvider)
if credentials is not None:
auth_method = "service_account"
# Otherwise, load explicit service account credentials if path provided
elif service_account_key:
if not VERTEXAI_AVAILABLE:
raise ValueError(
"Vertex AI service account auth requires 'google-auth' package. "
"Install with: pip install google-auth"
)
credentials = service_account.Credentials.from_service_account_file(
service_account_key,
scopes=["https://www.googleapis.com/auth/cloud-platform"],
)
auth_method = "service_account"
logger.info(f"Vertex AI: Using service account key: {service_account_key}")
# Strip google/ prefix from model name — native SDK uses bare names
# e.g. "google/gemini-2.0-flash-lite-001" -> "gemini-2.0-flash-lite-001"
if self.model.startswith("google/"):
self.model = self.model[len("google/") :]
# Create Vertex AI client
client_kwargs: dict[str, Any] = {
"vertexai": True,
"project": project_id,
"location": region,
}
if credentials is not None:
client_kwargs["credentials"] = credentials
self._client = genai.Client(**client_kwargs)
logger.info(f"Vertex AI: project={project_id}, region={region}, model={self.model}, auth={auth_method}")
async def verify_connection(self) -> None:
"""
Verify that the Gemini/VertexAI provider is configured correctly.
Raises:
RuntimeError: If the connection test fails.
"""
try:
logger.info(f"Verifying {self.provider.upper()}: model={self.model}...")
await self.call(
messages=[{"role": "user", "content": "Say 'ok'"}],
max_completion_tokens=100,
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
scope="verification",
)
logger.info(f"{self.provider.upper()} connection verified successfully")
except Exception as e:
raise RuntimeError(f"Failed to verify {self.provider.upper()} connection: {e}") from e
async def call(
self,
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 a Gemini/VertexAI API call with retry logic.
Args:
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Maximum tokens in response (not supported by Gemini).
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
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 (not supported by Gemini).
return_usage: If True, return tuple (result, TokenUsage).
Returns:
If return_usage=False: Parsed response if response_format provided, else text.
If return_usage=True: Tuple of (result, TokenUsage).
"""
start_time = time.time()
# 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", "")
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)]))
else:
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"):
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:
system_instruction += schema_msg
else:
system_instruction = schema_msg
# Build generation config
config_kwargs: dict[str, Any] = {}
if 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
if temperature is not None:
config_kwargs["temperature"] = temperature
# Apply safety settings: context var (per-request bank override) takes precedence over instance default
effective_safety_settings = _safety_settings_ctx.get()
if effective_safety_settings is None:
effective_safety_settings = self._safety_settings
if effective_safety_settings is not None:
config_kwargs["safety_settings"] = [
genai_types.SafetySetting(category=s["category"], threshold=s["threshold"])
for s in effective_safety_settings
]
generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await asyncio.wait_for(
self._client.aio.models.generate_content(
model=self.model,
contents=gemini_contents,
config=generation_config,
),
timeout=90.0, # Safety net for network hangs; valid slow responses are <90s
)
content = response.text
# Handle empty response
if content is None:
block_reason = None
if hasattr(response, "candidates") and response.candidates:
candidate = response.candidates[0]
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)
await asyncio.sleep(backoff)
continue
else:
raise RuntimeError(f"Gemini returned empty response after {max_retries + 1} attempts")
# Parse structured output if requested
if response_format is not None:
json_data = parse_llm_json(content)
if skip_validation:
result = json_data
else:
result = response_format.model_validate(json_data)
else:
result = content
# Extract token usage
input_tokens = 0
output_tokens = 0
if hasattr(response, "usage_metadata") and response.usage_metadata:
usage = response.usage_metadata
input_tokens = usage.prompt_token_count or 0
output_tokens = usage.candidates_token_count or 0
# Record metrics
duration = time.time() - start_time
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# Record trace span
from hindsight_api.tracing import get_span_recorder
finish_reason = None
if hasattr(response, "candidates") and response.candidates:
if hasattr(response.candidates[0], "finish_reason"):
finish_reason = str(response.candidates[0].finish_reason)
span_recorder = get_span_recorder()
from hindsight_api.tracing import _serialize_for_span
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=_serialize_for_span(result),
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
)
# Log slow calls
if duration > 10.0 and input_tokens > 0:
logger.info(
f"slow llm call: scope={scope}, 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("Gemini returned invalid JSON, retrying...")
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Gemini returned invalid JSON after {max_retries + 1} attempts")
raise
except genai_errors.APIError as e:
# Fast fail on auth errors - these won't recover with retries
if e.code in (401, 403):
logger.error(f"Gemini auth error (HTTP {e.code}), not retrying: {str(e)}")
raise
# Retry on retryable errors (rate limits, server errors, client errors)
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)
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
await asyncio.sleep(backoff + jitter)
else:
logger.error(f"Gemini API error after {max_retries + 1} attempts: {str(e)}")
raise
else:
logger.error(f"Gemini API error: {type(e).__name__}: {str(e)}")
raise
except Exception as e:
logger.error(f"Unexpected error during Gemini call: {type(e).__name__}: {str(e)}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Gemini call failed after all retries")
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""
Make a Gemini/VertexAI API call with tool/function calling support.
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Maximum tokens (not supported by Gemini).
temperature: Sampling temperature.
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools (Gemini uses "auto" only).
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
start_time = time.time()
# Convert tools to Gemini format
gemini_tools = []
for tool in tools:
func = tool.get("function", {})
gemini_tools.append(
genai_types.Tool(
function_declarations=[
genai_types.FunctionDeclaration(
name=func.get("name", ""),
description=func.get("description", ""),
parameters=func.get("parameters"),
)
]
)
)
# Convert messages
system_instruction = None
gemini_contents = []
msg_list = list(messages)
i = 0
while i < len(msg_list):
msg = msg_list[i]
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
system_instruction = (system_instruction + "\n\n" + content) if system_instruction else content
i += 1
elif role == "tool":
# Gemini requires ALL tool responses for a given model turn to be grouped
# into a single Content with multiple FunctionResponse parts.
# Consecutive role="tool" messages correspond to one model turn's tool calls.
parts = []
while i < len(msg_list) and msg_list[i].get("role") == "tool":
tool_msg = msg_list[i]
tool_content = tool_msg.get("content", "")
parts.append(
genai_types.Part(
function_response=genai_types.FunctionResponse(
name=tool_msg.get("name", ""),
response={"result": tool_content},
)
)
)
i += 1
gemini_contents.append(genai_types.Content(role="user", parts=parts))
elif role == "assistant":
tool_calls_in_msg = msg.get("tool_calls", [])
if tool_calls_in_msg:
# Convert OpenAI-style tool_calls to Gemini function_call parts
# This is required for proper multi-turn conversation history
parts = []
if content:
parts.append(genai_types.Part(text=content))
for tc in tool_calls_in_msg:
fn = tc.get("function", {})
fn_name = fn.get("name", "")
fn_args_str = fn.get("arguments", "{}")
fn_args = parse_llm_json(fn_args_str)
parts.append(
genai_types.Part(function_call=genai_types.FunctionCall(name=fn_name, args=fn_args))
)
gemini_contents.append(genai_types.Content(role="model", parts=parts))
else:
gemini_contents.append(genai_types.Content(role="model", parts=[genai_types.Part(text=content)]))
i += 1
else:
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
i += 1
config_kwargs: dict[str, Any] = {"tools": gemini_tools}
if system_instruction:
config_kwargs["system_instruction"] = system_instruction
if temperature is not None:
config_kwargs["temperature"] = temperature
# Map OpenAI-style tool_choice to Gemini FunctionCallingConfig
if tool_choice == "required":
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode="ANY",
)
)
elif isinstance(tool_choice, dict) and tool_choice.get("type") == "function":
fn_name = tool_choice.get("function", {}).get("name")
if fn_name:
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(
mode="ANY",
allowed_function_names=[fn_name],
)
)
elif tool_choice == "none":
config_kwargs["tool_config"] = genai_types.ToolConfig(
function_calling_config=genai_types.FunctionCallingConfig(mode="NONE")
)
# "auto" is the default (no tool_config needed)
# Apply safety settings: context var (per-request bank override) takes precedence over instance default
effective_safety_settings = _safety_settings_ctx.get()
if effective_safety_settings is None:
effective_safety_settings = self._safety_settings
if effective_safety_settings is not None:
config_kwargs["safety_settings"] = [
genai_types.SafetySetting(category=s["category"], threshold=s["threshold"])
for s in effective_safety_settings
]
config = genai_types.GenerateContentConfig(**config_kwargs)
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await asyncio.wait_for(
self._client.aio.models.generate_content(
model=self.model,
contents=gemini_contents,
config=config,
),
timeout=90.0, # Safety net for network hangs; valid slow responses are <90s
)
# Extract content and tool calls
content = None
tool_calls: list[LLMToolCall] = []
if response.candidates and response.candidates[0].content:
parts = response.candidates[0].content.parts
if parts:
for part in parts:
if hasattr(part, "text") and part.text:
content = part.text
if hasattr(part, "function_call") and part.function_call:
fc = part.function_call
tool_calls.append(
LLMToolCall(
id=f"gemini_{len(tool_calls)}",
name=fc.name,
arguments=dict(fc.args) if fc.args else {},
)
)
finish_reason = "tool_calls" if tool_calls else "stop"
# Extract token usage
input_tokens = 0
output_tokens = 0
if response.usage_metadata:
input_tokens = response.usage_metadata.prompt_token_count or 0
output_tokens = response.usage_metadata.candidates_token_count or 0
# Record metrics
duration = time.time() - start_time
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
if tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
except genai_errors.APIError as e:
# Fast fail on auth errors
if e.code in (401, 403):
logger.error(f"Gemini auth error (HTTP {e.code}), not retrying: {str(e)}")
raise
# Retry on retryable errors
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
raise
except Exception as e:
logger.error(f"Unexpected error during Gemini tool call: {type(e).__name__}: {str(e)}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Gemini tool call failed")
async def cleanup(self) -> None:
"""Clean up resources (close connections, etc.)."""
# Gemini client doesn't require explicit cleanup
pass
@@ -1,323 +0,0 @@
"""
Mock LLM provider for testing.
This provider allows tests to record LLM calls and return configurable mock responses
without making actual API calls to external LLM services.
"""
import logging
from collections.abc import Callable
from typing import Any
from ..llm_interface import LLMInterface
from ..response_models import LLMToolCall, LLMToolCallResult, TokenUsage
logger = logging.getLogger(__name__)
class MockLLM(LLMInterface):
"""
Mock LLM provider for testing.
This provider records all calls and returns configurable mock responses,
enabling tests to verify LLM interactions without making real API calls.
Example:
# Create mock provider
mock_llm = MockLLM(provider="mock", api_key="", base_url="", model="mock-model")
# Set mock response
mock_llm.set_mock_response({"answer": "test"})
# Make calls
result = await mock_llm.call(
messages=[{"role": "user", "content": "test"}],
response_format=MyResponseModel
)
# Verify calls
calls = mock_llm.get_mock_calls()
assert len(calls) == 1
assert calls[0]["scope"] == "memory"
"""
def __init__(
self,
provider: str,
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
**kwargs: Any,
):
"""
Initialize mock LLM provider.
Args:
provider: Provider name (should be "mock").
api_key: Not used for mock provider.
base_url: Not used for mock provider.
model: Model name for tracking.
reasoning_effort: Not used for mock provider.
**kwargs: Additional parameters (not used).
"""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
# Storage for test verification
self._mock_calls: list[dict] = []
self._mock_response: Any = None
self._mock_exception: Exception | None = None
self._response_callback: Callable[[list[dict], str], Any] | None = None
async def verify_connection(self) -> None:
"""
Verify mock provider (always succeeds).
Mock provider doesn't need connection verification since it doesn't
make real API calls.
"""
logger.debug("Mock LLM: connection verification (always succeeds)")
async def call(
self,
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 a mock LLM API call.
Records the call for test verification and returns the configured mock response.
Args:
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Not used in mock.
temperature: Not used in mock.
scope: Scope identifier for tracking.
max_retries: Not used in mock.
initial_backoff: Not used in mock.
max_backoff: Not used in mock.
skip_validation: Return raw JSON without Pydantic validation.
strict_schema: Not used in mock.
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
If return_usage=False: Parsed response if response_format is provided, otherwise text content.
If return_usage=True: Tuple of (result, TokenUsage) with mock token counts.
"""
# Record the call for test verification
call_record = {
"provider": self.provider,
"model": self.model,
"messages": messages,
"response_format": response_format.__name__
if response_format and hasattr(response_format, "__name__")
else str(response_format),
"scope": scope,
}
self._mock_calls.append(call_record)
logger.debug(f"Mock LLM call recorded: scope={scope}, model={self.model}")
# Raise mock exception if configured
if self._mock_exception is not None:
raise self._mock_exception
# Record trace span (minimal for mock provider)
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content="mock response",
input_tokens=10,
output_tokens=5,
duration=0.001, # Mock calls are instant
finish_reason="stop",
error=None,
)
# Return mock response
if self._response_callback is not None:
result = self._response_callback(messages, scope)
elif self._mock_response is not None:
result = self._mock_response
elif response_format is not None:
# Try to create a minimal valid instance of the response format
try:
# For Pydantic models, try to create with minimal valid data
result = {"mock": True}
except Exception:
result = {"mock": True}
else:
result = "mock response"
if return_usage:
token_usage = TokenUsage(input_tokens=10, output_tokens=5, total_tokens=15)
return result, token_usage
return result
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""
Make a mock LLM API call with tool/function calling support.
Records the call for test verification and returns the configured mock response.
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Not used in mock.
temperature: Not used in mock.
scope: Scope identifier for tracking.
max_retries: Not used in mock.
initial_backoff: Not used in mock.
max_backoff: Not used in mock.
tool_choice: Not used in mock.
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
# Record the call for test verification
call_record = {
"provider": self.provider,
"model": self.model,
"messages": messages,
"tools": [t.get("function", {}).get("name") for t in tools],
"scope": scope,
}
self._mock_calls.append(call_record)
# Raise mock exception if configured
if self._mock_exception is not None:
raise self._mock_exception
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
if self._response_callback is not None:
cb_result = self._response_callback(messages, scope)
if isinstance(cb_result, LLMToolCallResult):
result = cb_result
else:
result = LLMToolCallResult(
content=str(cb_result) if cb_result is not None else "mock response", finish_reason="stop"
)
elif self._mock_response is not None:
if isinstance(self._mock_response, LLMToolCallResult):
result = self._mock_response
elif isinstance(self._mock_response, list):
# Allow setting just tool calls as a list
result = LLMToolCallResult(
tool_calls=[
LLMToolCall(id=f"mock_{i}", name=tc["name"], arguments=tc.get("arguments", {}))
for i, tc in enumerate(self._mock_response)
],
finish_reason="tool_calls",
)
else:
result = LLMToolCallResult(content="mock response", finish_reason="stop")
else:
result = LLMToolCallResult(content="mock response", finish_reason="stop")
# Record span with mock values
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in result.tool_calls]
if result.tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=result.content,
input_tokens=10, # Mock value
output_tokens=5, # Mock value
duration=0.1, # Mock value
finish_reason=result.finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return result
async def cleanup(self) -> None:
"""Clean up resources (no-op for mock provider)."""
pass
def set_response_callback(self, fn: Callable[[list[dict], str], Any]) -> None:
"""
Set a callback invoked on each call() instead of _mock_response.
The callback receives (messages, scope) and returns the response.
Useful for returning different responses per call (e.g., cycling
through a corpus in a benchmark).
"""
self._response_callback = fn
def set_mock_response(self, response: Any) -> None:
"""
Set the response to return from mock calls.
Args:
response: The response to return. Can be:
- A dict/Pydantic model for regular calls
- An LLMToolCallResult for tool calls
- A list of tool call dicts for tool calls
- Any other value to return as-is
"""
self._mock_response = response
def set_mock_exception(self, exception: Exception) -> None:
"""
Set an exception to raise from mock calls.
Args:
exception: The exception to raise on the next call.
After raising, the exception is cleared.
"""
self._mock_exception = exception
def get_mock_calls(self) -> list[dict]:
"""
Get the list of recorded mock calls.
Returns:
List of call records, each containing:
- provider: Provider name
- model: Model name
- messages: Messages sent
- response_format/tools: Format or tools used
- scope: Call scope
"""
return self._mock_calls
def clear_mock_calls(self) -> None:
"""Clear the recorded mock calls and any set exception."""
self._mock_calls = []
self._mock_exception = None
@@ -1,924 +0,0 @@
"""
OpenAI-compatible LLM provider supporting OpenAI, Groq, Ollama, and LMStudio.
This provider handles all OpenAI API-compatible models including:
- OpenAI: GPT-4, GPT-4o, GPT-5, o1, o3 (reasoning models)
- Groq: Fast inference with seed control and service tiers
- Ollama: Local models with native streaming API support
- LMStudio: Local models with OpenAI-compatible API
Features:
- Reasoning models with extended thinking (o1, o3, GPT-5 families)
- Strict JSON schema enforcement (OpenAI)
- Provider-specific parameters (Groq seed, service tier)
- Native Ollama streaming for better structured output
- Automatic token limit handling per model family
"""
import asyncio
import io
import json
import logging
import os
import re
import time
from typing import Any
import httpx
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
from hindsight_api.metrics import get_metrics_collector
logger = logging.getLogger(__name__)
# Seed applied to every Groq request for deterministic behavior
DEFAULT_LLM_SEED = 4242
class OpenAICompatibleLLM(LLMInterface):
"""
LLM provider for OpenAI-compatible APIs.
Supports:
- OpenAI: Standard models (GPT-4, GPT-4o) and reasoning models (o1, o3, GPT-5)
- Groq: Fast inference with seed control and service tiers
- Ollama: Local models with native streaming API for better structured output
- LMStudio: Local models with OpenAI-compatible API
"""
def __init__(
self,
provider: str,
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
timeout: float | None = None,
groq_service_tier: str | None = None,
**kwargs: Any,
):
"""
Initialize OpenAI-compatible LLM provider.
Args:
provider: Provider name ("openai", "groq", "ollama", "lmstudio").
api_key: API key (optional for ollama/lmstudio).
base_url: Base URL for the API (uses defaults for groq/ollama/lmstudio if empty).
model: Model name.
reasoning_effort: Reasoning effort level for supported models ("low", "medium", "high").
timeout: Request timeout in seconds (uses env var or 300s default).
groq_service_tier: Groq service tier ("on_demand", "flex", "auto").
**kwargs: Additional provider-specific parameters.
"""
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
# Validate provider
valid_providers = ["openai", "groq", "ollama", "lmstudio"]
if self.provider not in valid_providers:
raise ValueError(f"OpenAICompatibleLLM only supports: {', '.join(valid_providers)}. Got: {self.provider}")
# Set default base URLs
if not self.base_url:
if self.provider == "groq":
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"
# For ollama/lmstudio, use dummy key if not provided
if self.provider in ("ollama", "lmstudio") and not self.api_key:
self.api_key = "local"
# Validate API key for cloud providers
if self.provider in ("openai", "groq") and not self.api_key:
raise ValueError(f"API key is required for {self.provider}")
# Service tier configuration (from config, not env vars)
self.groq_service_tier = groq_service_tier
self.openai_service_tier = kwargs.get("openai_service_tier")
# Get timeout config
self.timeout = timeout or float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT)))
# Create OpenAI client
client_kwargs: dict[str, Any] = {"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)
logger.info(
f"OpenAI-compatible client initialized: provider={self.provider}, model={self.model}, "
f"base_url={self.base_url or 'default'}"
)
async def verify_connection(self) -> None:
"""
Verify that the provider is configured correctly by making a simple test call.
Raises:
RuntimeError: If the connection test fails.
"""
try:
logger.info(f"Verifying connection: {self.provider}/{self.model}")
await self.call(
messages=[{"role": "user", "content": "Say 'ok'"}],
max_completion_tokens=100,
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
scope="verification",
)
logger.info(f"Connection verified: {self.provider}/{self.model}")
except Exception as e:
raise RuntimeError(f"Connection verification failed for {self.provider}/{self.model}: {e}") from e
def _supports_reasoning_model(self) -> bool:
"""Check if the current model is a reasoning model (o1, o3, GPT-5, DeepSeek)."""
model_lower = self.model.lower()
return any(x in model_lower for x in ["gpt-5", "o1", "o3", "deepseek"])
def _get_max_reasoning_tokens(self) -> int | None:
"""Get max reasoning tokens for reasoning models."""
model_lower = self.model.lower()
# GPT-4 and GPT-4.1 models have different caps
if any(x in model_lower for x in ["gpt-4.1", "gpt-4-"]):
return 32000
elif "gpt-4o" in model_lower:
return 16384
return None
async def call(
self,
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.
Args:
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
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).
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
Returns:
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.
Raises:
OutputTooLongError: If output exceeds token limits.
Exception: Re-raises API errors after retries exhausted.
"""
# 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=messages,
response_format=response_format,
max_completion_tokens=max_completion_tokens,
temperature=temperature,
max_retries=max_retries,
initial_backoff=initial_backoff,
max_backoff=max_backoff,
skip_validation=skip_validation,
scope=scope,
return_usage=return_usage,
)
start_time = time.time()
# Build call parameters
call_params: dict[str, Any] = {
"model": self.model,
"messages": messages,
}
# Check if model supports reasoning parameter
is_reasoning_model = self._supports_reasoning_model()
# Apply model-specific token limits
if max_completion_tokens is not None:
max_tokens_cap = self._get_max_reasoning_tokens()
if max_tokens_cap and max_completion_tokens > max_tokens_cap:
max_completion_tokens = max_tokens_cap
# For reasoning models, enforce minimum to ensure space for reasoning + output
if is_reasoning_model and max_completion_tokens < 16000:
max_completion_tokens = 16000
call_params["max_completion_tokens"] = max_completion_tokens
# Temperature - reasoning models don't support custom temperature
if temperature is not None and not is_reasoning_model:
call_params["temperature"] = temperature
# Set reasoning_effort for reasoning models
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: dict[str, Any] = {}
# Add service_tier if configured
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["include_reasoning"] = False
if extra_body:
call_params["extra_body"] = extra_body
# Prepare response format ONCE before retry loop
if response_format is not None:
schema = None
if hasattr(response_format, "model_json_schema"):
schema = response_format.model_json_schema()
if strict_schema and schema is not None:
# Use OpenAI's strict JSON schema enforcement
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)}"
)
if call_params["messages"] and call_params["messages"][0].get("role") == "system":
first_msg = call_params["messages"][0]
if isinstance(first_msg, dict) and isinstance(first_msg.get("content"), str):
first_msg["content"] += schema_msg
elif call_params["messages"]:
first_msg = call_params["messages"][0]
if isinstance(first_msg, dict) and isinstance(first_msg.get("content"), str):
first_msg["content"] = schema_msg + "\n\n" + first_msg["content"]
if self.provider not in ("lmstudio", "ollama"):
# LM Studio and Ollama don't support json_object response format reliably
call_params["response_format"] = {"type": "json_object"}
last_exception = None
for attempt in range(max_retries + 1):
try:
if response_format is not None:
response = await self._client.chat.completions.create(**call_params)
content = response.choices[0].message.content
# Strip reasoning model thinking tags
# Supports: <think>, <thinking>, <reasoning>, |startthink|/|endthink|
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
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
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
else:
result = response_format.model_validate(json_data)
else:
response = await self._client.chat.completions.create(**call_params)
result = response.choices[0].message.content
# Record token usage metrics
duration = time.time() - start_time
usage = response.usage
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
# Record LLM metrics
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# Record trace span
from hindsight_api.tracing import _serialize_for_span, get_span_recorder
finish_reason = response.choices[0].finish_reason if response.choices else None
span_recorder = get_span_recorder()
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=_serialize_for_span(result),
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
)
# 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
cache_info = f", cached_tokens={cached_tokens}" if cached_tokens > 0 else ""
logger.info(
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, "
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(
"LLM output exceeded token limits. Input may need to be split into smaller chunks."
) from e
except APIConnectionError as e:
last_exception = e
status_code = getattr(e, "status_code", None) or getattr(
getattr(e, "response", None), "status_code", None
)
logger.warning(f"APIConnectionError (HTTP {status_code}), attempt {attempt + 1}: {str(e)[:200]}")
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Connection error after {max_retries + 1} attempts: {str(e)}")
raise
except APIStatusError as e:
# Fast fail only on 401 (unauthorized) and 403 (forbidden)
if e.status_code in (401, 403):
logger.error(f"Auth error (HTTP {e.status_code}), not retrying: {str(e)}")
raise
# Handle tool_use_failed error - model outputted in tool call format
if e.status_code == 400 and response_format is not None:
try:
error_body = e.body if hasattr(e, "body") else {}
if isinstance(error_body, dict):
error_info: dict[str, Any] = error_body.get("error") or {}
if error_info.get("code") == "tool_use_failed":
failed_gen = error_info.get("failed_generation", "")
if failed_gen:
# Parse tool call format and convert to expected format
tool_call = json.loads(failed_gen)
tool_name = tool_call.get("name", "")
tool_args = tool_call.get("arguments", {})
converted = {"actions": [{"tool": tool_name, **tool_args}]}
if skip_validation:
result = converted
else:
result = response_format.model_validate(converted)
# Record metrics
duration = time.time() - start_time
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=0,
output_tokens=0,
success=True,
)
if return_usage:
return result, TokenUsage(input_tokens=0, output_tokens=0, total_tokens=0)
return result
except (json.JSONDecodeError, KeyError, TypeError):
pass # Failed to parse tool_use_failed, continue with normal retry
last_exception = e
if attempt < max_retries:
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)
else:
logger.error(f"API error after {max_retries + 1} attempts: {str(e)}")
raise
except Exception:
raise
if last_exception:
raise last_exception
raise RuntimeError("LLM call failed after all retries with no exception captured")
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""
Make an LLM API call with tool/function calling support.
Args:
messages: List of message dicts. Can include tool results with role='tool'.
tools: List of tool definitions in OpenAI format.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
tool_choice: How to choose tools - "auto", "none", "required", or specific function.
Returns:
LLMToolCallResult with content and/or tool_calls.
"""
start_time = time.time()
# Build call parameters
call_params: dict[str, Any] = {
"model": self.model,
"messages": messages,
"tools": tools,
"tool_choice": tool_choice,
}
if max_completion_tokens is not None:
call_params["max_completion_tokens"] = max_completion_tokens
if temperature is not None:
call_params["temperature"] = temperature
# Provider-specific parameters
if self.provider == "groq":
call_params["seed"] = DEFAULT_LLM_SEED
last_exception = None
for attempt in range(max_retries + 1):
try:
response = await self._client.chat.completions.create(**call_params)
message = response.choices[0].message
finish_reason = response.choices[0].finish_reason
# Extract tool calls if present
tool_calls: list[LLMToolCall] = []
if message.tool_calls:
for tc in message.tool_calls:
try:
args = json.loads(tc.function.arguments) if tc.function.arguments else {}
except json.JSONDecodeError:
args = {"_raw": tc.function.arguments}
tool_calls.append(LLMToolCall(id=tc.id, name=tc.function.name, arguments=args))
content = message.content
# Record metrics
duration = time.time() - start_time
usage = response.usage
input_tokens = usage.prompt_tokens or 0 if usage else 0
output_tokens = usage.completion_tokens or 0 if usage else 0
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# Record OpenTelemetry span
from hindsight_api.tracing import get_span_recorder
span_recorder = get_span_recorder()
# Convert LLMToolCall objects to dicts for span recording
tool_calls_dict = (
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
if tool_calls
else None
)
span_recorder.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
messages=messages,
response_content=content,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration,
finish_reason=finish_reason,
error=None,
tool_calls=tool_calls_dict,
)
return LLMToolCallResult(
content=content,
tool_calls=tool_calls,
finish_reason=finish_reason,
input_tokens=input_tokens,
output_tokens=output_tokens,
)
except APIConnectionError as e:
last_exception = e
if attempt < max_retries:
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
raise
except APIStatusError as e:
if e.status_code in (401, 403):
raise
last_exception = e
if attempt < max_retries:
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
continue
raise
except Exception:
raise
if last_exception:
raise last_exception
raise RuntimeError("Tool 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,
scope: str = "memory",
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.
"""
start_time = time.time()
# 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: dict[str, Any] = {
"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
options: dict[str, Any] = {
"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
duration = time.time() - start_time
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 LLM metrics
metrics = get_metrics_collector()
metrics.record_llm_call(
provider=self.provider,
model=self.model,
scope=scope,
duration=duration,
input_tokens=input_tokens,
output_tokens=output_tokens,
success=True,
)
# 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 supports_batch_api(self) -> bool:
"""Check if this provider supports batch API operations."""
# Only OpenAI and Groq support batch API
return self.provider in ("openai", "groq")
async def submit_batch(
self,
requests: list[dict[str, Any]],
endpoint: str = "/v1/chat/completions",
completion_window: str = "24h",
) -> dict[str, Any]:
"""
Submit a batch of requests to OpenAI/Groq Batch API.
Args:
requests: List of request dicts with custom_id, method, url, body
endpoint: API endpoint (e.g., "/v1/chat/completions")
completion_window: Completion window (e.g., "24h")
Returns:
Dict with batch metadata including batch_id
Raises:
NotImplementedError: If provider doesn't support batch API
"""
if not await self.supports_batch_api():
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
logger.info(f"Submitting batch with {len(requests)} requests to {self.provider}")
# Format requests as JSONL
jsonl_content = "\n".join(json.dumps(req) for req in requests)
# Upload file to provider (wrap in BytesIO with filename)
file_bytes = io.BytesIO(jsonl_content.encode("utf-8"))
file_bytes.name = "batch_input.jsonl" # OpenAI SDK needs a filename
file_response = await self._client.files.create(
file=file_bytes,
purpose="batch",
)
logger.debug(f"Uploaded batch file: {file_response.id}")
# Create batch
batch_response = await self._client.batches.create(
input_file_id=file_response.id,
endpoint=endpoint,
completion_window=completion_window,
)
logger.info(f"Batch submitted: {batch_response.id}, status={batch_response.status}")
return {
"batch_id": batch_response.id,
"status": batch_response.status,
"input_file_id": file_response.id,
"created_at": batch_response.created_at,
"request_count": len(requests),
}
async def get_batch_status(self, batch_id: str) -> dict[str, Any]:
"""
Get the status of a batch job.
Args:
batch_id: Batch identifier
Returns:
Dict with status info (batch_id, status, completed_at, etc.)
"""
if not await self.supports_batch_api():
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
batch = await self._client.batches.retrieve(batch_id)
result = {
"batch_id": batch.id,
"status": batch.status,
"created_at": batch.created_at,
"request_counts": {
"total": batch.request_counts.total if batch.request_counts else 0,
"completed": batch.request_counts.completed if batch.request_counts else 0,
"failed": batch.request_counts.failed if batch.request_counts else 0,
},
}
if batch.completed_at:
result["completed_at"] = batch.completed_at
if batch.output_file_id:
result["output_file_id"] = batch.output_file_id
if batch.error_file_id:
result["error_file_id"] = batch.error_file_id
if batch.errors:
result["errors"] = batch.errors
return result
async def retrieve_batch_results(self, batch_id: str) -> list[dict[str, Any]]:
"""
Retrieve completed batch results.
Args:
batch_id: Batch identifier
Returns:
List of result dicts (one per request, matched by custom_id)
"""
if not await self.supports_batch_api():
raise NotImplementedError(f"Batch API not supported for provider: {self.provider}")
# Get batch status
batch = await self._client.batches.retrieve(batch_id)
if batch.status != "completed":
raise ValueError(f"Batch {batch_id} is not completed yet (status: {batch.status})")
if not batch.output_file_id:
raise ValueError(f"Batch {batch_id} has no output file")
# Download results file
logger.debug(f"Downloading results for batch {batch_id} from file {batch.output_file_id}")
file_content = await self._client.files.content(batch.output_file_id)
# Parse JSONL results
results = []
for line in file_content.text.strip().split("\n"):
if line:
results.append(json.loads(line))
logger.info(f"Retrieved {len(results)} results for batch {batch_id}")
return results
async def cleanup(self) -> None:
"""Clean up resources (close OpenAI client connections)."""
if hasattr(self, "_client") and self._client:
await self._client.close()
@@ -92,17 +92,11 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
self._search_dates = None
def load(self) -> None:
"""Load dateparser and warm up internal data structures.
Triggers the real initialization cost (regex tables, timezone data) at
load time so the first actual recall doesn't pay the cold-start penalty.
"""
"""Load dateparser (lazy import)."""
if self._search_dates is None:
from dateparser.search import search_dates
self._search_dates = search_dates
# Warm up: fire a dummy call to trigger lazy-loaded internal tables.
self._search_dates("today")
def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAnalysis:
"""
@@ -4,15 +4,17 @@ Reflect agent module for agentic reflection with tools.
The reflect agent uses an iterative loop with tools to:
1. Lookup mental models (existing knowledge)
2. Recall facts (semantic + temporal search)
3. Expand memories (get chunk/document context)
3. Learn new insights (create/update mental models)
4. Expand memories (get chunk/document context)
"""
from .agent import ReflectAgentResult, run_reflect_agent
from .models import ReflectAction, ReflectActionBatch
from .models import MentalModelInput, ReflectAction, ReflectActionBatch
__all__ = [
"run_reflect_agent",
"ReflectAgentResult",
"ReflectAction",
"ReflectActionBatch",
"MentalModelInput",
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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