Compare commits
109
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17accfc961 | ||
|
|
c1377c5e1e | ||
|
|
04187ddbc4 | ||
|
|
61e81c81d1 | ||
|
|
3c6850ca92 | ||
|
|
e785b05831 | ||
|
|
58c4d65778 | ||
|
|
c3ef1555bf | ||
|
|
dcaa9f14ab | ||
|
|
41db2960c5 | ||
|
|
f78278ea89 | ||
|
|
117dd6988d | ||
|
|
7c78ae2371 | ||
|
|
6c695eb9f8 | ||
|
|
7eafba661e | ||
|
|
c461013047 | ||
|
|
7c99feb018 | ||
|
|
d06a0259cc | ||
|
|
be8728b313 | ||
|
|
917893aac7 | ||
|
|
224b7b74c1 | ||
|
|
8114ef440e | ||
|
|
6bad667344 | ||
|
|
b3f0205ead | ||
|
|
476726c2a2 | ||
|
|
970f1b3534 | ||
|
|
5883e5af2d | ||
|
|
95c4220477 | ||
|
|
6e30980add | ||
|
|
40d42c58aa | ||
|
|
aefb3fcf4d | ||
|
|
2a47389f2c | ||
|
|
b4b5c44a87 | ||
|
|
d5e62162e8 | ||
|
|
7dad9da02d | ||
|
|
ff55283018 | ||
|
|
b3b541fc53 | ||
|
|
4f112101ac | ||
|
|
e408b7e072 | ||
|
|
d871c3009d | ||
|
|
d8376ecf6b | ||
|
|
71e408c27b | ||
|
|
c029807add | ||
|
|
8d731f2e5f | ||
|
|
f9a8a8e01e | ||
|
|
a713b68b1f | ||
|
|
93ddd41621 | ||
|
|
7ee229ba23 | ||
|
|
29c0890f22 | ||
|
|
a1f22dabd2 | ||
|
|
60574ee08f | ||
|
|
7d95a002c7 | ||
|
|
83ca669011 | ||
|
|
e798979733 | ||
|
|
43f9a8bec2 | ||
|
|
f641b30d83 | ||
|
|
90be7c6829 | ||
|
|
6eec83b20d | ||
|
|
dd1e0986a1 | ||
|
|
69dec8ec34 | ||
|
|
888b50de12 | ||
|
|
fb7be3eced | ||
|
|
4499254f6d | ||
|
|
9943957fb7 | ||
|
|
03f47e29c8 | ||
|
|
1240b82629 | ||
|
|
08f1cda3bf | ||
|
|
a3a9d7b37d | ||
|
|
c2607d7699 | ||
|
|
e99ee0f243 | ||
|
|
c568094b8c | ||
|
|
5179d5f77d | ||
|
|
981cf6057f | ||
|
|
d90588b3e1 | ||
|
|
d0f67c9f8b | ||
|
|
fedfb494ee | ||
|
|
0430588e32 | ||
|
|
2af0e08dba | ||
|
|
f64817814a | ||
|
|
fa4cbf7ef2 | ||
|
|
2109397028 | ||
|
|
c4ef090a20 | ||
|
|
96f487213c | ||
|
|
0d8d805832 | ||
|
|
1cd836229b | ||
|
|
90ad003c46 | ||
|
|
278718dd84 | ||
|
|
093ecff48d | ||
|
|
85b9074f43 | ||
|
|
7e339e1677 | ||
|
|
dd621a69d0 | ||
|
|
7097716204 | ||
|
|
d3302c95b9 | ||
|
|
665877bb01 | ||
|
|
a43d208e93 | ||
|
|
34d9188e13 | ||
|
|
9a776e9f58 | ||
|
|
d02affd8f2 | ||
|
|
6b346925e2 | ||
|
|
63e2964a4c | ||
|
|
d5403a4b29 | ||
|
|
a24941f83b | ||
|
|
21b25fe8fe | ||
|
|
794a7435a9 | ||
|
|
038a9c2313 | ||
|
|
749478d9f9 | ||
|
|
96f0e54efa | ||
|
|
382550690a | ||
|
|
6c7f057e9d |
@@ -31,10 +31,22 @@ 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)
|
||||
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
|
||||
@@ -50,3 +62,18 @@ 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
|
||||
|
||||
@@ -188,6 +188,55 @@ jobs:
|
||||
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-control-plane:
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm
|
||||
@@ -291,6 +340,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
release-docker-images:
|
||||
name: Release Docker (${{ matrix.image_name }}${{ matrix.tag_suffix }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -300,10 +350,28 @@ 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
|
||||
@@ -341,6 +409,9 @@ 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 }}
|
||||
@@ -366,7 +437,7 @@ jobs:
|
||||
# - name: Smoke test - verify container starts
|
||||
# env:
|
||||
# GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
# run: ./scripts/docker-smoke-test.sh "${{ matrix.image_name }}:test" "${{ matrix.target }}"
|
||||
# run: ./docker/test-image.sh "${{ matrix.image_name }}:test" "${{ matrix.target }}"
|
||||
|
||||
# Build multi-platform and push to release tags
|
||||
- name: Build and push release images
|
||||
@@ -375,6 +446,7 @@ 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 }}
|
||||
@@ -415,7 +487,7 @@ jobs:
|
||||
|
||||
create-github-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
|
||||
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-ai-sdk-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -444,6 +516,12 @@ jobs:
|
||||
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 Control Plane
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -487,6 +565,8 @@ jobs:
|
||||
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
|
||||
# Control Plane
|
||||
cp artifacts/control-plane/*.tgz release-assets/ || true
|
||||
# Rust CLI binaries
|
||||
|
||||
+387
-64
@@ -9,42 +9,11 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-python-packages:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- name: hindsight-all
|
||||
path: hindsight
|
||||
- name: hindsight-api
|
||||
path: hindsight-api
|
||||
- name: hindsight-client
|
||||
path: hindsight-clients/python
|
||||
- name: hindsight-embed
|
||||
path: hindsight-embed
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Build ${{ matrix.name }}
|
||||
working-directory: ./${{ matrix.path }}
|
||||
run: uv build
|
||||
|
||||
build-api-python-versions:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.11', '3.12', '3.13']
|
||||
python-version: ['3.11', '3.12', '3.13', '3.14']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -105,6 +74,29 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm run build
|
||||
|
||||
build-ai-sdk-integration:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/ai-sdk
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/ai-sdk
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
working-directory: ./hindsight-integrations/ai-sdk
|
||||
run: npm run build
|
||||
|
||||
build-control-plane:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -285,16 +277,35 @@ jobs:
|
||||
run: helm lint helm/hindsight
|
||||
|
||||
build-docker-images:
|
||||
name: Build Docker (${{ matrix.name }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: api-only
|
||||
name: api
|
||||
variant: full
|
||||
build_args: ""
|
||||
- target: api-only
|
||||
name: api-slim
|
||||
variant: slim
|
||||
build_args: |
|
||||
INCLUDE_LOCAL_MODELS=false
|
||||
PRELOAD_ML_MODELS=false
|
||||
- target: cp-only
|
||||
name: control-plane
|
||||
variant: full
|
||||
build_args: ""
|
||||
- target: standalone
|
||||
name: standalone
|
||||
variant: full
|
||||
build_args: ""
|
||||
- target: standalone
|
||||
name: standalone-slim
|
||||
variant: slim
|
||||
build_args: |
|
||||
INCLUDE_LOCAL_MODELS=false
|
||||
PRELOAD_ML_MODELS=false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -313,20 +324,31 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build ${{ matrix.name }} image
|
||||
- name: Build ${{ matrix.name }} image (${{ matrix.variant }})
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/standalone/Dockerfile
|
||||
target: ${{ matrix.target }}
|
||||
build-args: ${{ matrix.build_args }}
|
||||
push: false
|
||||
load: false
|
||||
load: ${{ matrix.variant == 'slim' }}
|
||||
tags: hindsight-${{ matrix.name }}:test
|
||||
# Removed GitHub Actions cache (type=gha) - it frequently returns 502 errors
|
||||
# causing buildx to fail with "failed to parse error response 502"
|
||||
# Build will be slower but more reliable
|
||||
|
||||
# TODO: Re-enable smoke test when disk space issue is resolved
|
||||
# - name: Smoke test - verify container starts
|
||||
# env:
|
||||
# GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
# run: ./scripts/docker-smoke-test.sh "hindsight-${{ matrix.name }}:test" "${{ matrix.target }}"
|
||||
# Only test slim variants to save disk space (they're much smaller)
|
||||
# Slim variants require external embedding providers
|
||||
- name: Smoke test - verify container starts
|
||||
if: matrix.variant == 'slim'
|
||||
env:
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
HINDSIGHT_API_EMBEDDINGS_PROVIDER: openai
|
||||
HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
HINDSIGHT_API_RERANKER_PROVIDER: cohere
|
||||
HINDSIGHT_API_COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
|
||||
run: ./docker/test-image.sh "hindsight-${{ matrix.name }}:test" "${{ matrix.target }}"
|
||||
|
||||
test-api:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -626,6 +648,185 @@ jobs:
|
||||
echo "=== API Server Logs ==="
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-go-client:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: groq
|
||||
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
|
||||
HINDSIGHT_API_URL: http://localhost:8888
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
cache-dependency-path: hindsight-clients/go/go.sum
|
||||
|
||||
- name: Build API
|
||||
working-directory: ./hindsight-api
|
||||
run: uv build
|
||||
|
||||
- name: Install API dependencies
|
||||
working-directory: ./hindsight-api
|
||||
run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match
|
||||
|
||||
- name: Create .env file
|
||||
run: |
|
||||
cat > .env << EOF
|
||||
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
|
||||
HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
|
||||
HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }}
|
||||
EOF
|
||||
|
||||
- name: Start API server
|
||||
run: |
|
||||
./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 &
|
||||
echo "Waiting for API server to be ready..."
|
||||
for i in {1..60}; do
|
||||
if curl -sf http://localhost:8888/health > /dev/null 2>&1; then
|
||||
echo "API server is ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 60 ]; then
|
||||
echo "API server failed to start after 60s"
|
||||
cat /tmp/api-server.log
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Build Go client
|
||||
working-directory: ./hindsight-clients/go
|
||||
run: go build ./...
|
||||
|
||||
- name: Run Go client tests
|
||||
working-directory: ./hindsight-clients/go
|
||||
run: go test -v -tags=integration
|
||||
|
||||
- name: Show API server logs
|
||||
if: always()
|
||||
run: |
|
||||
echo "=== API Server Logs ==="
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-openclaw-integration:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: groq
|
||||
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
|
||||
HINDSIGHT_API_URL: http://localhost:8888
|
||||
HINDSIGHT_EMBED_PACKAGE_PATH: ${{ github.workspace }}/hindsight-embed
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Build API
|
||||
working-directory: ./hindsight-api
|
||||
run: uv build
|
||||
|
||||
- name: Install API dependencies
|
||||
working-directory: ./hindsight-api
|
||||
run: uv sync --frozen --no-install-project --index-strategy unsafe-best-match
|
||||
|
||||
- name: Install embed dependencies
|
||||
working-directory: ./hindsight-embed
|
||||
run: uv sync --frozen --index-strategy unsafe-best-match
|
||||
|
||||
- name: Cache HuggingFace models
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/huggingface
|
||||
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-huggingface-
|
||||
|
||||
- name: Pre-download models
|
||||
working-directory: ./hindsight-api
|
||||
run: |
|
||||
uv run python -c "
|
||||
from sentence_transformers import SentenceTransformer, CrossEncoder
|
||||
print('Downloading embedding model...')
|
||||
SentenceTransformer('BAAI/bge-small-en-v1.5')
|
||||
print('Downloading cross-encoder model...')
|
||||
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
|
||||
print('Models downloaded successfully')
|
||||
"
|
||||
|
||||
- name: Install openclaw integration dependencies
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm ci
|
||||
|
||||
- name: Create .env file
|
||||
run: |
|
||||
cat > .env << EOF
|
||||
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
|
||||
HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
|
||||
HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }}
|
||||
EOF
|
||||
|
||||
- name: Start API server
|
||||
run: |
|
||||
./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 &
|
||||
echo "Waiting for API server to be ready..."
|
||||
for i in {1..60}; do
|
||||
if curl -sf http://localhost:8888/health > /dev/null 2>&1; then
|
||||
echo "API server is ready after ${i}s"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 60 ]; then
|
||||
echo "API server failed to start after 60s"
|
||||
cat /tmp/api-server.log
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run openclaw integration tests
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm run test:integration
|
||||
|
||||
- name: Show API server logs
|
||||
if: always()
|
||||
run: |
|
||||
echo "=== API Server Logs ==="
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-integration:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -717,6 +918,35 @@ jobs:
|
||||
echo "=== API Server Logs ==="
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-crewai-integration:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Build crewai integration
|
||||
working-directory: ./hindsight-integrations/crewai
|
||||
run: uv build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/crewai
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/crewai
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-litellm-integration:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -749,9 +979,9 @@ jobs:
|
||||
test-embed:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_EMBED_LLM_PROVIDER: groq
|
||||
HINDSIGHT_EMBED_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
HINDSIGHT_EMBED_LLM_MODEL: openai/gpt-oss-20b
|
||||
HINDSIGHT_API_LLM_PROVIDER: groq
|
||||
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
|
||||
# Prefer CPU-only PyTorch in CI
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
@@ -782,10 +1012,62 @@ jobs:
|
||||
${{ runner.os }}-huggingface-embed-
|
||||
${{ runner.os }}-huggingface-
|
||||
|
||||
- name: Run unit and integration tests
|
||||
working-directory: ./hindsight-embed
|
||||
run: uv run pytest tests/ -v
|
||||
|
||||
- name: Run smoke test
|
||||
working-directory: ./hindsight-embed
|
||||
run: ./test.sh
|
||||
|
||||
test-hindsight-all:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: groq
|
||||
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
|
||||
# For test_server_integration.py compatibility
|
||||
HINDSIGHT_LLM_PROVIDER: groq
|
||||
HINDSIGHT_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
HINDSIGHT_LLM_MODEL: openai/gpt-oss-20b
|
||||
# Prefer CPU-only PyTorch in CI
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Build hindsight-all
|
||||
working-directory: ./hindsight
|
||||
run: uv build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight
|
||||
run: uv sync --frozen --extra test --index-strategy unsafe-best-match
|
||||
|
||||
- name: Cache HuggingFace models
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/huggingface
|
||||
key: ${{ runner.os }}-huggingface-all-${{ hashFiles('hindsight/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-huggingface-all-
|
||||
${{ runner.os }}-huggingface-
|
||||
|
||||
- name: Run unit tests
|
||||
working-directory: ./hindsight
|
||||
run: uv run pytest tests/ -v
|
||||
|
||||
test-doc-examples:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-rust-cli
|
||||
@@ -867,30 +1149,11 @@ jobs:
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run Python doc examples
|
||||
working-directory: ./hindsight-clients/python
|
||||
run: |
|
||||
for f in ../../hindsight-docs/examples/api/*.py; do
|
||||
echo "Running $f..."
|
||||
uv run python "$f"
|
||||
done
|
||||
|
||||
- name: Run Node.js doc examples
|
||||
run: |
|
||||
for f in hindsight-docs/examples/api/*.mjs; do
|
||||
echo "Running $f..."
|
||||
node "$f"
|
||||
done
|
||||
|
||||
- name: Configure CLI
|
||||
run: hindsight configure --api-url http://localhost:8888
|
||||
|
||||
- name: Run CLI doc examples
|
||||
run: |
|
||||
for f in hindsight-docs/examples/api/*.sh; do
|
||||
echo "Running $f..."
|
||||
bash "$f"
|
||||
done
|
||||
- name: Run all doc examples
|
||||
run: ./scripts/test-doc-examples.sh
|
||||
|
||||
- name: Show API server logs
|
||||
if: always()
|
||||
@@ -958,6 +1221,18 @@ jobs:
|
||||
working-directory: ./hindsight-dev
|
||||
run: uv run pytest upgrade_tests/ -v --tb=short
|
||||
|
||||
- name: Show upgrade test logs
|
||||
if: always()
|
||||
run: |
|
||||
echo "=== Upgrade Test Server Logs ==="
|
||||
for log in /tmp/upgrade-test-*.log; do
|
||||
if [ -f "$log" ]; then
|
||||
echo ""
|
||||
echo "--- $log ---"
|
||||
tail -500 "$log"
|
||||
fi
|
||||
done
|
||||
|
||||
verify-generated-files:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -1028,4 +1303,52 @@ jobs:
|
||||
git diff --stat
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ All generated files are up to date"
|
||||
echo "✓ All generated files are up to date"
|
||||
|
||||
check-openapi-compatibility:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full git history to access base branch
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install hindsight-dev dependencies
|
||||
run: |
|
||||
cd hindsight-dev && uv sync --frozen --index-strategy unsafe-best-match
|
||||
|
||||
- name: Check OpenAPI compatibility with base branch
|
||||
run: |
|
||||
# Get the base branch (usually main)
|
||||
BASE_BRANCH="${{ github.base_ref }}"
|
||||
|
||||
if [ -z "$BASE_BRANCH" ]; then
|
||||
echo "⚠️ Warning: No base branch found (not a PR?). Skipping compatibility check."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Checking OpenAPI compatibility against base branch: $BASE_BRANCH"
|
||||
|
||||
# Extract the old OpenAPI spec from base branch
|
||||
git show "origin/$BASE_BRANCH:hindsight-docs/static/openapi.json" > /tmp/old-openapi.json
|
||||
|
||||
if [ ! -s /tmp/old-openapi.json ]; then
|
||||
echo "⚠️ Warning: Could not find OpenAPI spec in base branch. Skipping compatibility check."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check compatibility using our tool
|
||||
cd hindsight-dev
|
||||
uv run check-openapi-compatibility /tmp/old-openapi.json ../hindsight-docs/static/openapi.json
|
||||
+4
-1
@@ -46,6 +46,7 @@ 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
|
||||
@@ -53,4 +54,6 @@ hindsight-clients/rust/target
|
||||
whats-next.md
|
||||
TASK.md
|
||||
# Changelog is now tracked in hindsight-docs/src/pages/changelog.md
|
||||
# CHANGELOG.md
|
||||
# CHANGELOG.md
|
||||
|
||||
blog-post*
|
||||
@@ -45,6 +45,7 @@ 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)
|
||||
@@ -56,8 +57,15 @@ 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
|
||||
```
|
||||
|
||||
@@ -237,26 +245,61 @@ def process(data: UserData) -> str:
|
||||
|
||||
### Adding New API Configuration Flags
|
||||
|
||||
When adding a new environment variable configuration:
|
||||
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
|
||||
|
||||
1. **config.py** (`hindsight-api/hindsight_api/config.py`):
|
||||
- Add `ENV_*` constant for the environment variable name
|
||||
- Add `ENV_*` constant for the environment variable name (e.g., `ENV_MY_SETTING = "HINDSIGHT_API_MY_SETTING"`)
|
||||
- Add `DEFAULT_*` constant for the default value
|
||||
- Add field to `HindsightConfig` dataclass
|
||||
- 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 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 the config** in code:
|
||||
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):
|
||||
```python
|
||||
from ...config import get_config
|
||||
config = get_config()
|
||||
value = config.your_new_field
|
||||
value = config.my_static_field
|
||||
```
|
||||
|
||||
4. **Documentation** (`hindsight-docs/docs/developer/configuration.md`):
|
||||
5. **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
|
||||
|
||||
@@ -280,3 +323,4 @@ 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: false, disabled for security)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||

|
||||
|
||||
[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)
|
||||
[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)
|
||||
|
||||
[](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
|
||||
[](https://join.slack.com/t/hindsight-space/shared_invite/zt-3nhbm4w29-LeSJ5Ixi6j8PdiYOCPlOgg)
|
||||
@@ -42,27 +42,51 @@ If you need more control over how and when your agent stores and recalls memorie
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
> 🤖 **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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker (recommended)
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=your-key
|
||||
export OPENAI_API_KEY=sk-xxx
|
||||
|
||||
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
|
||||
|
||||
Install client:
|
||||
|
||||
### 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
|
||||
|
||||
```bash
|
||||
pip install hindsight-client -U
|
||||
@@ -70,7 +94,7 @@ pip install hindsight-client -U
|
||||
npm install @vectorize-io/hindsight-client
|
||||
```
|
||||
|
||||
Python example:
|
||||
#### Python
|
||||
|
||||
```python
|
||||
from hindsight_client import Hindsight
|
||||
@@ -87,7 +111,29 @@ client.recall(bank_id="my-bank", query="What does Alice do?")
|
||||
client.reflect(bank_id="my-bank", query="Tell me about Alice")
|
||||
```
|
||||
|
||||
### Python (embedded, no Docker)
|
||||
#### 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)
|
||||
|
||||
```bash
|
||||
pip install hindsight-all -U
|
||||
@@ -107,20 +153,6 @@ with HindsightServer(
|
||||
results = client.recall(bank_id="my-bank", query="Where does Alice work?")
|
||||
```
|
||||
|
||||
### Node.js / TypeScript
|
||||
|
||||
```bash
|
||||
npm install @vectorize-io/hindsight-client
|
||||
```
|
||||
|
||||
```javascript
|
||||
const { HindsightClient } = require('@vectorize-io/hindsight-client');
|
||||
|
||||
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
|
||||
|
||||
await client.retain('my-bank', 'Alice loves hiking in Yosemite');
|
||||
await client.recall('my-bank', 'What does Alice like?');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# 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:
|
||||
@@ -0,0 +1,96 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,88 @@
|
||||
# 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:
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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
|
||||
@@ -0,0 +1,91 @@
|
||||
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:
|
||||
@@ -0,0 +1,83 @@
|
||||
# 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:
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"identities": [
|
||||
{
|
||||
"name": "hindsight",
|
||||
"credentials": [
|
||||
{
|
||||
"accessKey": "hindsight_s3_key",
|
||||
"secretKey": "hindsight_s3_secret"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
"Admin",
|
||||
"Read",
|
||||
"Write",
|
||||
"List"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Docker
|
||||
docker-compose.yaml
|
||||
.dockerignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.example
|
||||
@@ -0,0 +1,25 @@
|
||||
# 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
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,108 @@
|
||||
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:
|
||||
@@ -0,0 +1,93 @@
|
||||
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:
|
||||
@@ -8,6 +8,7 @@
|
||||
# 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)
|
||||
@@ -111,6 +112,10 @@ 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
|
||||
@@ -167,6 +172,28 @@ USER hindsight
|
||||
|
||||
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
|
||||
@@ -185,7 +212,6 @@ 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('Downloading tiktoken encoding...'); import tiktoken; tiktoken.get_encoding('cl100k_base'); \
|
||||
print('Models cached successfully')" && break; \
|
||||
if [ $i -lt $MAX_RETRIES ]; then \
|
||||
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
|
||||
@@ -208,6 +234,10 @@ 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"]
|
||||
|
||||
@@ -293,6 +323,28 @@ USER hindsight
|
||||
|
||||
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
|
||||
@@ -311,7 +363,6 @@ 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('Downloading tiktoken encoding...'); import tiktoken; tiktoken.get_encoding('cl100k_base'); \
|
||||
print('Models cached successfully')" && break; \
|
||||
if [ $i -lt $MAX_RETRIES ]; then \
|
||||
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
|
||||
@@ -336,6 +387,10 @@ 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"]
|
||||
|
||||
|
||||
@@ -6,28 +6,40 @@
|
||||
# Can be run locally or in CI pipelines.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/docker-smoke-test.sh <image> [target]
|
||||
# ./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:
|
||||
# GROQ_API_KEY - Required for API/standalone images (LLM verification)
|
||||
# HINDSIGHT_API_LLM_PROVIDER - LLM provider (default: groq)
|
||||
# HINDSIGHT_API_LLM_MODEL - LLM model (default: llama-3.3-70b-versatile)
|
||||
# SMOKE_TEST_TIMEOUT - Timeout in seconds (default: 120)
|
||||
# SMOKE_TEST_CONTAINER_NAME - Container name (default: hindsight-smoke-test)
|
||||
# GROQ_API_KEY - Required for API/standalone images (LLM verification)
|
||||
# HINDSIGHT_API_LLM_PROVIDER - LLM provider (default: groq)
|
||||
# HINDSIGHT_API_LLM_MODEL - LLM model (default: llama-3.3-70b-versatile)
|
||||
# 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 image
|
||||
# ./scripts/docker-smoke-test.sh hindsight-api:test
|
||||
# # Test a locally built full image
|
||||
# ./docker/test-image.sh hindsight-api:test
|
||||
#
|
||||
# # Test a released image
|
||||
# ./scripts/docker-smoke-test.sh ghcr.io/vectorize-io/hindsight:latest
|
||||
# ./docker/test-image.sh ghcr.io/vectorize-io/hindsight:latest
|
||||
#
|
||||
# # Test control plane image
|
||||
# ./scripts/docker-smoke-test.sh hindsight-control-plane:test cp-only
|
||||
# ./docker/test-image.sh hindsight-control-plane:test cp-only
|
||||
#
|
||||
# # Test slim image with external providers
|
||||
# export GROQ_API_KEY=gsk_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)
|
||||
@@ -108,12 +120,32 @@ if [ "$TARGET" = "cp-only" ]; then
|
||||
-p "${HEALTH_PORT}:${HEALTH_PORT}" \
|
||||
"$IMAGE"
|
||||
else
|
||||
docker run -d --name "$CONTAINER_NAME" \
|
||||
-e HINDSIGHT_API_LLM_PROVIDER="$LLM_PROVIDER" \
|
||||
-e HINDSIGHT_API_LLM_API_KEY="${GROQ_API_KEY}" \
|
||||
-e HINDSIGHT_API_LLM_MODEL="$LLM_MODEL" \
|
||||
-p "${HEALTH_PORT}:${HEALTH_PORT}" \
|
||||
"$IMAGE"
|
||||
# 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"
|
||||
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_API_KEY=${GROQ_API_KEY}"
|
||||
DOCKER_CMD="$DOCKER_CMD -e HINDSIGHT_API_LLM_MODEL=$LLM_MODEL"
|
||||
|
||||
# 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
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/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 GROQ_API_KEY=gsk_xxx
|
||||
# export OPENAI_API_KEY=sk-xxx
|
||||
# export COHERE_API_KEY=xxx
|
||||
# ./docker/test-slim-local.sh
|
||||
#
|
||||
# Or inline:
|
||||
# GROQ_API_KEY=gsk_xxx OPENAI_API_KEY=sk_xxx COHERE_API_KEY=xxx ./docker/test-slim-local.sh
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Check for required API keys
|
||||
if [ -z "${GROQ_API_KEY:-}" ]; then
|
||||
echo "❌ Error: GROQ_API_KEY environment variable is required"
|
||||
echo "Set it with: export GROQ_API_KEY=gsk_xxx"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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 external providers
|
||||
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,8 +2,8 @@ apiVersion: v2
|
||||
name: hindsight
|
||||
description: Hindsight helm chart
|
||||
type: application
|
||||
version: 0.4.7
|
||||
appVersion: "0.4.7"
|
||||
version: 0.4.12
|
||||
appVersion: "0.4.12"
|
||||
keywords:
|
||||
- ai
|
||||
- memory
|
||||
|
||||
@@ -127,6 +127,38 @@ 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
|
||||
*/}}
|
||||
|
||||
@@ -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 }}"
|
||||
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag | default .Values.version | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.api.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
@@ -60,10 +60,25 @@ 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 }}
|
||||
@@ -84,7 +99,7 @@ spec:
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
{{- with (.Values.api.affinity | default .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 }}"
|
||||
image: "{{ .Values.controlPlane.image.repository }}:{{ .Values.controlPlane.image.tag | default .Values.version | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.controlPlane.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
@@ -71,7 +71,7 @@ spec:
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
{{- with (.Values.controlPlane.affinity | default .Values.affinity) }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{{- 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 }}
|
||||
@@ -0,0 +1,76 @@
|
||||
{{- 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 }}
|
||||
@@ -0,0 +1,17 @@
|
||||
{{- 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 }}
|
||||
@@ -0,0 +1,76 @@
|
||||
{{- 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 }}
|
||||
@@ -0,0 +1,17 @@
|
||||
{{- 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 }}"
|
||||
image: "{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag | default .Values.version | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.worker.image.pullPolicy }}
|
||||
command: ["hindsight-worker"]
|
||||
ports:
|
||||
@@ -99,7 +99,7 @@ spec:
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
{{- with (.Values.worker.affinity | default .Values.affinity) }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
+110
-4
@@ -1,7 +1,8 @@
|
||||
# Default values for hindsight
|
||||
|
||||
# Chart version - use this to set a consistent image tag across all components
|
||||
version: "0.1.1"
|
||||
# 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: ""
|
||||
|
||||
# Use an existing secret instead of creating one from values
|
||||
# When set, all keys from this secret are injected as environment variables via envFrom
|
||||
@@ -57,6 +58,15 @@ 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"
|
||||
@@ -75,7 +85,7 @@ worker:
|
||||
image:
|
||||
repository: ghcr.io/vectorize-io/hindsight-api
|
||||
pullPolicy: IfNotPresent
|
||||
# tag defaults to .Values.version if not specified
|
||||
# tag: "" # defaults to .Values.version, then Chart.appVersion if not specified
|
||||
|
||||
service:
|
||||
# Service for metrics scraping (headless for StatefulSet)
|
||||
@@ -121,6 +131,15 @@ 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: {}
|
||||
|
||||
@@ -164,6 +183,15 @@ 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"
|
||||
@@ -262,9 +290,87 @@ nodeSelector: {}
|
||||
# Tolerations
|
||||
tolerations: []
|
||||
|
||||
# Affinity
|
||||
# Affinity (applied to all components unless overridden per-component)
|
||||
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
|
||||
|
||||
@@ -46,4 +46,4 @@ __all__ = [
|
||||
"RemoteTEICrossEncoder",
|
||||
"LLMConfig",
|
||||
]
|
||||
__version__ = "0.4.7"
|
||||
__version__ = "0.4.12"
|
||||
|
||||
@@ -6,11 +6,13 @@ 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.
|
||||
@@ -20,11 +22,114 @@ 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."""
|
||||
|
||||
# Enable required extensions
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
# 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
|
||||
|
||||
# Create banks table
|
||||
op.create_table(
|
||||
@@ -152,11 +257,29 @@ def upgrade() -> None:
|
||||
)
|
||||
|
||||
# Add search_vector column for full-text search
|
||||
op.execute("""
|
||||
ALTER TABLE memory_units
|
||||
ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('english', COALESCE(text, '') || ' ' || COALESCE(context, ''))) STORED
|
||||
""")
|
||||
# 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.create_index("idx_memory_units_bank_id", "memory_units", ["bank_id"])
|
||||
op.create_index("idx_memory_units_document_id", "memory_units", ["document_id"])
|
||||
@@ -186,19 +309,61 @@ def upgrade() -> None:
|
||||
["bank_id", sa.text("event_date DESC")],
|
||||
postgresql_where=sa.text("fact_type = 'observation'"),
|
||||
)
|
||||
op.create_index(
|
||||
"idx_memory_units_embedding",
|
||||
"memory_units",
|
||||
["embedding"],
|
||||
postgresql_using="hnsw",
|
||||
postgresql_ops={"embedding": "vector_cosine_ops"},
|
||||
)
|
||||
# Create vector index - conditional based on available extension
|
||||
vector_ext = _detect_vector_extension()
|
||||
|
||||
# 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)
|
||||
""")
|
||||
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)
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE MATERIALIZED VIEW memory_units_bm25 AS
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""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")
|
||||
+204
-21
@@ -10,9 +10,11 @@ This migration:
|
||||
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"
|
||||
@@ -27,10 +29,106 @@ def _get_schema_prefix() -> str:
|
||||
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 (
|
||||
@@ -57,18 +155,60 @@ def upgrade() -> None:
|
||||
|
||||
# Indexes for learnings
|
||||
op.execute(f"CREATE INDEX idx_learnings_bank_id ON {schema}learnings(bank_id)")
|
||||
op.execute(f"""
|
||||
CREATE INDEX idx_learnings_embedding ON {schema}learnings
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
""")
|
||||
|
||||
# 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
|
||||
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)")
|
||||
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"""
|
||||
@@ -94,21 +234,64 @@ def upgrade() -> None:
|
||||
|
||||
# Indexes for pinned_reflections
|
||||
op.execute(f"CREATE INDEX idx_pinned_reflections_bank_id ON {schema}pinned_reflections(bank_id)")
|
||||
op.execute(f"""
|
||||
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
""")
|
||||
|
||||
# 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
|
||||
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)
|
||||
""")
|
||||
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"""
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
"""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)
|
||||
""")
|
||||
@@ -0,0 +1,64 @@
|
||||
"""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
|
||||
""")
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
"""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")
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
|
||||
@@ -46,14 +45,14 @@ def create_app(
|
||||
# Both HTTP and MCP
|
||||
app = create_app(memory, mcp_api_enabled=True)
|
||||
"""
|
||||
mcp_app = None
|
||||
mcp_servers = None
|
||||
|
||||
# Create MCP app first if enabled (we need its lifespan for chaining)
|
||||
# Create MCP servers first if enabled (we need their lifespans for chaining)
|
||||
if mcp_api_enabled:
|
||||
try:
|
||||
from .mcp import create_mcp_app
|
||||
from .mcp import MCPMiddleware, create_mcp_servers
|
||||
|
||||
mcp_app = create_mcp_app(memory=memory)
|
||||
mcp_servers = create_mcp_servers(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]")
|
||||
@@ -70,30 +69,41 @@ def create_app(
|
||||
app = FastAPI(title="Hindsight API", version="0.0.7")
|
||||
logger.info("HTTP REST API disabled")
|
||||
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# Store the original lifespan
|
||||
original_lifespan = app.router.lifespan_context
|
||||
|
||||
@asynccontextmanager
|
||||
async def chained_lifespan(app_instance: FastAPI):
|
||||
"""Chain the MCP lifespan with the main app lifespan."""
|
||||
# Start MCP lifespan first
|
||||
async with mcp_starlette_app.router.lifespan_context(mcp_starlette_app):
|
||||
logger.info("MCP lifespan started")
|
||||
# Then start the original app lifespan
|
||||
async with original_lifespan(app_instance):
|
||||
yield
|
||||
logger.info("MCP lifespan stopped")
|
||||
"""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")
|
||||
|
||||
# Replace the app's lifespan with the chained version
|
||||
app.router.lifespan_context = chained_lifespan
|
||||
|
||||
# Mount the MCP middleware
|
||||
app.mount(mcp_mount_path, mcp_app)
|
||||
# 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,
|
||||
)
|
||||
|
||||
logger.info(f"MCP server enabled at {mcp_mount_path}/")
|
||||
|
||||
return app
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,11 @@ from contextvars import ContextVar
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from hindsight_api import MemoryEngine
|
||||
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.models import RequestContext
|
||||
|
||||
# Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable
|
||||
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower()
|
||||
@@ -29,7 +33,8 @@ logger = logging.getLogger(__name__)
|
||||
# Default bank_id from environment variable
|
||||
DEFAULT_BANK_ID = os.environ.get("HINDSIGHT_MCP_BANK_ID", "default")
|
||||
|
||||
# MCP authentication token (optional - if set, Bearer token auth is required)
|
||||
# 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
|
||||
@@ -38,6 +43,10 @@ _current_bank_id: ContextVar[str | None] = ContextVar("current_bank_id", default
|
||||
# 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."""
|
||||
@@ -49,57 +58,161 @@ def get_current_api_key() -> str | None:
|
||||
return _current_api_key.get()
|
||||
|
||||
|
||||
def create_mcp_server(memory: MemoryEngine) -> FastMCP:
|
||||
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:
|
||||
"""
|
||||
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 with stateless_http enabled
|
||||
Configured FastMCP server instance
|
||||
"""
|
||||
# Use stateless_http=True for Claude Code compatibility
|
||||
mcp = FastMCP("hindsight-mcp-server", stateless_http=True)
|
||||
mcp = FastMCP("hindsight-mcp-server")
|
||||
|
||||
# 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
|
||||
include_bank_id_param=True, # HTTP MCP supports multi-bank via parameter
|
||||
tools=None, # All tools
|
||||
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=None
|
||||
if multi_bank
|
||||
else {
|
||||
"retain",
|
||||
"recall",
|
||||
"reflect",
|
||||
"list_mental_models",
|
||||
"get_mental_model",
|
||||
"create_mental_model",
|
||||
"update_mental_model",
|
||||
"delete_mental_model",
|
||||
"refresh_mental_model",
|
||||
}, # Scoped tools for single-bank mode (excludes bank management: list_banks, create_bank)
|
||||
retain_fire_and_forget=False, # HTTP MCP supports sync/async modes
|
||||
)
|
||||
|
||||
register_mcp_tools(mcp, memory, config)
|
||||
|
||||
# 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)
|
||||
|
||||
# Make all tools tolerant of extra arguments from LLMs (e.g., "explanation")
|
||||
_make_tools_tolerant(mcp)
|
||||
|
||||
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 handles authentication and extracts bank_id from header or path.
|
||||
"""ASGI middleware that intercepts MCP requests and routes to appropriate MCP server.
|
||||
|
||||
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.
|
||||
|
||||
Authentication:
|
||||
If HINDSIGHT_API_MCP_AUTH_TOKEN is set, all requests must include a valid
|
||||
Authorization header with Bearer token or direct token matching the configured value.
|
||||
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
|
||||
|
||||
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)
|
||||
Two modes based on URL structure:
|
||||
|
||||
For Claude Code, configure with:
|
||||
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)
|
||||
claude mcp add --transport http hindsight http://localhost:8888/mcp \\
|
||||
--header "X-Bank-Id: my-bank" --header "Authorization: Bearer <token>"
|
||||
"""
|
||||
|
||||
def __init__(self, app, memory: MemoryEngine):
|
||||
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,
|
||||
):
|
||||
self.app = app
|
||||
self.prefix = prefix
|
||||
self.memory = memory
|
||||
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
|
||||
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)
|
||||
|
||||
def _get_header(self, scope: dict, name: str) -> str | None:
|
||||
"""Extract a header value from ASGI scope."""
|
||||
@@ -111,9 +224,20 @@ class MCPMiddleware:
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.mcp_app(scope, receive, send)
|
||||
await self.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 prefix from path
|
||||
path = path[len(self.prefix) :] or "/"
|
||||
|
||||
# Extract auth token from header (for tenant auth propagation)
|
||||
auth_header = self._get_header(scope, "Authorization")
|
||||
auth_token: str | None = None
|
||||
@@ -121,62 +245,92 @@ class MCPMiddleware:
|
||||
# Support both "Bearer <token>" and direct token
|
||||
auth_token = auth_header[7:].strip() if auth_header.startswith("Bearer ") else auth_header.strip()
|
||||
|
||||
# Authenticate if MCP_AUTH_TOKEN is configured
|
||||
# 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))
|
||||
return
|
||||
|
||||
path = scope.get("path", "")
|
||||
# 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
|
||||
)
|
||||
|
||||
# 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 "/"
|
||||
|
||||
# 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 = "/"
|
||||
|
||||
# Try to get bank_id from header first (for Claude Code compatibility)
|
||||
bank_id = self._get_header(scope, "X-Bank-Id")
|
||||
|
||||
# MCP endpoint paths that should not be treated as bank_ids
|
||||
MCP_ENDPOINTS = {"sse", "messages"}
|
||||
|
||||
# If no header, try to extract from path: /{bank_id}/...
|
||||
# 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
|
||||
new_path = path
|
||||
if not bank_id and path.startswith("/") and len(path) > 1:
|
||||
|
||||
# First, try to extract from path: /{bank_id}/...
|
||||
if path.startswith("/") and len(path) > 1:
|
||||
parts = path[1:].split("/", 1)
|
||||
# Don't treat MCP endpoints as bank_ids
|
||||
if parts[0] and parts[0] not in MCP_ENDPOINTS:
|
||||
# First segment looks like a bank_id
|
||||
if parts[0]:
|
||||
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}")
|
||||
|
||||
# Set bank_id and api_key context
|
||||
# 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
|
||||
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
|
||||
# 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
|
||||
|
||||
async def send_wrapper(message):
|
||||
if message["type"] == "http.response.body":
|
||||
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:
|
||||
body = message.get("body", b"")
|
||||
if body and b"/messages" in body:
|
||||
# Rewrite /messages to /{bank_id}/messages in SSE endpoint event
|
||||
@@ -184,11 +338,17 @@ class MCPMiddleware:
|
||||
message = {**message, "body": body}
|
||||
await send(message)
|
||||
|
||||
await self.mcp_app(new_scope, receive, send_wrapper)
|
||||
await target_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)
|
||||
|
||||
async def _send_error(self, send, status: int, message: str):
|
||||
"""Send an error response."""
|
||||
@@ -208,23 +368,19 @@ class MCPMiddleware:
|
||||
)
|
||||
|
||||
|
||||
def create_mcp_app(memory: MemoryEngine):
|
||||
"""
|
||||
Create an ASGI app that handles MCP requests.
|
||||
def create_mcp_servers(memory: MemoryEngine):
|
||||
"""Create multi-bank and single-bank MCP servers and their Starlette apps.
|
||||
|
||||
Authentication:
|
||||
Set HINDSIGHT_API_MCP_AUTH_TOKEN to require Bearer token authentication.
|
||||
If not set, MCP endpoint is open (for local development).
|
||||
|
||||
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 the servers and apps separately so lifespans can be chained before
|
||||
the middleware wraps the main app.
|
||||
|
||||
Returns:
|
||||
ASGI application
|
||||
Tuple of (multi_bank_server, single_bank_server, multi_bank_app, single_bank_app)
|
||||
"""
|
||||
return MCPMiddleware(None, memory)
|
||||
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
|
||||
|
||||
@@ -4,6 +4,8 @@ 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
|
||||
@@ -84,16 +86,20 @@ def print_startup_info(
|
||||
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(database_url, 0.4)}")
|
||||
print(f" {dim('Database:')} {color(mask_network_location(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()
|
||||
|
||||
@@ -8,8 +8,9 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field, fields
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from dotenv import find_dotenv, load_dotenv
|
||||
|
||||
@@ -18,6 +19,103 @@ load_dotenv(find_dotenv(usecwd=True), override=True)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConfigFieldAccessError(AttributeError):
|
||||
"""Raised when trying to access a bank-configurable field from global config."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class StaticConfigProxy:
|
||||
"""
|
||||
Proxy that wraps HindsightConfig and only allows access to static (non-configurable) fields.
|
||||
|
||||
Raises ConfigFieldAccessError when trying to access configurable fields that vary per-bank.
|
||||
Forces developers to use get_resolved_config(bank_id, context) for bank-specific settings.
|
||||
"""
|
||||
|
||||
def __init__(self, config: "HindsightConfig"):
|
||||
object.__setattr__(self, "_config", config)
|
||||
object.__setattr__(self, "_configurable_fields", HindsightConfig.get_configurable_fields())
|
||||
|
||||
def __getattribute__(self, name: str):
|
||||
if name.startswith("_"):
|
||||
return object.__getattribute__(self, name)
|
||||
|
||||
configurable_fields = object.__getattribute__(self, "_configurable_fields")
|
||||
if name in configurable_fields:
|
||||
raise ConfigFieldAccessError(
|
||||
f"Field '{name}' is bank-configurable and cannot be accessed from global config. "
|
||||
f"Use ConfigResolver.resolve_full_config(bank_id, context) to get bank-specific config. "
|
||||
f"This prevents accidentally using global defaults when bank-specific overrides exist."
|
||||
)
|
||||
|
||||
config = object.__getattribute__(self, "_config")
|
||||
return getattr(config, name)
|
||||
|
||||
def __setattr__(self, name: str, value):
|
||||
raise AttributeError("Config is read-only. Modifications must go through ConfigResolver.")
|
||||
|
||||
|
||||
# Configuration field markers for hierarchical configuration
|
||||
def hierarchical(default_value):
|
||||
"""
|
||||
Mark a config field as hierarchical (can be overridden per-tenant/bank).
|
||||
|
||||
Hierarchical fields can be customized at the tenant or bank level via database
|
||||
configuration. Examples: LLM settings, retention parameters, retrieval settings.
|
||||
"""
|
||||
return field(default=default_value, metadata={"hierarchical": True})
|
||||
|
||||
|
||||
def static(default_value):
|
||||
"""
|
||||
Mark a config field as static (server-level only, cannot be overridden).
|
||||
|
||||
Static fields are infrastructure-level settings that affect the entire server
|
||||
and cannot vary per tenant or bank. Examples: database URL, API port, worker settings.
|
||||
"""
|
||||
return field(default=default_value, metadata={"hierarchical": False})
|
||||
|
||||
|
||||
# Configuration key normalization utilities
|
||||
def normalize_config_key(key: str) -> str:
|
||||
"""
|
||||
Convert environment variable format to Python field name format.
|
||||
|
||||
Examples:
|
||||
HINDSIGHT_API_LLM_PROVIDER -> llm_provider
|
||||
LLM_MODEL -> llm_model
|
||||
llm_model -> llm_model (already normalized)
|
||||
|
||||
Args:
|
||||
key: Environment variable name or Python field name
|
||||
|
||||
Returns:
|
||||
Normalized Python field name (lowercase snake_case)
|
||||
"""
|
||||
if key.startswith("HINDSIGHT_API_"):
|
||||
key = key[len("HINDSIGHT_API_") :]
|
||||
return key.lower()
|
||||
|
||||
|
||||
def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Normalize all keys in a config dict to Python field names.
|
||||
|
||||
Allows users to provide config overrides in either format:
|
||||
- Python field format: {"llm_provider": "openai"}
|
||||
- Env var format: {"HINDSIGHT_API_LLM_PROVIDER": "openai"}
|
||||
|
||||
Args:
|
||||
config: Dict with env var or Python field names as keys
|
||||
|
||||
Returns:
|
||||
Dict with all keys normalized to Python field names
|
||||
"""
|
||||
return {normalize_config_key(k): v for k, v in config.items()}
|
||||
|
||||
|
||||
# Environment variable names
|
||||
ENV_DATABASE_URL = "HINDSIGHT_API_DATABASE_URL"
|
||||
ENV_DATABASE_SCHEMA = "HINDSIGHT_API_DATABASE_SCHEMA"
|
||||
@@ -31,6 +129,11 @@ ENV_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_LLM_INITIAL_BACKOFF"
|
||||
ENV_LLM_MAX_BACKOFF = "HINDSIGHT_API_LLM_MAX_BACKOFF"
|
||||
ENV_LLM_TIMEOUT = "HINDSIGHT_API_LLM_TIMEOUT"
|
||||
ENV_LLM_GROQ_SERVICE_TIER = "HINDSIGHT_API_LLM_GROQ_SERVICE_TIER"
|
||||
ENV_LLM_OPENAI_SERVICE_TIER = "HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER"
|
||||
|
||||
# Defaults for service tiers
|
||||
DEFAULT_LLM_GROQ_SERVICE_TIER = "auto" # "on_demand", "flex", or "auto"
|
||||
DEFAULT_LLM_OPENAI_SERVICE_TIER = None # None (default) or "flex" (50% cheaper)
|
||||
|
||||
# Per-operation LLM configuration (optional, falls back to global LLM config)
|
||||
ENV_RETAIN_LLM_PROVIDER = "HINDSIGHT_API_RETAIN_LLM_PROVIDER"
|
||||
@@ -66,27 +169,48 @@ ENV_CONSOLIDATION_LLM_TIMEOUT = "HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT"
|
||||
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
|
||||
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
|
||||
ENV_EMBEDDINGS_LOCAL_FORCE_CPU = "HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"
|
||||
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = "HINDSIGHT_API_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE"
|
||||
ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL"
|
||||
ENV_EMBEDDINGS_OPENAI_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY"
|
||||
ENV_EMBEDDINGS_OPENAI_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL"
|
||||
ENV_EMBEDDINGS_OPENAI_BASE_URL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL"
|
||||
|
||||
ENV_COHERE_API_KEY = "HINDSIGHT_API_COHERE_API_KEY"
|
||||
# Cohere configuration (separate for embeddings and reranker)
|
||||
ENV_EMBEDDINGS_COHERE_API_KEY = "HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY"
|
||||
ENV_EMBEDDINGS_COHERE_MODEL = "HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL"
|
||||
ENV_EMBEDDINGS_COHERE_BASE_URL = "HINDSIGHT_API_EMBEDDINGS_COHERE_BASE_URL"
|
||||
ENV_RERANKER_COHERE_API_KEY = "HINDSIGHT_API_RERANKER_COHERE_API_KEY"
|
||||
ENV_RERANKER_COHERE_MODEL = "HINDSIGHT_API_RERANKER_COHERE_MODEL"
|
||||
ENV_RERANKER_COHERE_BASE_URL = "HINDSIGHT_API_RERANKER_COHERE_BASE_URL"
|
||||
|
||||
# LiteLLM gateway configuration (for embeddings and reranker via LiteLLM proxy)
|
||||
# Deprecated: Legacy shared Cohere API key (for backward compatibility)
|
||||
ENV_COHERE_API_KEY = "HINDSIGHT_API_COHERE_API_KEY"
|
||||
|
||||
# LiteLLM configuration (separate for embeddings and reranker)
|
||||
ENV_EMBEDDINGS_LITELLM_API_BASE = "HINDSIGHT_API_EMBEDDINGS_LITELLM_API_BASE"
|
||||
ENV_EMBEDDINGS_LITELLM_API_KEY = "HINDSIGHT_API_EMBEDDINGS_LITELLM_API_KEY"
|
||||
ENV_EMBEDDINGS_LITELLM_MODEL = "HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL"
|
||||
ENV_RERANKER_LITELLM_API_BASE = "HINDSIGHT_API_RERANKER_LITELLM_API_BASE"
|
||||
ENV_RERANKER_LITELLM_API_KEY = "HINDSIGHT_API_RERANKER_LITELLM_API_KEY"
|
||||
ENV_RERANKER_LITELLM_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_MODEL"
|
||||
|
||||
# LiteLLM SDK configuration (direct API access, no proxy needed)
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_API_KEY = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_KEY"
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_MODEL = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MODEL"
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_API_BASE = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_BASE"
|
||||
ENV_RERANKER_LITELLM_SDK_API_KEY = "HINDSIGHT_API_RERANKER_LITELLM_SDK_API_KEY"
|
||||
ENV_RERANKER_LITELLM_SDK_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_SDK_MODEL"
|
||||
ENV_RERANKER_LITELLM_SDK_API_BASE = "HINDSIGHT_API_RERANKER_LITELLM_SDK_API_BASE"
|
||||
|
||||
# Deprecated: Legacy shared LiteLLM config (for backward compatibility)
|
||||
ENV_LITELLM_API_BASE = "HINDSIGHT_API_LITELLM_API_BASE"
|
||||
ENV_LITELLM_API_KEY = "HINDSIGHT_API_LITELLM_API_KEY"
|
||||
ENV_EMBEDDINGS_LITELLM_MODEL = "HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL"
|
||||
ENV_RERANKER_LITELLM_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_MODEL"
|
||||
|
||||
ENV_RERANKER_PROVIDER = "HINDSIGHT_API_RERANKER_PROVIDER"
|
||||
ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL"
|
||||
ENV_RERANKER_LOCAL_FORCE_CPU = "HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"
|
||||
ENV_RERANKER_LOCAL_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT"
|
||||
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE = "HINDSIGHT_API_RERANKER_LOCAL_TRUST_REMOTE_CODE"
|
||||
ENV_RERANKER_TEI_URL = "HINDSIGHT_API_RERANKER_TEI_URL"
|
||||
ENV_RERANKER_TEI_BATCH_SIZE = "HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE"
|
||||
ENV_RERANKER_TEI_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT"
|
||||
@@ -94,12 +218,17 @@ ENV_RERANKER_MAX_CANDIDATES = "HINDSIGHT_API_RERANKER_MAX_CANDIDATES"
|
||||
ENV_RERANKER_FLASHRANK_MODEL = "HINDSIGHT_API_RERANKER_FLASHRANK_MODEL"
|
||||
ENV_RERANKER_FLASHRANK_CACHE_DIR = "HINDSIGHT_API_RERANKER_FLASHRANK_CACHE_DIR"
|
||||
|
||||
ENV_VECTOR_EXTENSION = "HINDSIGHT_API_VECTOR_EXTENSION"
|
||||
ENV_TEXT_SEARCH_EXTENSION = "HINDSIGHT_API_TEXT_SEARCH_EXTENSION"
|
||||
|
||||
ENV_HOST = "HINDSIGHT_API_HOST"
|
||||
ENV_PORT = "HINDSIGHT_API_PORT"
|
||||
ENV_BASE_PATH = "HINDSIGHT_API_BASE_PATH"
|
||||
ENV_LOG_LEVEL = "HINDSIGHT_API_LOG_LEVEL"
|
||||
ENV_LOG_FORMAT = "HINDSIGHT_API_LOG_FORMAT"
|
||||
ENV_WORKERS = "HINDSIGHT_API_WORKERS"
|
||||
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
|
||||
ENV_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API"
|
||||
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
|
||||
ENV_MPFP_TOP_K_NEIGHBORS = "HINDSIGHT_API_MPFP_TOP_K_NEIGHBORS"
|
||||
ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
|
||||
@@ -108,6 +237,13 @@ ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
|
||||
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
|
||||
ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY"
|
||||
|
||||
# OpenTelemetry tracing configuration
|
||||
ENV_OTEL_TRACES_ENABLED = "HINDSIGHT_API_OTEL_TRACES_ENABLED"
|
||||
ENV_OTEL_EXPORTER_OTLP_ENDPOINT = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT"
|
||||
ENV_OTEL_EXPORTER_OTLP_HEADERS = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS"
|
||||
ENV_OTEL_SERVICE_NAME = "HINDSIGHT_API_OTEL_SERVICE_NAME"
|
||||
ENV_OTEL_DEPLOYMENT_ENVIRONMENT = "HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT"
|
||||
|
||||
# Vertex AI configuration
|
||||
ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
|
||||
ENV_LLM_VERTEXAI_REGION = "HINDSIGHT_API_LLM_VERTEXAI_REGION"
|
||||
@@ -119,6 +255,29 @@ ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
|
||||
ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
|
||||
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
|
||||
ENV_RETAIN_CUSTOM_INSTRUCTIONS = "HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"
|
||||
ENV_RETAIN_BATCH_TOKENS = "HINDSIGHT_API_RETAIN_BATCH_TOKENS"
|
||||
ENV_RETAIN_BATCH_ENABLED = "HINDSIGHT_API_RETAIN_BATCH_ENABLED"
|
||||
ENV_RETAIN_BATCH_POLL_INTERVAL_SECONDS = "HINDSIGHT_API_RETAIN_BATCH_POLL_INTERVAL_SECONDS"
|
||||
|
||||
# File storage configuration
|
||||
ENV_FILE_STORAGE_TYPE = "HINDSIGHT_API_FILE_STORAGE_TYPE"
|
||||
ENV_FILE_STORAGE_S3_BUCKET = "HINDSIGHT_API_FILE_STORAGE_S3_BUCKET"
|
||||
ENV_FILE_STORAGE_S3_REGION = "HINDSIGHT_API_FILE_STORAGE_S3_REGION"
|
||||
ENV_FILE_STORAGE_S3_ENDPOINT = "HINDSIGHT_API_FILE_STORAGE_S3_ENDPOINT"
|
||||
ENV_FILE_STORAGE_S3_ACCESS_KEY_ID = "HINDSIGHT_API_FILE_STORAGE_S3_ACCESS_KEY_ID"
|
||||
ENV_FILE_STORAGE_S3_SECRET_ACCESS_KEY = "HINDSIGHT_API_FILE_STORAGE_S3_SECRET_ACCESS_KEY"
|
||||
ENV_FILE_STORAGE_GCS_BUCKET = "HINDSIGHT_API_FILE_STORAGE_GCS_BUCKET"
|
||||
ENV_FILE_STORAGE_GCS_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_FILE_STORAGE_GCS_SERVICE_ACCOUNT_KEY"
|
||||
ENV_FILE_STORAGE_AZURE_CONTAINER = "HINDSIGHT_API_FILE_STORAGE_AZURE_CONTAINER"
|
||||
ENV_FILE_STORAGE_AZURE_ACCOUNT_NAME = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_NAME"
|
||||
ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_KEY"
|
||||
ENV_FILE_PARSER = "HINDSIGHT_API_FILE_PARSER"
|
||||
ENV_FILE_PARSER_IRIS_TOKEN = "HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN"
|
||||
ENV_FILE_PARSER_IRIS_ORG_ID = "HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID"
|
||||
ENV_FILE_CONVERSION_MAX_BATCH_SIZE_MB = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE_MB"
|
||||
ENV_FILE_CONVERSION_MAX_BATCH_SIZE = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE"
|
||||
ENV_ENABLE_FILE_UPLOAD_API = "HINDSIGHT_API_ENABLE_FILE_UPLOAD_API"
|
||||
ENV_FILE_DELETE_AFTER_RETAIN = "HINDSIGHT_API_FILE_DELETE_AFTER_RETAIN"
|
||||
|
||||
# Observations settings (consolidated knowledge from facts)
|
||||
ENV_ENABLE_OBSERVATIONS = "HINDSIGHT_API_ENABLE_OBSERVATIONS"
|
||||
@@ -183,6 +342,7 @@ DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = None # Optional, uses ADC if not set
|
||||
DEFAULT_EMBEDDINGS_PROVIDER = "local"
|
||||
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
|
||||
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS)
|
||||
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = False # Security: disabled by default, required for some models
|
||||
DEFAULT_EMBEDDINGS_OPENAI_MODEL = "text-embedding-3-small"
|
||||
DEFAULT_EMBEDDING_DIMENSION = 384
|
||||
|
||||
@@ -190,6 +350,9 @@ DEFAULT_RERANKER_PROVIDER = "local"
|
||||
DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
||||
DEFAULT_RERANKER_LOCAL_FORCE_CPU = False # Force CPU mode for local reranker (avoids MPS/XPC issues on macOS)
|
||||
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4 # Limit concurrent CPU-bound reranking to prevent thrashing
|
||||
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE = (
|
||||
False # Security: disabled by default, required for some models like jina-reranker-v2
|
||||
)
|
||||
DEFAULT_RERANKER_TEI_BATCH_SIZE = 128
|
||||
DEFAULT_RERANKER_TEI_MAX_CONCURRENT = 8
|
||||
DEFAULT_RERANKER_MAX_CANDIDATES = 300
|
||||
@@ -199,17 +362,29 @@ DEFAULT_RERANKER_FLASHRANK_CACHE_DIR = None # Use default cache directory
|
||||
DEFAULT_EMBEDDINGS_COHERE_MODEL = "embed-english-v3.0"
|
||||
DEFAULT_RERANKER_COHERE_MODEL = "rerank-english-v3.0"
|
||||
|
||||
# Vector extension (pgvector, vchord, or pgvectorscale)
|
||||
DEFAULT_VECTOR_EXTENSION = "pgvector" # Options: "pgvector", "vchord", "pgvectorscale"
|
||||
|
||||
# Text search extension (native PostgreSQL, vchord BM25, or Timescale pg_textsearch)
|
||||
DEFAULT_TEXT_SEARCH_EXTENSION = "native" # Options: "native", "vchord", "pg_textsearch"
|
||||
|
||||
# LiteLLM defaults
|
||||
DEFAULT_LITELLM_API_BASE = "http://localhost:4000"
|
||||
DEFAULT_EMBEDDINGS_LITELLM_MODEL = "text-embedding-3-small"
|
||||
DEFAULT_RERANKER_LITELLM_MODEL = "cohere/rerank-english-v3.0"
|
||||
|
||||
# LiteLLM SDK defaults
|
||||
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL = "cohere/embed-english-v3.0"
|
||||
DEFAULT_RERANKER_LITELLM_SDK_MODEL = "cohere/rerank-english-v3.0"
|
||||
|
||||
DEFAULT_HOST = "0.0.0.0"
|
||||
DEFAULT_PORT = 8888
|
||||
DEFAULT_BASE_PATH = "" # Empty string = root path
|
||||
DEFAULT_LOG_LEVEL = "info"
|
||||
DEFAULT_LOG_FORMAT = "text" # Options: "text", "json"
|
||||
DEFAULT_WORKERS = 1
|
||||
DEFAULT_MCP_ENABLED = True
|
||||
DEFAULT_ENABLE_BANK_CONFIG_API = False # Disabled by default for security
|
||||
DEFAULT_GRAPH_RETRIEVER = "link_expansion" # Options: "link_expansion", "mpfp", "bfs"
|
||||
DEFAULT_MPFP_TOP_K_NEIGHBORS = 20 # Fan-out limit per node in MPFP graph traversal
|
||||
DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker
|
||||
@@ -224,6 +399,17 @@ DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS = True # Extract causal links between facts
|
||||
DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise", "verbose", or "custom"
|
||||
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom") # Allowed extraction modes
|
||||
DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS = None # Custom extraction guidelines (only used when mode="custom")
|
||||
DEFAULT_RETAIN_BATCH_TOKENS = 10_000 # ~40KB of text # Max chars per sub-batch for async retain auto-splitting
|
||||
DEFAULT_RETAIN_BATCH_ENABLED = False # Use LLM Batch API for fact extraction (only when async=True)
|
||||
DEFAULT_RETAIN_BATCH_POLL_INTERVAL_SECONDS = 60 # Batch API polling interval in seconds
|
||||
|
||||
# File storage defaults
|
||||
DEFAULT_FILE_STORAGE_TYPE = "native" # PostgreSQL BYTEA storage
|
||||
DEFAULT_FILE_PARSER = "markitdown" # File parser to use (markitdown is the only supported parser)
|
||||
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB = 100 # Max total batch size in MB (all files combined)
|
||||
DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE = 10 # Max files per batch upload
|
||||
DEFAULT_ENABLE_FILE_UPLOAD_API = True # Enable file upload endpoint
|
||||
DEFAULT_FILE_DELETE_AFTER_RETAIN = True # Delete file bytes after retain (saves storage)
|
||||
|
||||
# Observations defaults (consolidated knowledge from facts)
|
||||
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
|
||||
@@ -251,6 +437,11 @@ DEFAULT_WORKER_CONSOLIDATION_MAX_SLOTS = 2 # Max concurrent consolidation tasks
|
||||
# Reflect agent settings
|
||||
DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing response
|
||||
|
||||
# OpenTelemetry tracing configuration
|
||||
DEFAULT_OTEL_TRACES_ENABLED = False # Disabled by default for backward compatibility
|
||||
DEFAULT_OTEL_SERVICE_NAME = "hindsight-api"
|
||||
DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT = "development"
|
||||
|
||||
# Default MCP tool descriptions (can be customized via env vars)
|
||||
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
|
||||
|
||||
@@ -329,6 +520,8 @@ class HindsightConfig:
|
||||
# Database
|
||||
database_url: str
|
||||
database_schema: str
|
||||
vector_extension: str # "pgvector" or "vchord"
|
||||
text_search_extension: str # "native" or "vchord"
|
||||
|
||||
# LLM (default, used as fallback for per-operation config)
|
||||
llm_provider: str
|
||||
@@ -340,6 +533,8 @@ class HindsightConfig:
|
||||
llm_initial_backoff: float
|
||||
llm_max_backoff: float
|
||||
llm_timeout: float
|
||||
llm_groq_service_tier: str # Groq: "on_demand", "flex", or "auto"
|
||||
llm_openai_service_tier: str | None # OpenAI: None (default) or "flex" (50% cheaper)
|
||||
|
||||
# Vertex AI configuration
|
||||
llm_vertexai_project_id: str | None
|
||||
@@ -381,27 +576,47 @@ class HindsightConfig:
|
||||
embeddings_provider: str
|
||||
embeddings_local_model: str
|
||||
embeddings_local_force_cpu: bool
|
||||
embeddings_local_trust_remote_code: bool
|
||||
embeddings_tei_url: str | None
|
||||
embeddings_openai_base_url: str | None
|
||||
embeddings_cohere_api_key: str | None
|
||||
embeddings_cohere_model: str
|
||||
embeddings_cohere_base_url: str | None
|
||||
embeddings_litellm_api_base: str
|
||||
embeddings_litellm_api_key: str | None
|
||||
embeddings_litellm_model: str
|
||||
embeddings_litellm_sdk_api_key: str | None
|
||||
embeddings_litellm_sdk_model: str
|
||||
embeddings_litellm_sdk_api_base: str | None
|
||||
|
||||
# Reranker
|
||||
reranker_provider: str
|
||||
reranker_local_model: str
|
||||
reranker_local_force_cpu: bool
|
||||
reranker_local_max_concurrent: int
|
||||
reranker_local_trust_remote_code: bool
|
||||
reranker_tei_url: str | None
|
||||
reranker_tei_batch_size: int
|
||||
reranker_tei_max_concurrent: int
|
||||
reranker_max_candidates: int
|
||||
reranker_cohere_api_key: str | None
|
||||
reranker_cohere_model: str
|
||||
reranker_cohere_base_url: str | None
|
||||
reranker_litellm_api_base: str
|
||||
reranker_litellm_api_key: str | None
|
||||
reranker_litellm_model: str
|
||||
reranker_litellm_sdk_api_key: str | None
|
||||
reranker_litellm_sdk_model: str
|
||||
reranker_litellm_sdk_api_base: str | None
|
||||
|
||||
# Server
|
||||
host: str
|
||||
port: int
|
||||
base_path: str
|
||||
log_level: str
|
||||
log_format: str
|
||||
mcp_enabled: bool
|
||||
enable_bank_config_api: bool
|
||||
|
||||
# Recall
|
||||
graph_retriever: str
|
||||
@@ -416,6 +631,29 @@ class HindsightConfig:
|
||||
retain_extract_causal_links: bool
|
||||
retain_extraction_mode: str
|
||||
retain_custom_instructions: str | None
|
||||
retain_batch_tokens: int
|
||||
retain_batch_enabled: bool
|
||||
retain_batch_poll_interval_seconds: int
|
||||
|
||||
# File storage (static - server-level only)
|
||||
file_storage_type: str # "native" (PostgreSQL) or "s3" (S3-compatible)
|
||||
file_storage_s3_bucket: str | None # S3 bucket name (required for s3 storage)
|
||||
file_storage_s3_region: str | None # S3 region (optional, uses SDK default)
|
||||
file_storage_s3_endpoint: str | None # S3 endpoint URL (for MinIO, R2, etc.)
|
||||
file_storage_s3_access_key_id: str | None # S3 access key (optional, uses env/IAM)
|
||||
file_storage_s3_secret_access_key: str | None # S3 secret key (optional, uses env/IAM)
|
||||
file_storage_gcs_bucket: str | None # GCS bucket name (required for gcs storage)
|
||||
file_storage_gcs_service_account_key: str | None # GCS service account key JSON (optional, uses ADC)
|
||||
file_storage_azure_container: str | None # Azure container name (required for azure storage)
|
||||
file_storage_azure_account_name: str | None # Azure storage account name
|
||||
file_storage_azure_account_key: str | None # Azure storage account key
|
||||
file_parser: str # File parser to use (e.g., "markitdown", "iris")
|
||||
file_parser_iris_token: str | None # Vectorize API token for iris parser (VECTORIZE_TOKEN)
|
||||
file_parser_iris_org_id: str | None # Vectorize org ID for iris parser (VECTORIZE_ORG_ID)
|
||||
file_conversion_max_batch_size_mb: int # Max total batch size in MB (all files combined)
|
||||
file_conversion_max_batch_size: int # Max files per request
|
||||
enable_file_upload_api: bool
|
||||
file_delete_after_retain: bool
|
||||
|
||||
# Observations settings (consolidated knowledge from facts)
|
||||
enable_observations: bool
|
||||
@@ -447,6 +685,141 @@ class HindsightConfig:
|
||||
# Reflect agent settings
|
||||
reflect_max_iterations: int
|
||||
|
||||
# OpenTelemetry tracing configuration
|
||||
otel_traces_enabled: bool
|
||||
otel_exporter_otlp_endpoint: str | None
|
||||
otel_exporter_otlp_headers: str | None
|
||||
otel_service_name: str
|
||||
otel_deployment_environment: str
|
||||
|
||||
# Class-level sets for configuration categorization
|
||||
|
||||
# CREDENTIAL_FIELDS: Never exposed via API, never configurable per-tenant/bank
|
||||
_CREDENTIAL_FIELDS = {
|
||||
# API Keys
|
||||
"llm_api_key",
|
||||
"retain_llm_api_key",
|
||||
"reflect_llm_api_key",
|
||||
"consolidation_llm_api_key",
|
||||
# Base URLs (could expose infrastructure)
|
||||
"llm_base_url",
|
||||
"retain_llm_base_url",
|
||||
"reflect_llm_base_url",
|
||||
"consolidation_llm_base_url",
|
||||
"embeddings_tei_base_url",
|
||||
"reranker_tei_base_url",
|
||||
"reranker_cohere_base_url",
|
||||
# Service Account Keys
|
||||
"llm_vertexai_service_account_key",
|
||||
# File storage credentials
|
||||
"file_storage_s3_access_key_id",
|
||||
"file_storage_s3_secret_access_key",
|
||||
"file_storage_gcs_service_account_key",
|
||||
"file_storage_azure_account_key",
|
||||
# File parser credentials
|
||||
"file_parser_iris_token",
|
||||
}
|
||||
|
||||
# CONFIGURABLE_FIELDS: Safe behavioral settings that can be customized per-tenant/bank
|
||||
# These fields are manually tagged as safe to expose and modify.
|
||||
# Excludes credentials, infrastructure config, provider/model selection, and performance tuning.
|
||||
_CONFIGURABLE_FIELDS = {
|
||||
# Retention settings (behavioral)
|
||||
"retain_chunk_size",
|
||||
"retain_extraction_mode",
|
||||
"retain_custom_instructions",
|
||||
# Consolidation settings
|
||||
"enable_observations",
|
||||
}
|
||||
|
||||
@property
|
||||
def file_conversion_max_batch_size_bytes(self) -> int:
|
||||
"""Get maximum total batch size in bytes."""
|
||||
return self.file_conversion_max_batch_size_mb * 1024 * 1024
|
||||
|
||||
@classmethod
|
||||
def get_configurable_fields(cls) -> set[str]:
|
||||
"""
|
||||
Get set of field names that are configurable per-tenant/bank via API.
|
||||
|
||||
Configurable fields are manually tagged behavioral settings that are safe
|
||||
to expose and modify (e.g., retain_chunk_size, custom_instructions).
|
||||
Excludes credentials, infrastructure config, and provider/model selection.
|
||||
|
||||
Returns:
|
||||
Set of configurable field names
|
||||
"""
|
||||
return cls._CONFIGURABLE_FIELDS.copy()
|
||||
|
||||
@classmethod
|
||||
def get_credential_fields(cls) -> set[str]:
|
||||
"""
|
||||
Get set of field names that are credentials (NEVER exposed via API).
|
||||
|
||||
Credential fields include API keys, base URLs, and service account keys.
|
||||
These must never be returned in API responses or accepted in updates.
|
||||
|
||||
Returns:
|
||||
Set of credential field names
|
||||
"""
|
||||
return cls._CREDENTIAL_FIELDS.copy()
|
||||
|
||||
@classmethod
|
||||
def get_hierarchical_fields(cls) -> set[str]:
|
||||
"""
|
||||
DEPRECATED: Use get_configurable_fields() instead.
|
||||
|
||||
Kept for backward compatibility during migration.
|
||||
"""
|
||||
return cls.get_configurable_fields()
|
||||
|
||||
@classmethod
|
||||
def get_static_fields(cls) -> set[str]:
|
||||
"""
|
||||
Get set of field names that are static (server-level only).
|
||||
|
||||
Static fields are infrastructure-level settings that cannot vary
|
||||
per tenant or bank. These include database config, API port, worker settings, etc.
|
||||
Also includes credential fields which are never configurable.
|
||||
|
||||
Returns:
|
||||
Set of static field names
|
||||
"""
|
||||
# Get all field names from dataclass
|
||||
all_fields = {f.name for f in fields(cls)}
|
||||
# Static fields = all fields - configurable fields
|
||||
return all_fields - cls._CONFIGURABLE_FIELDS
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate configuration values and raise errors for invalid combinations."""
|
||||
# Validate vector_extension
|
||||
valid_extensions = ("pgvector", "vchord", "pgvectorscale")
|
||||
if self.vector_extension not in valid_extensions:
|
||||
raise ValueError(
|
||||
f"Invalid vector_extension: {self.vector_extension}. Must be one of: {', '.join(valid_extensions)}"
|
||||
)
|
||||
|
||||
# Validate text_search_extension
|
||||
valid_text_search = ("native", "vchord", "pg_textsearch")
|
||||
if self.text_search_extension not in valid_text_search:
|
||||
raise ValueError(
|
||||
f"Invalid text_search_extension: {self.text_search_extension}. Must be one of: {', '.join(valid_text_search)}"
|
||||
)
|
||||
|
||||
# RETAIN_MAX_COMPLETION_TOKENS must be greater than RETAIN_CHUNK_SIZE
|
||||
# to ensure the LLM has enough output capacity to extract facts from chunks
|
||||
if self.retain_max_completion_tokens <= self.retain_chunk_size:
|
||||
raise ValueError(
|
||||
f"Invalid configuration: HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS "
|
||||
f"({self.retain_max_completion_tokens}) must be greater than "
|
||||
f"HINDSIGHT_API_RETAIN_CHUNK_SIZE ({self.retain_chunk_size}). "
|
||||
f"\n\nYou have two options to fix this:"
|
||||
f"\n 1. Increase HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS to a value > {self.retain_chunk_size}"
|
||||
f"\n 2. Use a model that supports at least {self.retain_max_completion_tokens} output tokens"
|
||||
f"\n (current model: {self.retain_llm_model or self.llm_model}, "
|
||||
f"provider: {self.retain_llm_provider or self.llm_provider})"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "HindsightConfig":
|
||||
"""Create configuration from environment variables."""
|
||||
@@ -454,10 +827,12 @@ class HindsightConfig:
|
||||
llm_provider = os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER)
|
||||
llm_model = os.getenv(ENV_LLM_MODEL) or _get_default_model_for_provider(llm_provider)
|
||||
|
||||
return cls(
|
||||
config = cls(
|
||||
# Database
|
||||
database_url=os.getenv(ENV_DATABASE_URL, DEFAULT_DATABASE_URL),
|
||||
database_schema=os.getenv(ENV_DATABASE_SCHEMA, DEFAULT_DATABASE_SCHEMA),
|
||||
vector_extension=os.getenv(ENV_VECTOR_EXTENSION, DEFAULT_VECTOR_EXTENSION).lower(),
|
||||
text_search_extension=os.getenv(ENV_TEXT_SEARCH_EXTENSION, DEFAULT_TEXT_SEARCH_EXTENSION).lower(),
|
||||
# LLM
|
||||
llm_provider=llm_provider,
|
||||
llm_api_key=os.getenv(ENV_LLM_API_KEY),
|
||||
@@ -468,6 +843,8 @@ class HindsightConfig:
|
||||
llm_initial_backoff=float(os.getenv(ENV_LLM_INITIAL_BACKOFF, str(DEFAULT_LLM_INITIAL_BACKOFF))),
|
||||
llm_max_backoff=float(os.getenv(ENV_LLM_MAX_BACKOFF, str(DEFAULT_LLM_MAX_BACKOFF))),
|
||||
llm_timeout=float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT))),
|
||||
llm_groq_service_tier=os.getenv(ENV_LLM_GROQ_SERVICE_TIER, DEFAULT_LLM_GROQ_SERVICE_TIER),
|
||||
llm_openai_service_tier=os.getenv(ENV_LLM_OPENAI_SERVICE_TIER, DEFAULT_LLM_OPENAI_SERVICE_TIER),
|
||||
# Vertex AI
|
||||
llm_vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or DEFAULT_LLM_VERTEXAI_PROJECT_ID,
|
||||
llm_vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION, DEFAULT_LLM_VERTEXAI_REGION),
|
||||
@@ -551,9 +928,27 @@ class HindsightConfig:
|
||||
ENV_EMBEDDINGS_LOCAL_FORCE_CPU, str(DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU)
|
||||
).lower()
|
||||
in ("true", "1"),
|
||||
embeddings_local_trust_remote_code=os.getenv(
|
||||
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE, str(DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE)
|
||||
).lower()
|
||||
in ("true", "1"),
|
||||
embeddings_tei_url=os.getenv(ENV_EMBEDDINGS_TEI_URL),
|
||||
embeddings_openai_base_url=os.getenv(ENV_EMBEDDINGS_OPENAI_BASE_URL) or None,
|
||||
# Cohere embeddings (with backward-compatible fallback to shared API key)
|
||||
embeddings_cohere_api_key=os.getenv(ENV_EMBEDDINGS_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY),
|
||||
embeddings_cohere_model=os.getenv(ENV_EMBEDDINGS_COHERE_MODEL, DEFAULT_EMBEDDINGS_COHERE_MODEL),
|
||||
embeddings_cohere_base_url=os.getenv(ENV_EMBEDDINGS_COHERE_BASE_URL) or None,
|
||||
# LiteLLM embeddings (with backward-compatible fallback to shared config)
|
||||
embeddings_litellm_api_base=os.getenv(ENV_EMBEDDINGS_LITELLM_API_BASE)
|
||||
or os.getenv(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE),
|
||||
embeddings_litellm_api_key=os.getenv(ENV_EMBEDDINGS_LITELLM_API_KEY) or os.getenv(ENV_LITELLM_API_KEY),
|
||||
embeddings_litellm_model=os.getenv(ENV_EMBEDDINGS_LITELLM_MODEL, DEFAULT_EMBEDDINGS_LITELLM_MODEL),
|
||||
# LiteLLM SDK embeddings (direct API access)
|
||||
embeddings_litellm_sdk_api_key=os.getenv(ENV_EMBEDDINGS_LITELLM_SDK_API_KEY),
|
||||
embeddings_litellm_sdk_model=os.getenv(
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_MODEL, DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL
|
||||
),
|
||||
embeddings_litellm_sdk_api_base=os.getenv(ENV_EMBEDDINGS_LITELLM_SDK_API_BASE) or None,
|
||||
# Reranker
|
||||
reranker_provider=os.getenv(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER),
|
||||
reranker_local_model=os.getenv(ENV_RERANKER_LOCAL_MODEL, DEFAULT_RERANKER_LOCAL_MODEL),
|
||||
@@ -564,19 +959,38 @@ class HindsightConfig:
|
||||
reranker_local_max_concurrent=int(
|
||||
os.getenv(ENV_RERANKER_LOCAL_MAX_CONCURRENT, str(DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT))
|
||||
),
|
||||
reranker_local_trust_remote_code=os.getenv(
|
||||
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE, str(DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE)
|
||||
).lower()
|
||||
in ("true", "1"),
|
||||
reranker_tei_url=os.getenv(ENV_RERANKER_TEI_URL),
|
||||
reranker_tei_batch_size=int(os.getenv(ENV_RERANKER_TEI_BATCH_SIZE, str(DEFAULT_RERANKER_TEI_BATCH_SIZE))),
|
||||
reranker_tei_max_concurrent=int(
|
||||
os.getenv(ENV_RERANKER_TEI_MAX_CONCURRENT, str(DEFAULT_RERANKER_TEI_MAX_CONCURRENT))
|
||||
),
|
||||
reranker_max_candidates=int(os.getenv(ENV_RERANKER_MAX_CANDIDATES, str(DEFAULT_RERANKER_MAX_CANDIDATES))),
|
||||
# Cohere reranker (with backward-compatible fallback to shared API key)
|
||||
reranker_cohere_api_key=os.getenv(ENV_RERANKER_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY),
|
||||
reranker_cohere_model=os.getenv(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL),
|
||||
reranker_cohere_base_url=os.getenv(ENV_RERANKER_COHERE_BASE_URL) or None,
|
||||
# LiteLLM reranker (with backward-compatible fallback to shared config)
|
||||
reranker_litellm_api_base=os.getenv(ENV_RERANKER_LITELLM_API_BASE)
|
||||
or os.getenv(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE),
|
||||
reranker_litellm_api_key=os.getenv(ENV_RERANKER_LITELLM_API_KEY) or os.getenv(ENV_LITELLM_API_KEY),
|
||||
reranker_litellm_model=os.getenv(ENV_RERANKER_LITELLM_MODEL, DEFAULT_RERANKER_LITELLM_MODEL),
|
||||
# LiteLLM SDK reranker (direct API access)
|
||||
reranker_litellm_sdk_api_key=os.getenv(ENV_RERANKER_LITELLM_SDK_API_KEY),
|
||||
reranker_litellm_sdk_model=os.getenv(ENV_RERANKER_LITELLM_SDK_MODEL, DEFAULT_RERANKER_LITELLM_SDK_MODEL),
|
||||
reranker_litellm_sdk_api_base=os.getenv(ENV_RERANKER_LITELLM_SDK_API_BASE) or None,
|
||||
# Server
|
||||
host=os.getenv(ENV_HOST, DEFAULT_HOST),
|
||||
port=int(os.getenv(ENV_PORT, DEFAULT_PORT)),
|
||||
base_path=os.getenv(ENV_BASE_PATH, DEFAULT_BASE_PATH),
|
||||
log_level=os.getenv(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL),
|
||||
log_format=os.getenv(ENV_LOG_FORMAT, DEFAULT_LOG_FORMAT).lower(),
|
||||
mcp_enabled=os.getenv(ENV_MCP_ENABLED, str(DEFAULT_MCP_ENABLED)).lower() == "true",
|
||||
enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower()
|
||||
== "true",
|
||||
# Recall
|
||||
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
|
||||
mpfp_top_k_neighbors=int(os.getenv(ENV_MPFP_TOP_K_NEIGHBORS, str(DEFAULT_MPFP_TOP_K_NEIGHBORS))),
|
||||
@@ -603,6 +1017,39 @@ class HindsightConfig:
|
||||
os.getenv(ENV_RETAIN_EXTRACTION_MODE, DEFAULT_RETAIN_EXTRACTION_MODE)
|
||||
),
|
||||
retain_custom_instructions=os.getenv(ENV_RETAIN_CUSTOM_INSTRUCTIONS) or DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS,
|
||||
retain_batch_tokens=int(os.getenv(ENV_RETAIN_BATCH_TOKENS, str(DEFAULT_RETAIN_BATCH_TOKENS))),
|
||||
retain_batch_enabled=os.getenv(ENV_RETAIN_BATCH_ENABLED, str(DEFAULT_RETAIN_BATCH_ENABLED)).lower()
|
||||
== "true",
|
||||
retain_batch_poll_interval_seconds=int(
|
||||
os.getenv(ENV_RETAIN_BATCH_POLL_INTERVAL_SECONDS, str(DEFAULT_RETAIN_BATCH_POLL_INTERVAL_SECONDS))
|
||||
),
|
||||
# File storage
|
||||
file_storage_type=os.getenv(ENV_FILE_STORAGE_TYPE, DEFAULT_FILE_STORAGE_TYPE),
|
||||
file_storage_s3_bucket=os.getenv(ENV_FILE_STORAGE_S3_BUCKET) or None,
|
||||
file_storage_s3_region=os.getenv(ENV_FILE_STORAGE_S3_REGION) or None,
|
||||
file_storage_s3_endpoint=os.getenv(ENV_FILE_STORAGE_S3_ENDPOINT) or None,
|
||||
file_storage_s3_access_key_id=os.getenv(ENV_FILE_STORAGE_S3_ACCESS_KEY_ID) or None,
|
||||
file_storage_s3_secret_access_key=os.getenv(ENV_FILE_STORAGE_S3_SECRET_ACCESS_KEY) or None,
|
||||
file_storage_gcs_bucket=os.getenv(ENV_FILE_STORAGE_GCS_BUCKET) or None,
|
||||
file_storage_gcs_service_account_key=os.getenv(ENV_FILE_STORAGE_GCS_SERVICE_ACCOUNT_KEY) or None,
|
||||
file_storage_azure_container=os.getenv(ENV_FILE_STORAGE_AZURE_CONTAINER) or None,
|
||||
file_storage_azure_account_name=os.getenv(ENV_FILE_STORAGE_AZURE_ACCOUNT_NAME) or None,
|
||||
file_storage_azure_account_key=os.getenv(ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY) or None,
|
||||
file_parser=os.getenv(ENV_FILE_PARSER, DEFAULT_FILE_PARSER),
|
||||
file_parser_iris_token=os.getenv(ENV_FILE_PARSER_IRIS_TOKEN) or None,
|
||||
file_parser_iris_org_id=os.getenv(ENV_FILE_PARSER_IRIS_ORG_ID) or None,
|
||||
file_conversion_max_batch_size_mb=int(
|
||||
os.getenv(ENV_FILE_CONVERSION_MAX_BATCH_SIZE_MB, str(DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB))
|
||||
),
|
||||
file_conversion_max_batch_size=int(
|
||||
os.getenv(ENV_FILE_CONVERSION_MAX_BATCH_SIZE, str(DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE))
|
||||
),
|
||||
enable_file_upload_api=os.getenv(ENV_ENABLE_FILE_UPLOAD_API, str(DEFAULT_ENABLE_FILE_UPLOAD_API)).lower()
|
||||
== "true",
|
||||
file_delete_after_retain=os.getenv(
|
||||
ENV_FILE_DELETE_AFTER_RETAIN, str(DEFAULT_FILE_DELETE_AFTER_RETAIN)
|
||||
).lower()
|
||||
== "true",
|
||||
# Observations settings (consolidated knowledge from facts)
|
||||
enable_observations=os.getenv(ENV_ENABLE_OBSERVATIONS, str(DEFAULT_ENABLE_OBSERVATIONS)).lower() == "true",
|
||||
consolidation_batch_size=int(
|
||||
@@ -630,7 +1077,16 @@ class HindsightConfig:
|
||||
),
|
||||
# Reflect agent settings
|
||||
reflect_max_iterations=int(os.getenv(ENV_REFLECT_MAX_ITERATIONS, str(DEFAULT_REFLECT_MAX_ITERATIONS))),
|
||||
# OpenTelemetry tracing configuration
|
||||
otel_traces_enabled=os.getenv(ENV_OTEL_TRACES_ENABLED, str(DEFAULT_OTEL_TRACES_ENABLED)).lower()
|
||||
in ("true", "1", "yes"),
|
||||
otel_exporter_otlp_endpoint=os.getenv(ENV_OTEL_EXPORTER_OTLP_ENDPOINT) or None,
|
||||
otel_exporter_otlp_headers=os.getenv(ENV_OTEL_EXPORTER_OTLP_HEADERS) or None,
|
||||
otel_service_name=os.getenv(ENV_OTEL_SERVICE_NAME, DEFAULT_OTEL_SERVICE_NAME),
|
||||
otel_deployment_environment=os.getenv(ENV_OTEL_DEPLOYMENT_ENVIRONMENT, DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT),
|
||||
)
|
||||
config.validate()
|
||||
return config
|
||||
|
||||
def get_llm_base_url(self) -> str:
|
||||
"""Get the LLM base URL, with provider-specific defaults."""
|
||||
@@ -708,8 +1164,35 @@ class HindsightConfig:
|
||||
_config_cache: HindsightConfig | None = None
|
||||
|
||||
|
||||
def get_config() -> HindsightConfig:
|
||||
"""Get the cached configuration, loading from environment on first call."""
|
||||
def get_config() -> StaticConfigProxy:
|
||||
"""
|
||||
Get global configuration with ONLY static (non-configurable) fields accessible.
|
||||
|
||||
This returns a proxy that prevents access to bank-configurable fields
|
||||
(like enable_observations, retain_chunk_size, etc.).
|
||||
|
||||
For bank-specific configuration, use:
|
||||
config_resolver.resolve_full_config(bank_id, context)
|
||||
|
||||
This design prevents accidentally using global defaults when bank-specific
|
||||
overrides exist.
|
||||
|
||||
Returns:
|
||||
StaticConfigProxy that only exposes static infrastructure fields
|
||||
|
||||
Raises:
|
||||
ConfigFieldAccessError: If you try to access a bank-configurable field
|
||||
"""
|
||||
return StaticConfigProxy(_get_raw_config())
|
||||
|
||||
|
||||
def _get_raw_config() -> HindsightConfig:
|
||||
"""
|
||||
Get raw config (internal use only).
|
||||
|
||||
INTERNAL USE ONLY. Do not use this directly in application code.
|
||||
Use get_config() for static fields or ConfigResolver.resolve_full_config() for bank-specific config.
|
||||
"""
|
||||
global _config_cache
|
||||
if _config_cache is None:
|
||||
_config_cache = HindsightConfig.from_env()
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
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.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(
|
||||
"""
|
||||
SELECT config FROM 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(
|
||||
"""
|
||||
UPDATE 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(
|
||||
"""
|
||||
UPDATE banks
|
||||
SET config = '{}'::jsonb,
|
||||
updated_at = now()
|
||||
WHERE bank_id = $1
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
logger.info(f"Reset bank config for {bank_id} to defaults")
|
||||
@@ -1,11 +1,10 @@
|
||||
"""
|
||||
Daemon mode support for Hindsight API.
|
||||
|
||||
Provides idle timeout and lockfile management for running as a background daemon.
|
||||
Provides idle timeout for running as a background daemon.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
@@ -17,8 +16,9 @@ logger = logging.getLogger(__name__)
|
||||
# Default daemon configuration
|
||||
DEFAULT_DAEMON_PORT = 8888
|
||||
DEFAULT_IDLE_TIMEOUT = 0 # 0 = no auto-exit (hindsight-embed passes its own timeout)
|
||||
LOCKFILE_PATH = Path.home() / ".hindsight" / "daemon.lock"
|
||||
DAEMON_LOG_PATH = Path.home() / ".hindsight" / "daemon.log"
|
||||
|
||||
# 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")))
|
||||
|
||||
|
||||
class IdleTimeoutMiddleware:
|
||||
@@ -58,97 +58,27 @@ class IdleTimeoutMiddleware:
|
||||
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():
|
||||
"""
|
||||
Fork the current process into a background daemon.
|
||||
|
||||
Uses double-fork technique to properly detach from terminal.
|
||||
"""
|
||||
# First fork
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
# Parent exits
|
||||
sys.exit(0)
|
||||
# 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)
|
||||
|
||||
# Create new session
|
||||
# Decouple from parent environment
|
||||
os.chdir("/")
|
||||
os.setsid()
|
||||
os.umask(0)
|
||||
|
||||
# Second fork to prevent zombie processes
|
||||
# Second fork - prevent zombie
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
sys.exit(0)
|
||||
@@ -181,27 +111,3 @@ 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
|
||||
|
||||
@@ -18,6 +18,7 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ...config import get_config
|
||||
from ..memory_engine import fq_table
|
||||
from ..retain import embedding_utils
|
||||
from .prompts import (
|
||||
@@ -82,9 +83,8 @@ async def run_consolidation_job(
|
||||
Returns:
|
||||
Dict with consolidation results
|
||||
"""
|
||||
from ...config import get_config
|
||||
|
||||
config = get_config()
|
||||
# Resolve bank-specific config with hierarchical overrides
|
||||
config = await memory_engine._config_resolver.resolve_full_config(bank_id, request_context)
|
||||
perf = ConsolidationPerfLog(bank_id)
|
||||
max_memories_per_batch = config.consolidation_batch_size
|
||||
|
||||
@@ -143,6 +143,9 @@ async def run_consolidation_job(
|
||||
"skipped": 0,
|
||||
}
|
||||
|
||||
# Track all unique tags from consolidated memories for mental model refresh filtering
|
||||
consolidated_tags: set[str] = set()
|
||||
|
||||
batch_num = 0
|
||||
last_progress_timings = {} # Track timings at last progress log
|
||||
while True:
|
||||
@@ -176,6 +179,11 @@ async def run_consolidation_job(
|
||||
for memory in memories:
|
||||
mem_start = time.time()
|
||||
|
||||
# Track tags from this memory for mental model refresh filtering
|
||||
memory_tags = memory.get("tags") or []
|
||||
if memory_tags:
|
||||
consolidated_tags.update(memory_tags)
|
||||
|
||||
# Process the memory (uses its own connection internally)
|
||||
async with pool.acquire() as conn:
|
||||
result = await _process_memory(
|
||||
@@ -284,10 +292,12 @@ async def run_consolidation_job(
|
||||
perf.log(f"[4] Timing breakdown: {', '.join(timing_parts)}")
|
||||
|
||||
# Trigger mental model refreshes for models with refresh_after_consolidation=true
|
||||
# SECURITY: Only refresh mental models with matching tags (or all if no tags were consolidated)
|
||||
mental_models_refreshed = await _trigger_mental_model_refreshes(
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
consolidated_tags=list(consolidated_tags) if consolidated_tags else None,
|
||||
perf=perf,
|
||||
)
|
||||
stats["mental_models_refreshed"] = mental_models_refreshed
|
||||
@@ -301,15 +311,20 @@ async def _trigger_mental_model_refreshes(
|
||||
memory_engine: "MemoryEngine",
|
||||
bank_id: str,
|
||||
request_context: "RequestContext",
|
||||
consolidated_tags: list[str] | None = None,
|
||||
perf: ConsolidationPerfLog | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Trigger refreshes for mental models with refresh_after_consolidation=true.
|
||||
|
||||
SECURITY: Only triggers refresh for mental models whose tags overlap with the
|
||||
consolidated memory tags, preventing unnecessary refreshes across security boundaries.
|
||||
|
||||
Args:
|
||||
memory_engine: MemoryEngine instance
|
||||
bank_id: Bank identifier
|
||||
request_context: Request context for authentication
|
||||
consolidated_tags: Tags from memories that were consolidated (None = refresh all)
|
||||
perf: Performance logging
|
||||
|
||||
Returns:
|
||||
@@ -318,22 +333,52 @@ async def _trigger_mental_model_refreshes(
|
||||
pool = memory_engine._pool
|
||||
|
||||
# Find mental models with refresh_after_consolidation=true
|
||||
# SECURITY: Control which mental models get refreshed based on tags
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, name
|
||||
FROM {fq_table("mental_models")}
|
||||
WHERE bank_id = $1
|
||||
AND (trigger->>'refresh_after_consolidation')::boolean = true
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
if consolidated_tags:
|
||||
# Tagged memories were consolidated - refresh:
|
||||
# 1. Mental models with overlapping tags (security boundary)
|
||||
# 2. Untagged mental models (they're "global" and available to all contexts)
|
||||
# DO NOT refresh mental models with different tags
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, name, tags
|
||||
FROM {fq_table("mental_models")}
|
||||
WHERE bank_id = $1
|
||||
AND (trigger->>'refresh_after_consolidation')::boolean = true
|
||||
AND (
|
||||
(tags IS NOT NULL AND tags != '{{}}' AND tags && $2::varchar[])
|
||||
OR (tags IS NULL OR tags = '{{}}')
|
||||
)
|
||||
""",
|
||||
bank_id,
|
||||
consolidated_tags,
|
||||
)
|
||||
else:
|
||||
# Untagged memories were consolidated - only refresh untagged mental models
|
||||
# SECURITY: Tagged mental models are NOT refreshed when untagged memories are consolidated
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, name, tags
|
||||
FROM {fq_table("mental_models")}
|
||||
WHERE bank_id = $1
|
||||
AND (trigger->>'refresh_after_consolidation')::boolean = true
|
||||
AND (tags IS NULL OR tags = '{{}}')
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
if perf:
|
||||
perf.log(f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true")
|
||||
if consolidated_tags:
|
||||
perf.log(
|
||||
f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true "
|
||||
f"(filtered by tags: {consolidated_tags})"
|
||||
)
|
||||
else:
|
||||
perf.log(f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true")
|
||||
|
||||
# Submit refresh tasks for each mental model
|
||||
refreshed_count = 0
|
||||
@@ -381,92 +426,109 @@ async def _process_memory(
|
||||
Returns:
|
||||
Dict with action summary: created/updated/merged counts
|
||||
"""
|
||||
from ...tracing import get_tracer, is_tracing_enabled
|
||||
|
||||
fact_text = memory["text"]
|
||||
memory_id = memory["id"]
|
||||
fact_tags = memory.get("tags") or []
|
||||
|
||||
# Find related observations using the full recall system (NO tag filtering)
|
||||
t0 = time.time()
|
||||
related_observations = await _find_related_observations(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
query=fact_text,
|
||||
request_context=request_context,
|
||||
)
|
||||
if perf:
|
||||
perf.record_timing("recall", time.time() - t0)
|
||||
# Create parent span for this memory's consolidation
|
||||
tracer = get_tracer()
|
||||
if is_tracing_enabled():
|
||||
consolidation_span = tracer.start_span("hindsight.consolidation")
|
||||
consolidation_span.set_attribute("hindsight.memory_id", str(memory_id))
|
||||
consolidation_span.set_attribute("hindsight.bank_id", bank_id)
|
||||
else:
|
||||
consolidation_span = None
|
||||
|
||||
# Single LLM call handles ALL cases (with or without existing observations)
|
||||
# Note: Tags are NOT passed to LLM - they are handled algorithmically
|
||||
t0 = time.time()
|
||||
actions = await _consolidate_with_llm(
|
||||
memory_engine=memory_engine,
|
||||
fact_text=fact_text,
|
||||
observations=related_observations, # Can be empty list
|
||||
mission=mission,
|
||||
)
|
||||
if perf:
|
||||
perf.record_timing("llm", time.time() - t0)
|
||||
try:
|
||||
# Find related observations using the full recall system
|
||||
# SECURITY: Pass tags to ensure observations don't leak across security boundaries
|
||||
t0 = time.time()
|
||||
related_observations = await _find_related_observations(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
query=fact_text,
|
||||
request_context=request_context,
|
||||
tags=fact_tags, # Pass source memory's tags for security
|
||||
)
|
||||
if perf:
|
||||
perf.record_timing("recall", time.time() - t0)
|
||||
|
||||
if not actions:
|
||||
# LLM returned empty array - fact is purely ephemeral, skip
|
||||
return {"action": "skipped", "reason": "no_durable_knowledge"}
|
||||
# Single LLM call handles ALL cases (with or without existing observations)
|
||||
# Note: Tags are NOT passed to LLM - they are handled algorithmically
|
||||
t0 = time.time()
|
||||
actions = await _consolidate_with_llm(
|
||||
memory_engine=memory_engine,
|
||||
fact_text=fact_text,
|
||||
observations=related_observations, # Can be empty list
|
||||
mission=mission,
|
||||
)
|
||||
if perf:
|
||||
perf.record_timing("llm", time.time() - t0)
|
||||
|
||||
# Execute all actions and collect results
|
||||
results = []
|
||||
for action in actions:
|
||||
action_type = action.get("action")
|
||||
if action_type == "update":
|
||||
result = await _execute_update_action(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
memory_id=memory_id,
|
||||
action=action,
|
||||
observations=related_observations,
|
||||
source_fact_tags=fact_tags, # Pass source fact's tags for security
|
||||
source_occurred_start=memory.get("occurred_start"),
|
||||
source_occurred_end=memory.get("occurred_end"),
|
||||
source_mentioned_at=memory.get("mentioned_at"),
|
||||
perf=perf,
|
||||
)
|
||||
results.append(result)
|
||||
elif action_type == "create":
|
||||
result = await _execute_create_action(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
memory_id=memory_id,
|
||||
action=action,
|
||||
source_fact_tags=fact_tags, # Pass source fact's tags for security
|
||||
event_date=memory.get("event_date"),
|
||||
occurred_start=memory.get("occurred_start"),
|
||||
occurred_end=memory.get("occurred_end"),
|
||||
mentioned_at=memory.get("mentioned_at"),
|
||||
perf=perf,
|
||||
)
|
||||
results.append(result)
|
||||
if not actions:
|
||||
# LLM returned empty array - fact is purely ephemeral, skip
|
||||
return {"action": "skipped", "reason": "no_durable_knowledge"}
|
||||
|
||||
if not results:
|
||||
# No valid actions executed
|
||||
return {"action": "skipped", "reason": "no_valid_actions"}
|
||||
# Execute all actions and collect results
|
||||
results = []
|
||||
for action in actions:
|
||||
action_type = action.get("action")
|
||||
if action_type == "update":
|
||||
result = await _execute_update_action(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
memory_id=memory_id,
|
||||
action=action,
|
||||
observations=related_observations,
|
||||
source_fact_tags=fact_tags, # Pass source fact's tags for security
|
||||
source_occurred_start=memory.get("occurred_start"),
|
||||
source_occurred_end=memory.get("occurred_end"),
|
||||
source_mentioned_at=memory.get("mentioned_at"),
|
||||
perf=perf,
|
||||
)
|
||||
results.append(result)
|
||||
elif action_type == "create":
|
||||
result = await _execute_create_action(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
memory_id=memory_id,
|
||||
action=action,
|
||||
source_fact_tags=fact_tags, # Pass source fact's tags for security
|
||||
event_date=memory.get("event_date"),
|
||||
occurred_start=memory.get("occurred_start"),
|
||||
occurred_end=memory.get("occurred_end"),
|
||||
mentioned_at=memory.get("mentioned_at"),
|
||||
perf=perf,
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
# Summarize results
|
||||
created = sum(1 for r in results if r.get("action") == "created")
|
||||
updated = sum(1 for r in results if r.get("action") == "updated")
|
||||
merged = sum(1 for r in results if r.get("action") == "merged")
|
||||
if not results:
|
||||
# No valid actions executed
|
||||
return {"action": "skipped", "reason": "no_valid_actions"}
|
||||
|
||||
if len(results) == 1:
|
||||
return results[0]
|
||||
# Summarize results
|
||||
created = sum(1 for r in results if r.get("action") == "created")
|
||||
updated = sum(1 for r in results if r.get("action") == "updated")
|
||||
merged = sum(1 for r in results if r.get("action") == "merged")
|
||||
|
||||
return {
|
||||
"action": "multiple",
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"merged": merged,
|
||||
"total_actions": len(results),
|
||||
}
|
||||
if len(results) == 1:
|
||||
return results[0]
|
||||
|
||||
return {
|
||||
"action": "multiple",
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"merged": merged,
|
||||
"total_actions": len(results),
|
||||
}
|
||||
finally:
|
||||
if consolidation_span:
|
||||
consolidation_span.end()
|
||||
|
||||
|
||||
async def _execute_update_action(
|
||||
@@ -666,34 +728,57 @@ async def _find_related_observations(
|
||||
bank_id: str,
|
||||
query: str,
|
||||
request_context: "RequestContext",
|
||||
tags: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Find observations related to the given query using optimized recall.
|
||||
|
||||
IMPORTANT: We do NOT filter by tags here. Consolidation needs to see ALL
|
||||
potentially related observations regardless of scope, so the LLM can
|
||||
decide on tag routing (same scope update vs cross-scope create).
|
||||
SECURITY: Filters by tags using all_strict matching to prevent cross-tenant/cross-user
|
||||
information leakage. Observations are only consolidated within the same tag scope.
|
||||
|
||||
Uses max_tokens to naturally limit observations (no artificial count limit).
|
||||
Includes source memories with dates for LLM context.
|
||||
|
||||
Args:
|
||||
tags: Optional tags to filter observations (uses all_strict matching for security)
|
||||
|
||||
Returns:
|
||||
List of related observations with their tags, source memories, and dates
|
||||
"""
|
||||
# Use recall to find related observations with token budget
|
||||
# max_tokens naturally limits how many observations are returned
|
||||
from ...config import get_config
|
||||
from ...tracing import get_tracer, is_tracing_enabled
|
||||
|
||||
config = get_config()
|
||||
recall_result = await memory_engine.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
max_tokens=config.consolidation_max_tokens, # Token budget for observations (configurable)
|
||||
fact_type=["observation"], # Only retrieve observations
|
||||
request_context=request_context,
|
||||
_quiet=True, # Suppress logging
|
||||
# NO tags parameter - intentionally get ALL observations
|
||||
)
|
||||
|
||||
# SECURITY: Use all_strict matching if tags provided to prevent cross-scope consolidation
|
||||
tags_match = "all_strict" if tags else "any"
|
||||
|
||||
# Create span for recall operation within consolidation
|
||||
tracer = get_tracer()
|
||||
if is_tracing_enabled():
|
||||
recall_span = tracer.start_span("hindsight.consolidation_recall")
|
||||
recall_span.set_attribute("hindsight.bank_id", bank_id)
|
||||
recall_span.set_attribute("hindsight.query", query[:100]) # Truncate for brevity
|
||||
recall_span.set_attribute("hindsight.fact_type", "observation")
|
||||
else:
|
||||
recall_span = None
|
||||
|
||||
try:
|
||||
recall_result = await memory_engine.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
max_tokens=config.consolidation_max_tokens, # Token budget for observations (configurable)
|
||||
fact_type=["observation"], # Only retrieve observations
|
||||
request_context=request_context,
|
||||
tags=tags, # Filter by source memory's tags
|
||||
tags_match=tags_match, # Use strict matching for security
|
||||
_quiet=True, # Suppress logging
|
||||
)
|
||||
finally:
|
||||
if recall_span:
|
||||
recall_span.end()
|
||||
|
||||
# If no observations returned, return empty list
|
||||
if not recall_result.results:
|
||||
@@ -931,15 +1016,34 @@ async def _create_observation_directly(
|
||||
|
||||
t0 = time.time()
|
||||
observation_id = uuid.uuid4()
|
||||
|
||||
# Query varies based on text search backend
|
||||
config = get_config()
|
||||
if config.text_search_extension == "vchord":
|
||||
# VectorChord: manually tokenize and insert search_vector
|
||||
query = f"""
|
||||
INSERT INTO {fq_table("memory_units")} (
|
||||
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history,
|
||||
tags, event_date, occurred_start, occurred_end, mentioned_at, search_vector
|
||||
)
|
||||
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8, $9, $10,
|
||||
tokenize($3, 'llmlingua2')::bm25_catalog.bm25vector)
|
||||
RETURNING id
|
||||
"""
|
||||
else: # native or pg_textsearch
|
||||
# Native PostgreSQL: search_vector is GENERATED ALWAYS, don't include it
|
||||
# pg_textsearch: indexes operate on base columns directly, don't populate search_vector
|
||||
query = f"""
|
||||
INSERT INTO {fq_table("memory_units")} (
|
||||
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history,
|
||||
tags, event_date, occurred_start, occurred_end, mentioned_at
|
||||
)
|
||||
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8, $9, $10)
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
INSERT INTO {fq_table("memory_units")} (
|
||||
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history,
|
||||
tags, event_date, occurred_start, occurred_end, mentioned_at
|
||||
)
|
||||
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8, $9, $10)
|
||||
RETURNING id
|
||||
""",
|
||||
query,
|
||||
observation_id,
|
||||
bank_id,
|
||||
observation_text,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
CONSOLIDATION_SYSTEM_PROMPT = """You are a memory consolidation system. Your job is to convert facts into durable knowledge (observations) and merge with existing knowledge when appropriate.
|
||||
|
||||
You must output ONLY valid JSON with no markdown formatting, no code blocks, and no additional text.
|
||||
You must output ONLY valid JSON with no markdown code blocks or additional text. However, the "text" field within each observation should use markdown formatting (headers, lists, bold, etc.) for clarity and readability.
|
||||
|
||||
## EXTRACT DURABLE KNOWLEDGE, NOT EPHEMERAL STATE
|
||||
Facts often describe events or actions. Extract the DURABLE KNOWLEDGE implied by the fact, not the transient state.
|
||||
@@ -32,13 +32,16 @@ BAD examples:
|
||||
|
||||
## MERGE RULES (when comparing to existing observations):
|
||||
1. REDUNDANT: Same information worded differently → update existing
|
||||
2. CONTRADICTION: Opposite information about same topic → update with history (e.g., "used to X, now Y")
|
||||
3. UPDATE: New state replacing old state → update with history
|
||||
2. CONTRADICTION: Opposite information about same topic → update with temporal markers showing change
|
||||
Example: "Alex used to love pizza but now hates it" OR "Alex's pizza preference changed from love to hate"
|
||||
3. UPDATE: New state replacing old state → update showing the transition with "used to", "now", "changed from X to Y"
|
||||
|
||||
## CRITICAL RULES:
|
||||
- NEVER merge facts about DIFFERENT people
|
||||
- NEVER merge unrelated topics (food preferences vs work vs hobbies)
|
||||
- When merging contradictions, capture the CHANGE (before → after)
|
||||
- When merging contradictions, the "text" field MUST capture BOTH states with temporal markers:
|
||||
* Use "used to X, now Y" OR "changed from X to Y" OR "X but now Y"
|
||||
* DO NOT just state the new fact - you MUST show the change
|
||||
- Keep observations focused on ONE specific topic per person
|
||||
- The "text" field MUST contain durable knowledge, not ephemeral state
|
||||
- Do NOT include "tags" in output - tags are handled automatically"""
|
||||
@@ -68,10 +71,15 @@ Instructions:
|
||||
- New topic → CREATE new observation
|
||||
- Purely ephemeral → return []
|
||||
|
||||
Output JSON array of actions:
|
||||
Output JSON array of actions (the "text" field should use markdown formatting for structure):
|
||||
[
|
||||
{{"action": "update", "learning_id": "uuid-from-observations", "text": "updated knowledge", "reason": "..."}},
|
||||
{{"action": "create", "text": "new durable knowledge", "reason": "..."}}
|
||||
{{"action": "update", "learning_id": "uuid-from-observations", "text": "## Updated Knowledge\n\n**Key point**: details here\n\n- Supporting detail 1\n- Supporting detail 2", "reason": "..."}},
|
||||
{{"action": "create", "text": "## New Durable Knowledge\n\nDescription with **emphasis** and proper structure", "reason": "..."}}
|
||||
]
|
||||
|
||||
Return [] if fact contains no durable knowledge."""
|
||||
Return [] if fact contains no durable knowledge.
|
||||
|
||||
IMPORTANT: Format the "text" field with markdown for better readability:
|
||||
- Use headers, lists, bold/italic, tables where appropriate
|
||||
- CRITICAL: Add blank lines before and after block elements (tables, code blocks, lists)
|
||||
- Ensure proper spacing for markdown to render correctly"""
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
|
||||
@@ -20,23 +21,23 @@ 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,
|
||||
ENV_COHERE_API_KEY,
|
||||
ENV_LITELLM_API_BASE,
|
||||
ENV_LITELLM_API_KEY,
|
||||
ENV_RERANKER_COHERE_BASE_URL,
|
||||
ENV_RERANKER_COHERE_API_KEY,
|
||||
ENV_RERANKER_COHERE_MODEL,
|
||||
ENV_RERANKER_FLASHRANK_CACHE_DIR,
|
||||
ENV_RERANKER_FLASHRANK_MODEL,
|
||||
ENV_RERANKER_LITELLM_MODEL,
|
||||
ENV_RERANKER_LITELLM_SDK_API_KEY,
|
||||
ENV_RERANKER_LOCAL_FORCE_CPU,
|
||||
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,
|
||||
@@ -101,7 +102,13 @@ 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):
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str | None = None,
|
||||
max_concurrent: int = 4,
|
||||
force_cpu: bool = False,
|
||||
trust_remote_code: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize local SentenceTransformers cross-encoder.
|
||||
|
||||
@@ -112,9 +119,13 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
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
|
||||
|
||||
@@ -162,11 +173,29 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
|
||||
|
||||
self._model = CrossEncoder(
|
||||
self.model_name,
|
||||
device=device,
|
||||
model_kwargs={"low_cpu_mem_usage": False},
|
||||
)
|
||||
# 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)
|
||||
|
||||
# Initialize shared executor (limited workers naturally limits concurrency)
|
||||
if LocalSTCrossEncoder._executor is None:
|
||||
@@ -801,6 +830,126 @@ 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.
|
||||
@@ -829,26 +978,41 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
|
||||
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,
|
||||
)
|
||||
elif provider == "cohere":
|
||||
api_key = os.environ.get(ENV_COHERE_API_KEY)
|
||||
api_key = config.reranker_cohere_api_key
|
||||
if not api_key:
|
||||
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)
|
||||
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,
|
||||
)
|
||||
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":
|
||||
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)
|
||||
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 == "rrf":
|
||||
return RRFPassthroughCrossEncoder()
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'flashrank', 'litellm', 'rrf'"
|
||||
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'flashrank', 'litellm', 'litellm-sdk', 'rrf'"
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ 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
|
||||
@@ -18,24 +19,23 @@ 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_COHERE_API_KEY,
|
||||
ENV_EMBEDDINGS_COHERE_BASE_URL,
|
||||
ENV_EMBEDDINGS_COHERE_MODEL,
|
||||
ENV_EMBEDDINGS_LITELLM_MODEL,
|
||||
ENV_EMBEDDINGS_COHERE_API_KEY,
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_API_KEY,
|
||||
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
|
||||
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,7 +94,7 @@ class LocalSTEmbeddings(Embeddings):
|
||||
The embedding dimension is auto-detected from the model.
|
||||
"""
|
||||
|
||||
def __init__(self, model_name: str | None = None, force_cpu: bool = False):
|
||||
def __init__(self, model_name: str | None = None, force_cpu: bool = False, trust_remote_code: bool = False):
|
||||
"""
|
||||
Initialize local SentenceTransformers embeddings.
|
||||
|
||||
@@ -103,9 +103,13 @@ class LocalSTEmbeddings(Embeddings):
|
||||
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
|
||||
|
||||
@@ -157,11 +161,29 @@ class LocalSTEmbeddings(Embeddings):
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
|
||||
|
||||
self._model = SentenceTransformer(
|
||||
self.model_name,
|
||||
device=device,
|
||||
model_kwargs={"low_cpu_mem_usage": False},
|
||||
)
|
||||
# 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)
|
||||
|
||||
self._dimension = self._model.get_sentence_embedding_dimension()
|
||||
logger.info(f"Embeddings: local provider initialized (dim: {self._dimension})")
|
||||
@@ -700,6 +722,148 @@ 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,
|
||||
}
|
||||
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,
|
||||
}
|
||||
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.
|
||||
@@ -723,6 +887,7 @@ def create_embeddings_from_env() -> Embeddings:
|
||||
return LocalSTEmbeddings(
|
||||
model_name=config.embeddings_local_model,
|
||||
force_cpu=config.embeddings_local_force_cpu,
|
||||
trust_remote_code=config.embeddings_local_trust_remote_code,
|
||||
)
|
||||
elif provider == "openai":
|
||||
# Use dedicated embeddings API key, or fall back to LLM API key
|
||||
@@ -736,18 +901,33 @@ 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 = os.environ.get(ENV_COHERE_API_KEY)
|
||||
api_key = config.embeddings_cohere_api_key
|
||||
if not api_key:
|
||||
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)
|
||||
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,
|
||||
)
|
||||
elif provider == "litellm":
|
||||
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)
|
||||
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,
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown embeddings provider: {provider}. Supported: 'local', 'tei', 'openai', 'cohere', 'litellm'"
|
||||
f"Unknown embeddings provider: {provider}. "
|
||||
f"Supported: 'local', 'tei', 'openai', 'cohere', 'litellm', 'litellm-sdk'"
|
||||
)
|
||||
|
||||
@@ -48,6 +48,7 @@ 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.
|
||||
@@ -55,8 +56,9 @@ class MemoryEngineInterface(ABC):
|
||||
Args:
|
||||
bank_id: The memory bank ID.
|
||||
contents: List of content dicts with 'content', optional 'event_date',
|
||||
'context', 'metadata', 'document_id'.
|
||||
'context', 'metadata', 'document_id', and per-item 'tags'.
|
||||
request_context: Request context for authentication.
|
||||
document_tags: Optional tags applied to all items in the batch.
|
||||
|
||||
Returns:
|
||||
Dict with processing results.
|
||||
@@ -561,6 +563,7 @@ 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.
|
||||
@@ -569,6 +572,7 @@ 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.
|
||||
|
||||
@@ -128,6 +128,67 @@ class LLMInterface(ABC):
|
||||
"""
|
||||
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.)."""
|
||||
|
||||
@@ -67,6 +67,7 @@ def create_llm_provider(
|
||||
model: str,
|
||||
reasoning_effort: str,
|
||||
groq_service_tier: str | None = None,
|
||||
openai_service_tier: str | None = None,
|
||||
vertexai_project_id: str | None = None,
|
||||
vertexai_region: str | None = None,
|
||||
vertexai_credentials: Any = None,
|
||||
@@ -80,7 +81,8 @@ def create_llm_provider(
|
||||
base_url: Base URL for the API.
|
||||
model: Model name.
|
||||
reasoning_effort: Reasoning effort level for supported providers.
|
||||
groq_service_tier: Groq service tier (for Groq provider).
|
||||
groq_service_tier: Groq service tier (for Groq provider) - "on_demand", "flex", or "auto".
|
||||
openai_service_tier: OpenAI service tier (for OpenAI provider) - None (default) or "flex" (50% cheaper).
|
||||
vertexai_project_id: Vertex AI project ID (for VertexAI provider).
|
||||
vertexai_region: Vertex AI region (for VertexAI provider).
|
||||
vertexai_credentials: Vertex AI credentials object (for VertexAI provider).
|
||||
@@ -156,6 +158,7 @@ def create_llm_provider(
|
||||
model=model,
|
||||
reasoning_effort=reasoning_effort,
|
||||
groq_service_tier=groq_service_tier,
|
||||
openai_service_tier=openai_service_tier,
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -177,6 +180,7 @@ class LLMProvider:
|
||||
model: str,
|
||||
reasoning_effort: str = "low",
|
||||
groq_service_tier: str | None = None,
|
||||
openai_service_tier: str | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize LLM provider.
|
||||
@@ -187,15 +191,17 @@ class LLMProvider:
|
||||
base_url: Base URL for the API.
|
||||
model: Model name.
|
||||
reasoning_effort: Reasoning effort level for supported providers.
|
||||
groq_service_tier: Groq service tier ("on_demand", "flex", "auto"). Default: None (uses Groq's default).
|
||||
groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config.
|
||||
openai_service_tier: OpenAI service tier (None or "flex") - from config.
|
||||
"""
|
||||
self.provider = provider.lower()
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
self.model = model
|
||||
self.reasoning_effort = reasoning_effort
|
||||
# Default to 'auto' for best performance, users can override to 'on_demand' for free tier
|
||||
self.groq_service_tier = groq_service_tier or os.getenv(ENV_LLM_GROQ_SERVICE_TIER, "auto")
|
||||
# Service tiers from hierarchical config (not env vars)
|
||||
self.groq_service_tier = groq_service_tier
|
||||
self.openai_service_tier = openai_service_tier
|
||||
|
||||
# Validate provider
|
||||
valid_providers = [
|
||||
@@ -272,6 +278,7 @@ class LLMProvider:
|
||||
model=self.model,
|
||||
reasoning_effort=self.reasoning_effort,
|
||||
groq_service_tier=self.groq_service_tier,
|
||||
openai_service_tier=self.openai_service_tier,
|
||||
vertexai_project_id=vertexai_project_id,
|
||||
vertexai_region=vertexai_region,
|
||||
vertexai_credentials=vertexai_credentials,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
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)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,58 @@
|
||||
"""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
|
||||
@@ -0,0 +1,137 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""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"
|
||||
@@ -84,7 +84,7 @@ class AnthropicLLM(LLMInterface):
|
||||
messages=test_messages,
|
||||
max_completion_tokens=10,
|
||||
temperature=0.0,
|
||||
scope="test",
|
||||
scope="verification",
|
||||
max_retries=0,
|
||||
)
|
||||
logger.info("Anthropic connection verified successfully")
|
||||
@@ -223,6 +223,24 @@ class AnthropicLLM(LLMInterface):
|
||||
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(
|
||||
@@ -397,16 +415,41 @@ class AnthropicLLM(LLMInterface):
|
||||
|
||||
# Record metrics
|
||||
metrics = get_metrics_collector()
|
||||
duration = time.time() - start_time
|
||||
metrics.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
duration=time.time() - start_time,
|
||||
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,
|
||||
|
||||
@@ -95,7 +95,7 @@ class ClaudeCodeLLM(LLMInterface):
|
||||
messages=test_messages,
|
||||
max_completion_tokens=10,
|
||||
temperature=0.0,
|
||||
scope="test",
|
||||
scope="verification",
|
||||
max_retries=0,
|
||||
)
|
||||
logger.info("Claude Code connection verified successfully")
|
||||
@@ -237,6 +237,23 @@ class ClaudeCodeLLM(LLMInterface):
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Record trace span
|
||||
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 json.dumps(result),
|
||||
input_tokens=estimated_input,
|
||||
output_tokens=estimated_output,
|
||||
duration=duration,
|
||||
finish_reason=None,
|
||||
error=None,
|
||||
)
|
||||
|
||||
# Log slow calls
|
||||
if duration > 10.0:
|
||||
logger.info(
|
||||
@@ -291,61 +308,202 @@ class ClaudeCodeLLM(LLMInterface):
|
||||
tool_choice: str | dict[str, Any] = "auto",
|
||||
) -> LLMToolCallResult:
|
||||
"""
|
||||
Make an LLM API call with tool/function calling support.
|
||||
Make an LLM API call with tool/function calling support using Claude Agent SDK.
|
||||
|
||||
Note: This is a simplified implementation. Full tool support would require
|
||||
integrating with Claude Agent SDK's tool system.
|
||||
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.
|
||||
temperature: Sampling temperature.
|
||||
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 - "auto", "none", "required", or specific function.
|
||||
tool_choice: How to choose tools (not used by Claude Agent SDK).
|
||||
|
||||
Returns:
|
||||
LLMToolCallResult with content and/or tool_calls.
|
||||
"""
|
||||
# For now, use regular call without tools
|
||||
# Full implementation would require mapping OpenAI tool format to Claude Agent SDK tools
|
||||
logger.warning(
|
||||
"Claude Code provider does not fully support tool calling yet. Falling back to regular text completion."
|
||||
from claude_agent_sdk import (
|
||||
AssistantMessage,
|
||||
ClaudeAgentOptions,
|
||||
ClaudeSDKClient,
|
||||
SdkMcpTool,
|
||||
TextBlock,
|
||||
ToolUseBlock,
|
||||
create_sdk_mcp_server,
|
||||
)
|
||||
|
||||
result = await self.call(
|
||||
messages=messages,
|
||||
response_format=None,
|
||||
max_completion_tokens=max_completion_tokens,
|
||||
temperature=temperature,
|
||||
scope=scope,
|
||||
max_retries=max_retries,
|
||||
initial_backoff=initial_backoff,
|
||||
max_backoff=max_backoff,
|
||||
return_usage=True,
|
||||
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,
|
||||
)
|
||||
|
||||
if isinstance(result, tuple):
|
||||
text, usage = result
|
||||
return LLMToolCallResult(
|
||||
content=text,
|
||||
tool_calls=[],
|
||||
finish_reason="stop",
|
||||
input_tokens=usage.input_tokens,
|
||||
output_tokens=usage.output_tokens,
|
||||
)
|
||||
else:
|
||||
# Fallback if return_usage didn't work as expected
|
||||
return LLMToolCallResult(
|
||||
content=str(result),
|
||||
tool_calls=[],
|
||||
finish_reason="stop",
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
)
|
||||
# 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)."""
|
||||
|
||||
@@ -136,6 +136,7 @@ class CodexLLM(LLMInterface):
|
||||
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:
|
||||
@@ -177,6 +178,9 @@ class CodexLLM(LLMInterface):
|
||||
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,
|
||||
@@ -192,7 +196,7 @@ class CodexLLM(LLMInterface):
|
||||
"tools": [],
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": True,
|
||||
"reasoning": {"summary": self.reasoning_summary},
|
||||
"reasoning": {"summary": reasoning_summary},
|
||||
"store": False, # Codex uses stateless mode
|
||||
"stream": True, # SSE streaming
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
@@ -258,6 +262,26 @@ class CodexLLM(LLMInterface):
|
||||
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
|
||||
@@ -283,13 +307,20 @@ class CodexLLM(LLMInterface):
|
||||
"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})")
|
||||
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: {e}")
|
||||
logger.error(
|
||||
f"Codex HTTP error after {max_retries + 1} attempts: Status {status_code}, Detail: {error_detail}"
|
||||
)
|
||||
raise
|
||||
|
||||
except httpx.RequestError as e:
|
||||
@@ -379,8 +410,22 @@ class CodexLLM(LLMInterface):
|
||||
"""
|
||||
Make API call with tool calling support.
|
||||
|
||||
Note: This is a basic implementation. Full tool calling support for Codex
|
||||
may require additional SSE event parsing.
|
||||
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()
|
||||
|
||||
@@ -413,20 +458,22 @@ class CodexLLM(LLMInterface):
|
||||
)
|
||||
|
||||
# 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",
|
||||
"function": {
|
||||
"name": func.get("name", ""),
|
||||
"description": func.get("description", ""),
|
||||
"parameters": func.get("parameters", {}),
|
||||
},
|
||||
"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,
|
||||
@@ -434,7 +481,7 @@ class CodexLLM(LLMInterface):
|
||||
"tools": codex_tools,
|
||||
"tool_choice": tool_choice,
|
||||
"parallel_tool_calls": True,
|
||||
"reasoning": {"summary": self.reasoning_summary},
|
||||
"reasoning": {"summary": reasoning_summary},
|
||||
"store": False,
|
||||
"stream": True,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
@@ -451,8 +498,16 @@ class CodexLLM(LLMInterface):
|
||||
|
||||
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
|
||||
@@ -470,6 +525,28 @@ class CodexLLM(LLMInterface):
|
||||
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,
|
||||
@@ -512,13 +589,30 @@ class CodexLLM(LLMInterface):
|
||||
if event_type == "response.text.delta" and "delta" in data:
|
||||
content += data["delta"]
|
||||
|
||||
# Extract tool calls
|
||||
elif event_type == "response.function_call_arguments.delta":
|
||||
# Handle tool call events (implementation depends on actual Codex SSE format)
|
||||
pass
|
||||
# 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", "")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
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
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ class GeminiLLM(LLMInterface):
|
||||
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:
|
||||
@@ -275,6 +276,29 @@ class GeminiLLM(LLMInterface):
|
||||
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(
|
||||
@@ -466,6 +490,30 @@ class GeminiLLM(LLMInterface):
|
||||
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,
|
||||
|
||||
@@ -65,6 +65,7 @@ class MockLLM(LLMInterface):
|
||||
# Storage for test verification
|
||||
self._mock_calls: list[dict] = []
|
||||
self._mock_response: Any = None
|
||||
self._mock_exception: Exception | None = None
|
||||
|
||||
async def verify_connection(self) -> None:
|
||||
"""
|
||||
@@ -124,6 +125,27 @@ class MockLLM(LLMInterface):
|
||||
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._mock_response is not None:
|
||||
result = self._mock_response
|
||||
@@ -183,20 +205,54 @@ class MockLLM(LLMInterface):
|
||||
}
|
||||
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._mock_response is not None:
|
||||
if isinstance(self._mock_response, LLMToolCallResult):
|
||||
return self._mock_response
|
||||
# Allow setting just tool calls as a list
|
||||
if isinstance(self._mock_response, list):
|
||||
return 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")
|
||||
|
||||
return 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)."""
|
||||
@@ -215,6 +271,16 @@ class MockLLM(LLMInterface):
|
||||
"""
|
||||
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.
|
||||
@@ -230,5 +296,6 @@ class MockLLM(LLMInterface):
|
||||
return self._mock_calls
|
||||
|
||||
def clear_mock_calls(self) -> None:
|
||||
"""Clear the recorded mock calls."""
|
||||
"""Clear the recorded mock calls and any set exception."""
|
||||
self._mock_calls = []
|
||||
self._mock_exception = None
|
||||
|
||||
@@ -16,6 +16,7 @@ Features:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -96,8 +97,9 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
if self.provider in ("openai", "groq") and not self.api_key:
|
||||
raise ValueError(f"API key is required for {self.provider}")
|
||||
|
||||
# Groq service tier configuration
|
||||
self.groq_service_tier = groq_service_tier or os.getenv("HINDSIGHT_API_LLM_GROQ_SERVICE_TIER", "auto")
|
||||
# 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)))
|
||||
@@ -130,6 +132,7 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
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:
|
||||
@@ -368,6 +371,24 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
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)
|
||||
@@ -556,6 +577,30 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
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,
|
||||
@@ -739,6 +784,140 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
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:
|
||||
|
||||
@@ -402,7 +402,7 @@ async def run_reflect_agent(
|
||||
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
scope="reflect_agent_final",
|
||||
scope="reflect",
|
||||
max_completion_tokens=max_tokens,
|
||||
return_usage=True,
|
||||
)
|
||||
@@ -447,7 +447,7 @@ async def run_reflect_agent(
|
||||
result = await llm_config.call_with_tools(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
scope="reflect_agent",
|
||||
scope="reflect_tool_call",
|
||||
tool_choice="required" if iteration == 0 else "auto", # Force tool use on first iteration
|
||||
)
|
||||
llm_duration = int((time.time() - llm_start) * 1000)
|
||||
@@ -479,7 +479,7 @@ async def run_reflect_agent(
|
||||
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
scope="reflect_agent_final",
|
||||
scope="reflect",
|
||||
max_completion_tokens=max_tokens,
|
||||
return_usage=True,
|
||||
)
|
||||
@@ -550,7 +550,7 @@ async def run_reflect_agent(
|
||||
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
scope="reflect_agent_final",
|
||||
scope="reflect",
|
||||
max_completion_tokens=max_tokens,
|
||||
return_usage=True,
|
||||
)
|
||||
@@ -617,23 +617,30 @@ async def run_reflect_agent(
|
||||
)
|
||||
continue
|
||||
|
||||
# Process done tool
|
||||
return await _process_done_tool(
|
||||
done_call,
|
||||
available_memory_ids,
|
||||
available_mental_model_ids,
|
||||
available_observation_ids,
|
||||
iteration + 1,
|
||||
total_tools_called,
|
||||
tool_trace,
|
||||
_get_llm_trace(),
|
||||
_get_usage(),
|
||||
_log_completion,
|
||||
reflect_id,
|
||||
directives_applied=directives_applied,
|
||||
llm_config=llm_config,
|
||||
response_schema=response_schema,
|
||||
)
|
||||
# Process done tool - wrap with tool call span
|
||||
from hindsight_api.tracing import get_tracer
|
||||
|
||||
tracer = get_tracer()
|
||||
span_name = "hindsight.reflect_tool_call"
|
||||
with tracer.start_as_current_span(span_name) as span:
|
||||
span.set_attribute("hindsight.scope", "reflect_tool_call")
|
||||
span.set_attribute("hindsight.operation", "reflect_tool_call")
|
||||
return await _process_done_tool(
|
||||
done_call,
|
||||
available_memory_ids,
|
||||
available_mental_model_ids,
|
||||
available_observation_ids,
|
||||
iteration + 1,
|
||||
total_tools_called,
|
||||
tool_trace,
|
||||
_get_llm_trace(),
|
||||
_get_usage(),
|
||||
_log_completion,
|
||||
reflect_id,
|
||||
directives_applied=directives_applied,
|
||||
llm_config=llm_config,
|
||||
response_schema=response_schema,
|
||||
)
|
||||
|
||||
# Execute other tools in parallel (exclude done tool in all its format variants)
|
||||
other_tools = [tc for tc in result.tool_calls if not _is_done_tool(tc.name)]
|
||||
@@ -842,17 +849,67 @@ async def _execute_tool_with_timing(
|
||||
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
"""Execute a tool call and return result with timing."""
|
||||
start = time.time()
|
||||
result = await _execute_tool(
|
||||
tc.name,
|
||||
tc.arguments,
|
||||
search_mental_models_fn,
|
||||
search_observations_fn,
|
||||
recall_fn,
|
||||
expand_fn,
|
||||
)
|
||||
duration_ms = int((time.time() - start) * 1000)
|
||||
return result, duration_ms
|
||||
from hindsight_api.tracing import get_tracer
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Create span for tool execution
|
||||
tracer = get_tracer()
|
||||
# Normalize tool name for span
|
||||
normalized_name = _normalize_tool_name(tc.name)
|
||||
span_name = f"hindsight.reflect_tool_exec.{normalized_name}"
|
||||
|
||||
# Calculate timestamps
|
||||
start_time_ns = time.time_ns()
|
||||
|
||||
with tracer.start_as_current_span(
|
||||
span_name,
|
||||
start_time=start_time_ns,
|
||||
end_on_exit=False,
|
||||
) as span:
|
||||
# Set attributes
|
||||
span.set_attribute("hindsight.tool.name", normalized_name)
|
||||
span.set_attribute("hindsight.tool.id", tc.id)
|
||||
span.set_attribute("hindsight.tool.arguments", json.dumps(tc.arguments))
|
||||
|
||||
try:
|
||||
result = await _execute_tool(
|
||||
tc.name,
|
||||
tc.arguments,
|
||||
search_mental_models_fn,
|
||||
search_observations_fn,
|
||||
recall_fn,
|
||||
expand_fn,
|
||||
)
|
||||
|
||||
# Set success attributes
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
from opentelemetry.trace import Status, StatusCode
|
||||
|
||||
span.set_status(Status(StatusCode.ERROR, result["error"]))
|
||||
else:
|
||||
from opentelemetry.trace import Status, StatusCode
|
||||
|
||||
span.set_status(Status(StatusCode.OK))
|
||||
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
span.set_attribute("hindsight.tool.duration_ms", duration_ms)
|
||||
|
||||
# End span with correct timestamp
|
||||
end_time_ns = time.time_ns()
|
||||
span.end(end_time=end_time_ns)
|
||||
|
||||
return result, duration_ms
|
||||
except Exception as e:
|
||||
from opentelemetry.trace import Status, StatusCode
|
||||
|
||||
span.set_status(Status(StatusCode.ERROR, str(e)))
|
||||
span.record_exception(e)
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
span.set_attribute("hindsight.tool.duration_ms", duration_ms)
|
||||
end_time_ns = time.time_ns()
|
||||
span.end(end_time=end_time_ns)
|
||||
raise
|
||||
|
||||
|
||||
async def _execute_tool(
|
||||
@@ -871,21 +928,21 @@ async def _execute_tool(
|
||||
query = args.get("query")
|
||||
if not query:
|
||||
return {"error": "search_mental_models requires a query parameter"}
|
||||
max_results = args.get("max_results") or 5
|
||||
max_results = int(args.get("max_results") or 5)
|
||||
return await search_mental_models_fn(query, max_results)
|
||||
|
||||
elif tool_name == "search_observations":
|
||||
query = args.get("query")
|
||||
if not query:
|
||||
return {"error": "search_observations requires a query parameter"}
|
||||
max_tokens = max(args.get("max_tokens") or 5000, 1000) # Default 5000, min 1000
|
||||
max_tokens = max(int(args.get("max_tokens") or 5000), 1000) # Default 5000, min 1000
|
||||
return await search_observations_fn(query, max_tokens)
|
||||
|
||||
elif tool_name == "recall":
|
||||
query = args.get("query")
|
||||
if not query:
|
||||
return {"error": "recall requires a query parameter"}
|
||||
max_tokens = max(args.get("max_tokens") or 2048, 1000) # Default 2048, min 1000
|
||||
max_tokens = max(int(args.get("max_tokens") or 2048), 1000) # Default 2048, min 1000
|
||||
return await recall_fn(query, max_tokens)
|
||||
|
||||
elif tool_name == "expand":
|
||||
@@ -904,18 +961,18 @@ def _summarize_input(tool_name: str, args: dict[str, Any]) -> str:
|
||||
if tool_name == "search_mental_models":
|
||||
query = args.get("query", "")
|
||||
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
|
||||
max_results = args.get("max_results") or 5
|
||||
max_results = int(args.get("max_results") or 5)
|
||||
return f"(query={query_preview}, max_results={max_results})"
|
||||
elif tool_name == "search_observations":
|
||||
query = args.get("query", "")
|
||||
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
|
||||
max_tokens = max(args.get("max_tokens") or 5000, 1000)
|
||||
max_tokens = max(int(args.get("max_tokens") or 5000), 1000)
|
||||
return f"(query={query_preview}, max_tokens={max_tokens})"
|
||||
elif tool_name == "recall":
|
||||
query = args.get("query", "")
|
||||
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
|
||||
# Show actual value used (default 2048, min 1000)
|
||||
max_tokens = max(args.get("max_tokens") or 2048, 1000)
|
||||
max_tokens = max(int(args.get("max_tokens") or 2048), 1000)
|
||||
return f"(query={query_preview}, max_tokens={max_tokens})"
|
||||
elif tool_name == "expand":
|
||||
memory_ids = args.get("memory_ids", [])
|
||||
|
||||
@@ -31,7 +31,7 @@ class ReflectAction(BaseModel):
|
||||
default=None, description="Observation sections for done action (when output_mode=observations)"
|
||||
)
|
||||
# Plain text answer fields (for output_mode=answer)
|
||||
answer: str | None = Field(default=None, description="Plain text answer for done action (no markdown)")
|
||||
answer: str | None = Field(default=None, description="Well-formatted markdown answer for done action")
|
||||
answer_memory_ids: list[str] | None = Field(
|
||||
default=None, description="Memory IDs supporting the answer", alias="memory_ids"
|
||||
)
|
||||
|
||||
@@ -148,7 +148,15 @@ def build_system_prompt_for_tools(
|
||||
|
||||
parts = []
|
||||
|
||||
# Inject directives at the VERY START for maximum prominence
|
||||
# Anti-hallucination rule at the very top
|
||||
parts.extend(
|
||||
[
|
||||
"CRITICAL: You MUST ONLY use information from retrieved tool results. NEVER make up names, people, events, or entities.",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
# Inject directives after anti-hallucination rule
|
||||
if directives:
|
||||
parts.append(build_directives_section(directives))
|
||||
|
||||
@@ -162,7 +170,7 @@ def build_system_prompt_for_tools(
|
||||
parts.extend(
|
||||
[
|
||||
"## CRITICAL RULES",
|
||||
"- You must NEVER fabricate information that has no basis in retrieved data",
|
||||
"- ONLY use information from tool results - no external knowledge or guessing",
|
||||
"- You SHOULD synthesize, infer, and reason from the retrieved memories",
|
||||
"- You MUST search before saying you don't have information",
|
||||
"",
|
||||
@@ -300,9 +308,11 @@ def build_system_prompt_for_tools(
|
||||
parts.extend(
|
||||
[
|
||||
"",
|
||||
"## Output Format: Plain Text Answer",
|
||||
"Call done() with a plain text 'answer' field.",
|
||||
"- Do NOT use markdown formatting",
|
||||
"## Output Format: Well-Formatted Markdown Answer",
|
||||
"Call done() with a well-formatted markdown 'answer' field.",
|
||||
"- USE markdown formatting for structure (headers, lists, bold, italic, code blocks, tables, etc.)",
|
||||
"- CRITICAL: Add blank lines before and after block elements (tables, code blocks, lists)",
|
||||
"- Format for clarity and readability with proper spacing and hierarchy",
|
||||
"- NEVER include memory IDs, UUIDs, or 'Memory references' in the answer text",
|
||||
"- Put IDs ONLY in the memory_ids/mental_model_ids/observation_ids arrays, not in the answer",
|
||||
]
|
||||
@@ -463,21 +473,41 @@ def build_final_prompt(
|
||||
parts.append(
|
||||
"\n## Instructions\n"
|
||||
"Provide a thoughtful answer by synthesizing and reasoning from the retrieved data above. "
|
||||
"You can make reasonable inferences from the memories, but don't completely fabricate information."
|
||||
"You can make reasonable inferences from the memories, but don't completely fabricate information. "
|
||||
"If the exact answer isn't stated, use what IS stated to give the best possible answer. "
|
||||
"Only say 'I don't have information' if the retrieved data is truly unrelated to the question."
|
||||
"Only say 'I don't have information' if the retrieved data is truly unrelated to the question.\n\n"
|
||||
"IMPORTANT: Output ONLY the final answer. Do NOT include meta-commentary like "
|
||||
'"I\'ll search..." or "Let me analyze...". Do NOT explain your reasoning process. '
|
||||
"Just provide the direct synthesized answer."
|
||||
)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
FINAL_SYSTEM_PROMPT = """You are a thoughtful assistant that synthesizes answers from retrieved memories.
|
||||
FINAL_SYSTEM_PROMPT = """CRITICAL: You MUST ONLY use information from retrieved tool results. NEVER make up names, people, events, or entities.
|
||||
|
||||
You are a thoughtful assistant that synthesizes answers from retrieved memories.
|
||||
|
||||
Your approach:
|
||||
- Reason over the retrieved memories to answer the question
|
||||
- Make reasonable inferences when the exact answer isn't explicitly stated
|
||||
- Connect related memories to form a complete picture
|
||||
- Be helpful - if you have related information, use it to give the best possible answer
|
||||
- ONLY use information from tool results - no external knowledge or guessing
|
||||
|
||||
Only say "I don't have information" if the retrieved data is truly unrelated to the question.
|
||||
Do NOT fabricate information that has no basis in the retrieved data."""
|
||||
|
||||
FORMATTING: Use proper markdown formatting in your answer:
|
||||
- Headers (##, ###) for sections
|
||||
- Lists (bullet or numbered) for enumerations
|
||||
- Bold/italic for emphasis
|
||||
- Tables with proper syntax (ensure blank line before and after)
|
||||
- Code blocks where appropriate
|
||||
- CRITICAL: Always add blank lines before and after block elements (tables, code blocks, lists)
|
||||
- Proper spacing between sections
|
||||
|
||||
CRITICAL: Output ONLY the final synthesized answer. Do NOT include:
|
||||
- Meta-commentary about what you're doing ("I'll search...", "Let me analyze...")
|
||||
- Explanations of your reasoning process
|
||||
- Descriptions of your approach
|
||||
Just provide the direct answer with proper markdown formatting."""
|
||||
|
||||
@@ -54,19 +54,18 @@ async def tool_search_mental_models(
|
||||
Dict with matching mental models including content and freshness info
|
||||
"""
|
||||
from ..memory_engine import fq_table
|
||||
from ..search.tags import build_tags_where_clause
|
||||
|
||||
# Build filters dynamically
|
||||
filters = ""
|
||||
params: list[Any] = [bank_id, str(query_embedding), max_results]
|
||||
next_param = 4
|
||||
|
||||
# Use the centralized tag filtering logic
|
||||
if tags:
|
||||
if tags_match == "all":
|
||||
filters += f" AND tags @> ${next_param}::varchar[]"
|
||||
else:
|
||||
filters += f" AND (tags && ${next_param}::varchar[] OR tags IS NULL OR tags = '{{}}')"
|
||||
params.append(tags)
|
||||
next_param += 1
|
||||
tag_clause, tag_params, next_param = build_tags_where_clause(tags, param_offset=next_param, match=tags_match)
|
||||
filters += f" {tag_clause}"
|
||||
params.extend(tag_params)
|
||||
|
||||
if exclude_ids:
|
||||
filters += f" AND id != ALL(${next_param}::text[])"
|
||||
|
||||
@@ -139,7 +139,7 @@ TOOL_DONE_ANSWER = {
|
||||
"properties": {
|
||||
"answer": {
|
||||
"type": "string",
|
||||
"description": "Your response as plain text. Do NOT use markdown formatting. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array.",
|
||||
"description": "Your response as well-formatted markdown. Use headers, lists, bold/italic, and code blocks for clarity. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array.",
|
||||
},
|
||||
"memory_ids": {
|
||||
"type": "array",
|
||||
@@ -190,7 +190,7 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
|
||||
"properties": {
|
||||
"answer": {
|
||||
"type": "string",
|
||||
"description": "Your response as plain text. Do NOT use markdown formatting. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array.",
|
||||
"description": "Your response as well-formatted markdown. Use headers, lists, bold/italic, and code blocks for clarity. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array.",
|
||||
},
|
||||
"memory_ids": {
|
||||
"type": "array",
|
||||
|
||||
@@ -159,6 +159,10 @@ class MemoryFact(BaseModel):
|
||||
None, description="ID of the chunk this fact was extracted from (format: bank_id_document_id_chunk_index)"
|
||||
)
|
||||
tags: list[str] | None = Field(None, description="Visibility scope tags associated with this fact")
|
||||
source_fact_ids: list[str] | None = Field(
|
||||
None,
|
||||
description="IDs of source facts this observation was derived from (observation type only, when source_facts is enabled)",
|
||||
)
|
||||
|
||||
|
||||
class ChunkInfo(BaseModel):
|
||||
@@ -226,6 +230,9 @@ class RecallResult(BaseModel):
|
||||
chunks: dict[str, ChunkInfo] | None = Field(
|
||||
None, description="Chunks for facts, keyed by '{document_id}_{chunk_index}'"
|
||||
)
|
||||
source_facts: dict[str, MemoryFact] | None = Field(
|
||||
None, description="Source facts for observation-type results, keyed by fact ID"
|
||||
)
|
||||
|
||||
|
||||
class ReflectResult(BaseModel):
|
||||
|
||||
@@ -542,7 +542,12 @@ Output: ONLY 2 facts (skip coffee preference - too trivial):
|
||||
QUALITY OVER QUANTITY
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Ask: "Would this be useful to recall in 6 months?" If no, skip it."""
|
||||
Ask: "Would this be useful to recall in 6 months?" If no, skip it.
|
||||
|
||||
IMPORTANT: Sensory/emotional details and observations that provide meaningful context
|
||||
about experiences ARE important to remember, even if they seem small (e.g., how food
|
||||
tasted, how someone looked, how loud music was). Extract these if they characterize
|
||||
an experience or person."""
|
||||
|
||||
# Assembled concise prompt (backward compatible - exact same output as before)
|
||||
CONCISE_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
|
||||
@@ -641,6 +646,7 @@ For EVENTS (fact_kind="event") - MUST SET BOTH occurred_start AND occurred_end:
|
||||
- Convert relative dates → absolute using Event Date as reference
|
||||
- If Event Date is "Saturday, March 15, 2020", then "yesterday" = Friday, March 14, 2020
|
||||
- Dates mentioned in text (e.g., "in March 2020") should use THAT year, not current year
|
||||
- CRITICAL: If the content mentions an absolute date (e.g., "March 15, 2024", "2024-03-15"), you MUST extract it and set occurred_start in ISO format
|
||||
- Always include the day name (Monday, Tuesday, etc.) in the 'when' field
|
||||
- Set occurred_start AND occurred_end to WHEN IT HAPPENED (not when mentioned)
|
||||
- For single-day/point events: set occurred_end = occurred_start (same timestamp)
|
||||
@@ -689,44 +695,20 @@ Example: "Lost job → couldn't pay rent → moved apartment"
|
||||
- Fact 2: Moved apartment, causal_relations: [{target_index: 1, relation_type: "caused_by"}]"""
|
||||
|
||||
|
||||
async def _extract_facts_from_chunk(
|
||||
chunk: str,
|
||||
chunk_index: int,
|
||||
total_chunks: int,
|
||||
event_date: datetime,
|
||||
context: str,
|
||||
llm_config: "LLMConfig",
|
||||
agent_name: str = None,
|
||||
) -> tuple[list[dict[str, str]], TokenUsage]:
|
||||
def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
|
||||
"""
|
||||
Extract facts from a single chunk (internal helper for parallel processing).
|
||||
Build extraction prompt and response schema based on config.
|
||||
|
||||
Note: event_date parameter is kept for backward compatibility but not used in prompt.
|
||||
The LLM extracts temporal information from the context string instead.
|
||||
Returns:
|
||||
Tuple of (prompt, response_schema)
|
||||
"""
|
||||
import logging
|
||||
|
||||
from openai import BadRequestError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Determine which fact types to extract
|
||||
# Note: We use "assistant" in the prompt but convert to "bank" for storage
|
||||
fact_types_instruction = "Extract ONLY 'world' and 'assistant' type facts."
|
||||
|
||||
# Check config for extraction mode and causal link extraction
|
||||
config = get_config()
|
||||
extraction_mode = config.retain_extraction_mode
|
||||
extract_causal_links = config.retain_extract_causal_links
|
||||
|
||||
# Select base prompt based on extraction mode
|
||||
if extraction_mode == "custom":
|
||||
# Custom mode: inject user-provided guidelines
|
||||
if not config.retain_custom_instructions:
|
||||
logger.warning(
|
||||
"extraction_mode='custom' but HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS not set. "
|
||||
"Falling back to 'concise' mode."
|
||||
)
|
||||
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
|
||||
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
|
||||
else:
|
||||
@@ -742,33 +724,26 @@ async def _extract_facts_from_chunk(
|
||||
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
|
||||
prompt = base_prompt.format(fact_types_instruction=fact_types_instruction)
|
||||
|
||||
# Build the full prompt with or without causal relationships section
|
||||
# Select appropriate response schema based on extraction mode and causal links
|
||||
# Add causal relationships section if enabled
|
||||
if extract_causal_links:
|
||||
prompt = prompt + CAUSAL_RELATIONSHIPS_SECTION
|
||||
if extraction_mode == "verbose":
|
||||
response_schema = FactExtractionResponseVerbose
|
||||
else:
|
||||
response_schema = FactExtractionResponse
|
||||
response_schema = FactExtractionResponseVerbose if extraction_mode == "verbose" else FactExtractionResponse
|
||||
else:
|
||||
response_schema = FactExtractionResponseNoCausal
|
||||
|
||||
# Retry logic for JSON validation errors
|
||||
max_retries = 2
|
||||
last_error = None
|
||||
return prompt, response_schema
|
||||
|
||||
# Sanitize input text to prevent Unicode encoding errors (e.g., unpaired surrogates)
|
||||
sanitized_chunk = _sanitize_text(chunk)
|
||||
sanitized_context = _sanitize_text(context) if context else "none"
|
||||
|
||||
# Build user message with metadata and chunk content in a clear format
|
||||
# Format event_date with day of week for better temporal reasoning
|
||||
# Handle both datetime objects and ISO string formats (from deserialized async tasks)
|
||||
def _build_user_message(chunk: str, chunk_index: int, total_chunks: int, event_date: datetime, context: str) -> str:
|
||||
"""Build user message for fact extraction."""
|
||||
from .orchestrator import parse_datetime_flexible
|
||||
|
||||
sanitized_chunk = _sanitize_text(chunk)
|
||||
sanitized_context = _sanitize_text(context) if context else "none"
|
||||
event_date = parse_datetime_flexible(event_date)
|
||||
event_date_formatted = event_date.strftime("%A, %B %d, %Y") # e.g., "Monday, June 10, 2024"
|
||||
user_message = f"""Extract facts from the following text chunk.
|
||||
event_date_formatted = event_date.strftime("%A, %B %d, %Y")
|
||||
|
||||
return f"""Extract facts from the following text chunk.
|
||||
|
||||
Chunk: {chunk_index + 1}/{total_chunks}
|
||||
Event Date: {event_date_formatted} ({event_date.isoformat()})
|
||||
@@ -777,6 +752,70 @@ Context: {sanitized_context}
|
||||
Text:
|
||||
{sanitized_chunk}"""
|
||||
|
||||
|
||||
def _build_request_body(llm_config, config, prompt: str, user_message: str, response_schema: type) -> dict:
|
||||
"""Build request body for LLM API call."""
|
||||
request_body = {
|
||||
"model": llm_config.model,
|
||||
"messages": [{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
|
||||
"temperature": 0.1,
|
||||
}
|
||||
|
||||
# Add max_completion_tokens if configured
|
||||
if config.retain_max_completion_tokens:
|
||||
request_body["max_completion_tokens"] = config.retain_max_completion_tokens
|
||||
|
||||
# Add service_tier for OpenAI Flex Processing
|
||||
if llm_config.provider == "openai" and llm_config._provider_impl.openai_service_tier:
|
||||
request_body["service_tier"] = llm_config._provider_impl.openai_service_tier
|
||||
|
||||
# Add response_format (JSON schema)
|
||||
if hasattr(response_schema, "model_json_schema"):
|
||||
schema = response_schema.model_json_schema()
|
||||
request_body["response_format"] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {"name": "facts", "schema": schema},
|
||||
}
|
||||
|
||||
return request_body
|
||||
|
||||
|
||||
async def _extract_facts_from_chunk(
|
||||
chunk: str,
|
||||
chunk_index: int,
|
||||
total_chunks: int,
|
||||
event_date: datetime,
|
||||
context: str,
|
||||
llm_config: "LLMConfig",
|
||||
config,
|
||||
agent_name: str = None,
|
||||
) -> tuple[list[dict[str, str]], TokenUsage]:
|
||||
"""
|
||||
Extract facts from a single chunk (internal helper for parallel processing).
|
||||
|
||||
Note: event_date parameter is kept for backward compatibility but not used in prompt.
|
||||
The LLM extracts temporal information from the context string instead.
|
||||
"""
|
||||
import logging
|
||||
|
||||
from openai import BadRequestError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Build prompt and schema using helper function
|
||||
prompt, response_schema = _build_extraction_prompt_and_schema(config)
|
||||
|
||||
# Check config for extraction mode and causal link extraction
|
||||
extraction_mode = config.retain_extraction_mode
|
||||
extract_causal_links = config.retain_extract_causal_links
|
||||
|
||||
# Build user message using helper function
|
||||
user_message = _build_user_message(chunk, chunk_index, total_chunks, event_date, context)
|
||||
|
||||
# Retry logic for JSON validation errors
|
||||
max_retries = 2
|
||||
last_error = None
|
||||
|
||||
usage = TokenUsage() # Track cumulative usage across retries
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
@@ -796,7 +835,7 @@ Text:
|
||||
extraction_response_json, call_usage = await llm_config.call(
|
||||
messages=[{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
|
||||
response_format=response_schema,
|
||||
scope="memory_extract_facts",
|
||||
scope="retain_extract_facts",
|
||||
temperature=0.1,
|
||||
max_completion_tokens=config.retain_max_completion_tokens,
|
||||
max_retries=max_retries,
|
||||
@@ -1005,6 +1044,29 @@ Text:
|
||||
|
||||
except BadRequestError as e:
|
||||
last_error = e
|
||||
error_str = str(e).lower()
|
||||
|
||||
# Check if error is related to max_tokens/completion_tokens not being supported
|
||||
if any(
|
||||
keyword in error_str
|
||||
for keyword in [
|
||||
"max_tokens",
|
||||
"max_completion_tokens",
|
||||
"maximum context",
|
||||
"token limit",
|
||||
"context length",
|
||||
]
|
||||
):
|
||||
# Provide helpful error message with configuration suggestions
|
||||
raise ValueError(
|
||||
f"Model does not support the required output token limit.\n\n"
|
||||
f"The model '{llm_config.model}' (provider: {llm_config.provider}) failed with: {e}\n\n"
|
||||
f"You have two options to fix this:\n"
|
||||
f" 1. Use a different model that supports at least {config.retain_max_completion_tokens} output tokens\n"
|
||||
f" 2. Decrease HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS to a value your model supports\n"
|
||||
f" (current value: {config.retain_max_completion_tokens}, must be > RETAIN_CHUNK_SIZE={config.retain_chunk_size})"
|
||||
) from e
|
||||
|
||||
if "json_validate_failed" in str(e):
|
||||
logger.warning(
|
||||
f" [1.3.{chunk_index + 1}] Attempt {attempt + 1}/{max_retries} failed with JSON validation error: {e}"
|
||||
@@ -1026,6 +1088,7 @@ async def _extract_facts_with_auto_split(
|
||||
event_date: datetime,
|
||||
context: str,
|
||||
llm_config: LLMConfig,
|
||||
config,
|
||||
agent_name: str = None,
|
||||
) -> tuple[list[dict[str, str]], TokenUsage]:
|
||||
"""
|
||||
@@ -1041,6 +1104,7 @@ async def _extract_facts_with_auto_split(
|
||||
event_date: Reference date for temporal information
|
||||
context: Context about the conversation/document
|
||||
llm_config: LLM configuration to use
|
||||
config: Resolved HindsightConfig for this bank
|
||||
agent_name: Optional agent name (memory owner)
|
||||
|
||||
Returns:
|
||||
@@ -1059,6 +1123,7 @@ async def _extract_facts_with_auto_split(
|
||||
event_date=event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
config=config,
|
||||
agent_name=agent_name,
|
||||
)
|
||||
except OutputTooLongError:
|
||||
@@ -1103,6 +1168,7 @@ async def _extract_facts_with_auto_split(
|
||||
event_date=event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
config=config,
|
||||
agent_name=agent_name,
|
||||
),
|
||||
_extract_facts_with_auto_split(
|
||||
@@ -1112,6 +1178,7 @@ async def _extract_facts_with_auto_split(
|
||||
event_date=event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
config=config,
|
||||
agent_name=agent_name,
|
||||
),
|
||||
]
|
||||
@@ -1135,6 +1202,7 @@ async def extract_facts_from_text(
|
||||
event_date: datetime,
|
||||
llm_config: LLMConfig,
|
||||
agent_name: str,
|
||||
config,
|
||||
context: str = "",
|
||||
) -> tuple[list[Fact], list[tuple[str, int]], TokenUsage]:
|
||||
"""
|
||||
@@ -1149,9 +1217,10 @@ async def extract_facts_from_text(
|
||||
Args:
|
||||
text: Input text (conversation, article, etc.)
|
||||
event_date: Reference date for resolving relative times
|
||||
context: Context about the conversation/document
|
||||
llm_config: LLM configuration to use
|
||||
agent_name: Agent name (memory owner)
|
||||
config: Resolved HindsightConfig for this bank
|
||||
context: Context about the conversation/document
|
||||
|
||||
Returns:
|
||||
Tuple of (facts, chunks, usage) where:
|
||||
@@ -1159,7 +1228,6 @@ async def extract_facts_from_text(
|
||||
- chunks: List of tuples (chunk_text, fact_count) for each chunk
|
||||
- usage: Aggregated token usage across all LLM calls
|
||||
"""
|
||||
config = get_config()
|
||||
chunks = chunk_text(text, max_chars=config.retain_chunk_size)
|
||||
|
||||
# Log chunk count before starting LLM requests
|
||||
@@ -1178,6 +1246,7 @@ async def extract_facts_from_text(
|
||||
event_date=event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
config=config,
|
||||
agent_name=agent_name,
|
||||
)
|
||||
for i, chunk in enumerate(chunks)
|
||||
@@ -1205,12 +1274,424 @@ from .types import ExtractedFact as ExtractedFactType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Each fact gets 10 seconds offset to preserve ordering within a document
|
||||
SECONDS_PER_FACT = 10
|
||||
# Each fact gets 10ms offset to preserve ordering within a document
|
||||
SECONDS_PER_FACT = 0.01
|
||||
|
||||
|
||||
async def extract_facts_from_contents_batch_api(
|
||||
contents: list[RetainContent],
|
||||
llm_config,
|
||||
agent_name: str,
|
||||
config,
|
||||
pool=None,
|
||||
operation_id: str | None = None,
|
||||
schema: str | None = None,
|
||||
) -> tuple[list[ExtractedFactType], list[ChunkMetadata], TokenUsage]:
|
||||
"""
|
||||
Extract facts using LLM Batch API (OpenAI/Groq).
|
||||
|
||||
Submits all chunks as a single batch, polls until complete, then processes results.
|
||||
Only called when config.retain_batch_enabled=True.
|
||||
|
||||
Args:
|
||||
contents: List of RetainContent objects to process
|
||||
llm_config: LLM configuration with batch API support
|
||||
agent_name: Name of the agent
|
||||
config: Resolved HindsightConfig for this bank
|
||||
pool: Database connection pool (for storing batch state)
|
||||
operation_id: Async operation ID (for crash recovery)
|
||||
schema: Database schema (for multi-tenant support)
|
||||
|
||||
Returns:
|
||||
Tuple of (extracted_facts, chunks_metadata, usage)
|
||||
"""
|
||||
if not contents:
|
||||
return [], [], TokenUsage()
|
||||
|
||||
logger.info(f"Using Batch API for fact extraction ({len(contents)} contents)")
|
||||
|
||||
# Check config for extraction mode and causal link extraction (used throughout)
|
||||
extraction_mode = config.retain_extraction_mode
|
||||
extract_causal_links = config.retain_extract_causal_links
|
||||
|
||||
# Check if provider supports batch API
|
||||
if not await llm_config._provider_impl.supports_batch_api():
|
||||
logger.warning(f"Batch API not supported for provider {llm_config.provider}, falling back to sync mode")
|
||||
return await extract_facts_from_contents(contents, llm_config, agent_name, config, pool, operation_id, schema)
|
||||
|
||||
# Check if we're resuming an existing batch (crash recovery)
|
||||
batch_id = None
|
||||
if operation_id and pool:
|
||||
from ..task_backend import fq_table
|
||||
|
||||
table = fq_table("async_operations", schema)
|
||||
row = await pool.fetchrow(
|
||||
f"SELECT result_metadata FROM {table} WHERE operation_id = $1",
|
||||
operation_id,
|
||||
)
|
||||
|
||||
if row and row["result_metadata"]:
|
||||
metadata = row["result_metadata"]
|
||||
if isinstance(metadata, str):
|
||||
metadata = json.loads(metadata)
|
||||
batch_id = metadata.get("batch_id")
|
||||
|
||||
if batch_id:
|
||||
logger.info(f"Resuming existing batch: batch_id={batch_id} (crash recovery)")
|
||||
|
||||
# Step 1: Chunk all contents and build batch requests (skip if resuming)
|
||||
all_chunks_info = [] # List of (chunk_text, content_index, chunk_index_in_content, event_date, context)
|
||||
batch_requests = []
|
||||
|
||||
# Build prompt and schema once (same for all chunks)
|
||||
prompt, response_schema = _build_extraction_prompt_and_schema(config)
|
||||
|
||||
for content_index, item in enumerate(contents):
|
||||
chunks = chunk_text(item.content, max_chars=config.retain_chunk_size)
|
||||
|
||||
for chunk_index_in_content, chunk in enumerate(chunks):
|
||||
all_chunks_info.append((chunk, content_index, chunk_index_in_content, item.event_date, item.context))
|
||||
|
||||
# Build batch request for this chunk
|
||||
custom_id = f"chunk_{len(all_chunks_info) - 1}" # Global chunk index
|
||||
|
||||
# Build user message using helper function
|
||||
user_message = _build_user_message(
|
||||
chunk, chunk_index_in_content, len(chunks), item.event_date, item.context
|
||||
)
|
||||
|
||||
# Build request body using helper function
|
||||
request_body = _build_request_body(llm_config, config, prompt, user_message, response_schema)
|
||||
|
||||
batch_requests.append(
|
||||
{"custom_id": custom_id, "method": "POST", "url": "/v1/chat/completions", "body": request_body}
|
||||
)
|
||||
|
||||
if not batch_requests and not batch_id: # No requests and not resuming
|
||||
return [], [], TokenUsage()
|
||||
|
||||
# Step 2: Submit batch (skip if resuming)
|
||||
if not batch_id:
|
||||
logger.info(f"Submitting batch with {len(batch_requests)} chunk requests")
|
||||
|
||||
batch_metadata = await llm_config._provider_impl.submit_batch(batch_requests)
|
||||
batch_id = batch_metadata["batch_id"]
|
||||
|
||||
logger.info(f"Batch submitted: {batch_id}, polling every {config.retain_batch_poll_interval_seconds}s")
|
||||
|
||||
# CRITICAL: Store minimal batch state in operation metadata for crash recovery
|
||||
# This allows resuming polling if worker restarts
|
||||
if operation_id and pool:
|
||||
batch_state = {
|
||||
"batch_id": batch_id,
|
||||
"batch_provider": llm_config.provider,
|
||||
"chunk_count": len(batch_requests),
|
||||
}
|
||||
|
||||
# Update operation result_metadata
|
||||
from ..task_backend import fq_table
|
||||
|
||||
table = fq_table("async_operations", schema)
|
||||
await pool.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET result_metadata = result_metadata || $1::jsonb, updated_at = now()
|
||||
WHERE operation_id = $2
|
||||
""",
|
||||
json.dumps(batch_state),
|
||||
operation_id,
|
||||
)
|
||||
logger.info(f"Stored batch state for operation {operation_id} (crash recovery enabled)")
|
||||
else:
|
||||
logger.info(f"Resuming polling for existing batch: {batch_id}")
|
||||
|
||||
# Step 3: Poll until complete
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
while True:
|
||||
status_info = await llm_config._provider_impl.get_batch_status(batch_id)
|
||||
status = status_info["status"]
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
logger.info(
|
||||
f"Batch {batch_id}: status={status}, "
|
||||
f"completed={status_info['request_counts']['completed']}/{status_info['request_counts']['total']}, "
|
||||
f"elapsed={elapsed:.0f}s"
|
||||
)
|
||||
|
||||
if status == "completed":
|
||||
break
|
||||
elif status in ("failed", "expired", "cancelled"):
|
||||
error_msg = status_info.get("errors", "Unknown error")
|
||||
raise RuntimeError(f"Batch {batch_id} failed with status {status}: {error_msg}")
|
||||
|
||||
# Wait before polling again
|
||||
await asyncio.sleep(config.retain_batch_poll_interval_seconds)
|
||||
|
||||
logger.info(f"Batch {batch_id} completed in {elapsed:.0f}s, retrieving results")
|
||||
|
||||
# Step 4: Retrieve results
|
||||
batch_results = await llm_config._provider_impl.retrieve_batch_results(batch_id)
|
||||
|
||||
# Map results by custom_id
|
||||
results_by_id = {result["custom_id"]: result for result in batch_results}
|
||||
|
||||
# Step 5: Parse results into facts (same as sync mode)
|
||||
all_facts_from_llm = []
|
||||
chunks_metadata = []
|
||||
total_usage = TokenUsage()
|
||||
|
||||
for chunk_idx, (chunk_content, content_index, chunk_index_in_content, event_date, context) in enumerate(
|
||||
all_chunks_info
|
||||
):
|
||||
custom_id = f"chunk_{chunk_idx}"
|
||||
result = results_by_id.get(custom_id)
|
||||
|
||||
if not result:
|
||||
logger.warning(f"Missing result for {custom_id}, skipping")
|
||||
chunks_metadata.append(
|
||||
ChunkMetadata(
|
||||
chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Check for errors
|
||||
if result.get("error"):
|
||||
logger.error(f"Error in {custom_id}: {result['error']}")
|
||||
chunks_metadata.append(
|
||||
ChunkMetadata(
|
||||
chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Extract response
|
||||
response_body = result.get("response", {}).get("body", {})
|
||||
choices = response_body.get("choices", [])
|
||||
|
||||
if not choices:
|
||||
logger.warning(f"No choices in response for {custom_id}")
|
||||
chunks_metadata.append(
|
||||
ChunkMetadata(
|
||||
chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Parse JSON content
|
||||
message = choices[0].get("message", {})
|
||||
content_str = message.get("content", "{}")
|
||||
|
||||
try:
|
||||
extraction_response_json = json.loads(content_str)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse JSON for {custom_id}: {e}")
|
||||
chunks_metadata.append(
|
||||
ChunkMetadata(
|
||||
chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Parse facts (reuse existing logic from _extract_facts_from_chunk)
|
||||
raw_facts = extraction_response_json.get("facts", [])
|
||||
chunk_facts = []
|
||||
|
||||
for i, llm_fact in enumerate(raw_facts):
|
||||
if not isinstance(llm_fact, dict):
|
||||
continue
|
||||
|
||||
def get_value(field_name):
|
||||
value = llm_fact.get(field_name)
|
||||
if value and value != "" and value != [] and value != {} and str(value).upper() != "N/A":
|
||||
return value
|
||||
return None
|
||||
|
||||
what = get_value("what")
|
||||
if not what:
|
||||
what = get_value("factual_core")
|
||||
if not what:
|
||||
continue
|
||||
|
||||
when = get_value("when")
|
||||
who = get_value("who")
|
||||
why = get_value("why")
|
||||
|
||||
# Critical field: fact_type
|
||||
original_fact_type = llm_fact.get("fact_type")
|
||||
fact_type = original_fact_type
|
||||
|
||||
# Convert "assistant" → "experience"
|
||||
if fact_type == "assistant":
|
||||
fact_type = "experience"
|
||||
|
||||
# Validate fact_type
|
||||
if fact_type not in ["world", "experience", "opinion"]:
|
||||
fact_kind = llm_fact.get("fact_kind")
|
||||
if fact_kind == "assistant":
|
||||
fact_type = "experience"
|
||||
elif fact_kind in ["world", "experience", "opinion"]:
|
||||
fact_type = fact_kind
|
||||
else:
|
||||
fact_type = "world"
|
||||
|
||||
# Build combined fact text
|
||||
combined_parts = [what]
|
||||
if when:
|
||||
combined_parts.append(f"When: {when}")
|
||||
if who:
|
||||
combined_parts.append(f"Involving: {who}")
|
||||
if why:
|
||||
combined_parts.append(why)
|
||||
combined_text = " | ".join(combined_parts)
|
||||
|
||||
# Temporal fields
|
||||
fact_data = {}
|
||||
fact_kind = llm_fact.get("fact_kind", "conversation")
|
||||
if fact_kind not in ["conversation", "event", "other"]:
|
||||
fact_kind = "conversation"
|
||||
|
||||
if fact_kind == "event":
|
||||
occurred_start = get_value("occurred_start")
|
||||
occurred_end = get_value("occurred_end")
|
||||
|
||||
if not occurred_start:
|
||||
fact_data["occurred_start"] = _infer_temporal_date(combined_text, event_date)
|
||||
else:
|
||||
fact_data["occurred_start"] = occurred_start
|
||||
|
||||
if occurred_end:
|
||||
fact_data["occurred_end"] = occurred_end
|
||||
elif fact_data.get("occurred_start"):
|
||||
fact_data["occurred_end"] = fact_data["occurred_start"]
|
||||
|
||||
# Entities
|
||||
entities = get_value("entities")
|
||||
if entities:
|
||||
validated_entities = []
|
||||
for ent in entities:
|
||||
if isinstance(ent, str):
|
||||
validated_entities.append(Entity(text=ent))
|
||||
elif isinstance(ent, dict) and "text" in ent:
|
||||
try:
|
||||
validated_entities.append(Entity.model_validate(ent))
|
||||
except Exception:
|
||||
pass
|
||||
if validated_entities:
|
||||
fact_data["entities"] = validated_entities
|
||||
|
||||
# Causal relations
|
||||
if extract_causal_links:
|
||||
validated_relations = []
|
||||
causal_relations_raw = get_value("causal_relations")
|
||||
if causal_relations_raw:
|
||||
for rel in causal_relations_raw:
|
||||
if not isinstance(rel, dict):
|
||||
continue
|
||||
target_idx = rel.get("target_index")
|
||||
relation_type = rel.get("relation_type")
|
||||
strength = rel.get("strength", 1.0)
|
||||
|
||||
if target_idx is None or relation_type is None:
|
||||
continue
|
||||
if target_idx < 0 or target_idx >= i:
|
||||
continue
|
||||
|
||||
try:
|
||||
validated_relations.append(
|
||||
CausalRelation(
|
||||
target_fact_index=target_idx, relation_type=relation_type, strength=strength
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if validated_relations:
|
||||
fact_data["causal_relations"] = validated_relations
|
||||
|
||||
# Always set mentioned_at
|
||||
fact_data["mentioned_at"] = event_date.isoformat()
|
||||
|
||||
try:
|
||||
fact = Fact(fact=combined_text, fact_type=fact_type, **fact_data)
|
||||
chunk_facts.append(fact)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create Fact model for fact {i}: {e}")
|
||||
continue
|
||||
|
||||
all_facts_from_llm.extend(chunk_facts)
|
||||
chunks_metadata.append(
|
||||
ChunkMetadata(
|
||||
chunk_text=chunk_content,
|
||||
fact_count=len(chunk_facts),
|
||||
content_index=content_index,
|
||||
chunk_index=chunk_idx,
|
||||
)
|
||||
)
|
||||
|
||||
# Track token usage
|
||||
usage_data = response_body.get("usage", {})
|
||||
if usage_data:
|
||||
total_usage = total_usage + TokenUsage(
|
||||
input_tokens=usage_data.get("prompt_tokens", 0),
|
||||
output_tokens=usage_data.get("completion_tokens", 0),
|
||||
total_tokens=usage_data.get("total_tokens", 0),
|
||||
)
|
||||
|
||||
# Step 6: Convert to ExtractedFact objects with proper chunk mapping
|
||||
# Group facts by chunk
|
||||
facts_by_chunk = [] # List of (chunk_metadata, [facts])
|
||||
fact_start_idx = 0
|
||||
|
||||
for chunk_meta in chunks_metadata:
|
||||
chunk_facts = all_facts_from_llm[fact_start_idx : fact_start_idx + chunk_meta.fact_count]
|
||||
facts_by_chunk.append((chunk_meta, chunk_facts))
|
||||
fact_start_idx += chunk_meta.fact_count
|
||||
|
||||
# Now convert to ExtractedFactType
|
||||
extracted_facts = []
|
||||
global_fact_idx = 0
|
||||
|
||||
for chunk_meta, chunk_facts in facts_by_chunk:
|
||||
content = contents[chunk_meta.content_index]
|
||||
|
||||
for fact_from_llm in chunk_facts:
|
||||
extracted_fact = ExtractedFactType(
|
||||
fact_text=fact_from_llm.fact,
|
||||
fact_type=fact_from_llm.fact_type,
|
||||
entities=[e.text for e in (fact_from_llm.entities or [])],
|
||||
occurred_start=_parse_datetime(fact_from_llm.occurred_start) if fact_from_llm.occurred_start else None,
|
||||
occurred_end=_parse_datetime(fact_from_llm.occurred_end) if fact_from_llm.occurred_end else None,
|
||||
causal_relations=_convert_causal_relations(fact_from_llm.causal_relations or [], global_fact_idx),
|
||||
content_index=chunk_meta.content_index,
|
||||
chunk_index=chunk_meta.chunk_index,
|
||||
context=content.context,
|
||||
mentioned_at=content.event_date,
|
||||
metadata=content.metadata,
|
||||
tags=content.tags,
|
||||
)
|
||||
|
||||
extracted_facts.append(extracted_fact)
|
||||
global_fact_idx += 1
|
||||
|
||||
# Step 7: Add temporal offsets
|
||||
_add_temporal_offsets(extracted_facts, contents)
|
||||
|
||||
logger.info(f"Batch API extracted {len(extracted_facts)} facts from {len(all_chunks_info)} chunks")
|
||||
|
||||
return extracted_facts, chunks_metadata, total_usage
|
||||
|
||||
|
||||
async def extract_facts_from_contents(
|
||||
contents: list[RetainContent], llm_config, agent_name: str
|
||||
contents: list[RetainContent],
|
||||
llm_config,
|
||||
agent_name: str,
|
||||
config,
|
||||
pool=None,
|
||||
operation_id: str | None = None,
|
||||
schema: str | None = None,
|
||||
) -> tuple[list[ExtractedFactType], list[ChunkMetadata], TokenUsage]:
|
||||
"""
|
||||
Extract facts from multiple content items in parallel.
|
||||
@@ -1221,10 +1702,16 @@ async def extract_facts_from_contents(
|
||||
3. Adds time offsets to preserve fact ordering within each content
|
||||
4. Returns typed ExtractedFact and ChunkMetadata objects
|
||||
|
||||
Routes to batch API mode if config.retain_batch_enabled=True.
|
||||
|
||||
Args:
|
||||
contents: List of RetainContent objects to process
|
||||
llm_config: LLM configuration for fact extraction
|
||||
agent_name: Name of the agent (for agent-related fact detection)
|
||||
config: Resolved HindsightConfig for this bank
|
||||
pool: Database connection pool (passed to batch API for state storage)
|
||||
operation_id: Async operation ID (passed to batch API for crash recovery)
|
||||
schema: Database schema (passed to batch API for multi-tenant support)
|
||||
|
||||
Returns:
|
||||
Tuple of (extracted_facts, chunks_metadata, usage)
|
||||
@@ -1232,6 +1719,12 @@ async def extract_facts_from_contents(
|
||||
if not contents:
|
||||
return [], [], TokenUsage()
|
||||
|
||||
# Route to batch API if enabled
|
||||
if config.retain_batch_enabled:
|
||||
return await extract_facts_from_contents_batch_api(
|
||||
contents, llm_config, agent_name, config, pool, operation_id, schema
|
||||
)
|
||||
|
||||
# Step 1: Create parallel fact extraction tasks
|
||||
fact_extraction_tasks = []
|
||||
for item in contents:
|
||||
@@ -1243,6 +1736,7 @@ async def extract_facts_from_contents(
|
||||
context=item.context,
|
||||
llm_config=llm_config,
|
||||
agent_name=agent_name,
|
||||
config=config,
|
||||
)
|
||||
fact_extraction_tasks.append(task)
|
||||
|
||||
@@ -1347,28 +1841,21 @@ def _convert_causal_relations(relations_from_llm, fact_start_idx: int) -> list[C
|
||||
|
||||
def _add_temporal_offsets(facts: list[ExtractedFactType], contents: list[RetainContent]) -> None:
|
||||
"""
|
||||
Add time offsets to preserve fact ordering within each content.
|
||||
Add time offsets to preserve fact ordering across all contents.
|
||||
|
||||
This allows retrieval to distinguish between facts that happened earlier vs later
|
||||
in the same conversation, even when the base event_date is the same.
|
||||
This allows retrieval to distinguish between facts from different documents/conversations
|
||||
even when they have the same base event_date, and also between facts within the same
|
||||
conversation.
|
||||
|
||||
Uses absolute position across all facts to ensure unique timestamps.
|
||||
|
||||
Modifies facts in place.
|
||||
"""
|
||||
from .orchestrator import parse_datetime_flexible
|
||||
|
||||
# Group facts by content_index
|
||||
current_content_idx = 0
|
||||
content_fact_start = 0
|
||||
|
||||
for i, fact in enumerate(facts):
|
||||
if fact.content_index != current_content_idx:
|
||||
# Moved to next content
|
||||
current_content_idx = fact.content_index
|
||||
content_fact_start = i
|
||||
|
||||
# Calculate position within this content
|
||||
fact_position = i - content_fact_start
|
||||
offset = timedelta(seconds=fact_position * SECONDS_PER_FACT)
|
||||
# Use absolute position across all facts to ensure uniqueness across different contents
|
||||
offset = timedelta(seconds=i * SECONDS_PER_FACT)
|
||||
|
||||
# Apply offset to all temporal fields (handle both datetime objects and ISO strings)
|
||||
if fact.occurred_start:
|
||||
|
||||
@@ -7,6 +7,7 @@ Handles insertion of facts into the database.
|
||||
import json
|
||||
import logging
|
||||
|
||||
from ...config import get_config
|
||||
from ..memory_engine import fq_table
|
||||
from .fact_extraction import _sanitize_text
|
||||
from .types import ProcessedFact
|
||||
@@ -70,28 +71,59 @@ async def insert_facts_batch(
|
||||
|
||||
# Batch insert all facts
|
||||
# Note: tags are passed as JSON strings and converted back to varchar[] via jsonb_array_elements_text + array_agg
|
||||
results = await conn.fetch(
|
||||
f"""
|
||||
WITH input_data AS (
|
||||
SELECT * FROM unnest(
|
||||
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
|
||||
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[]
|
||||
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags_json)
|
||||
)
|
||||
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags)
|
||||
SELECT
|
||||
$1,
|
||||
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id,
|
||||
COALESCE(
|
||||
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
|
||||
'{{}}'::varchar[]
|
||||
# Query varies based on text search backend
|
||||
config = get_config()
|
||||
if config.text_search_extension == "vchord":
|
||||
# VectorChord: manually tokenize and insert search_vector
|
||||
query = f"""
|
||||
WITH input_data AS (
|
||||
SELECT * FROM unnest(
|
||||
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
|
||||
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[]
|
||||
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags_json)
|
||||
)
|
||||
FROM input_data
|
||||
RETURNING id
|
||||
""",
|
||||
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags, search_vector)
|
||||
SELECT
|
||||
$1,
|
||||
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id,
|
||||
COALESCE(
|
||||
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
|
||||
'{{}}'::varchar[]
|
||||
),
|
||||
tokenize(COALESCE(text, '') || ' ' || COALESCE(context, ''), 'llmlingua2')::bm25_catalog.bm25vector
|
||||
FROM input_data
|
||||
RETURNING id
|
||||
"""
|
||||
else: # native or pg_textsearch
|
||||
# Native PostgreSQL: search_vector is GENERATED ALWAYS, don't include it
|
||||
# pg_textsearch: indexes operate on base columns directly, don't populate search_vector
|
||||
query = f"""
|
||||
WITH input_data AS (
|
||||
SELECT * FROM unnest(
|
||||
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
|
||||
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[]
|
||||
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags_json)
|
||||
)
|
||||
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags)
|
||||
SELECT
|
||||
$1,
|
||||
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id,
|
||||
COALESCE(
|
||||
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
|
||||
'{{}}'::varchar[]
|
||||
)
|
||||
FROM input_data
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
results = await conn.fetch(
|
||||
query,
|
||||
bank_id,
|
||||
fact_texts,
|
||||
embeddings,
|
||||
|
||||
@@ -76,11 +76,14 @@ async def retain_batch(
|
||||
duplicate_checker_fn,
|
||||
bank_id: str,
|
||||
contents_dicts: list[RetainContentDict],
|
||||
config,
|
||||
document_id: str | None = None,
|
||||
is_first_batch: bool = True,
|
||||
fact_type_override: str | None = None,
|
||||
confidence_score: float | None = None,
|
||||
document_tags: list[str] | None = None,
|
||||
operation_id: str | None = None,
|
||||
schema: str | None = None,
|
||||
) -> tuple[list[list[str]], TokenUsage]:
|
||||
"""
|
||||
Process a batch of content through the retain pipeline.
|
||||
@@ -94,6 +97,7 @@ async def retain_batch(
|
||||
duplicate_checker_fn: Function to check for duplicate facts
|
||||
bank_id: Bank identifier
|
||||
contents_dicts: List of content dictionaries
|
||||
config: Resolved HindsightConfig for this bank
|
||||
document_id: Optional document ID
|
||||
is_first_batch: Whether this is the first batch
|
||||
fact_type_override: Override fact type for all facts
|
||||
@@ -144,20 +148,38 @@ async def retain_batch(
|
||||
# Step 1: Extract facts from all contents
|
||||
step_start = time.time()
|
||||
|
||||
extracted_facts, chunks, usage = await fact_extraction.extract_facts_from_contents(contents, llm_config, agent_name)
|
||||
extracted_facts, chunks, usage = await fact_extraction.extract_facts_from_contents(
|
||||
contents, llm_config, agent_name, config, pool, operation_id, schema
|
||||
)
|
||||
log_buffer.append(
|
||||
f"[1] Extract facts: {len(extracted_facts)} facts, {len(chunks)} chunks from {len(contents)} contents in {time.time() - step_start:.3f}s"
|
||||
)
|
||||
|
||||
if not extracted_facts:
|
||||
# Still need to create document if document_id was provided
|
||||
# Still need to create document if document_id was provided or chunks exist
|
||||
from collections import defaultdict
|
||||
|
||||
docs_tracked = 0
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
async with conn.transaction():
|
||||
await fact_storage.ensure_bank_exists(conn, bank_id)
|
||||
|
||||
# Handle document tracking even with no facts
|
||||
# Group contents by document_id (consistent with normal path)
|
||||
contents_by_doc_early = defaultdict(list)
|
||||
for idx, content_dict in enumerate(contents_dicts):
|
||||
doc_id = content_dict.get("document_id")
|
||||
contents_by_doc_early[doc_id].append((idx, content_dict))
|
||||
|
||||
if document_id:
|
||||
# Legacy: single document_id parameter
|
||||
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
|
||||
# Collect tags from all content items and merge with document_tags
|
||||
all_tags = set(document_tags or [])
|
||||
for item in contents_dicts:
|
||||
item_tags = item.get("tags", []) or []
|
||||
all_tags.update(item_tags)
|
||||
merged_tags = list(all_tags)
|
||||
|
||||
retain_params = {}
|
||||
if contents_dicts:
|
||||
first_item = contents_dicts[0]
|
||||
@@ -172,40 +194,59 @@ async def retain_batch(
|
||||
if first_item.get("metadata"):
|
||||
retain_params["metadata"] = first_item["metadata"]
|
||||
await fact_storage.handle_document_tracking(
|
||||
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, document_tags
|
||||
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, merged_tags
|
||||
)
|
||||
docs_tracked += 1
|
||||
else:
|
||||
# Check for per-item document_ids
|
||||
from collections import defaultdict
|
||||
# Handle per-item document_ids and/or chunks (mirrors normal path logic)
|
||||
has_any_doc_ids = any(item.get("document_id") for item in contents_dicts)
|
||||
|
||||
contents_by_doc = defaultdict(list)
|
||||
for idx, content_dict in enumerate(contents_dicts):
|
||||
doc_id = content_dict.get("document_id")
|
||||
if doc_id:
|
||||
contents_by_doc[doc_id].append((idx, content_dict))
|
||||
if has_any_doc_ids or chunks:
|
||||
for original_doc_id, doc_contents in contents_by_doc_early.items():
|
||||
should_create_doc = (original_doc_id is not None) or chunks
|
||||
if not should_create_doc:
|
||||
continue
|
||||
|
||||
for doc_id, doc_contents in contents_by_doc.items():
|
||||
combined_content = "\n".join([c.get("content", "") for _, c in doc_contents])
|
||||
retain_params = {}
|
||||
if doc_contents:
|
||||
first_item = doc_contents[0][1]
|
||||
if first_item.get("context"):
|
||||
retain_params["context"] = first_item["context"]
|
||||
if first_item.get("event_date"):
|
||||
retain_params["event_date"] = (
|
||||
first_item["event_date"].isoformat()
|
||||
if hasattr(first_item["event_date"], "isoformat")
|
||||
else str(first_item["event_date"])
|
||||
)
|
||||
if first_item.get("metadata"):
|
||||
retain_params["metadata"] = first_item["metadata"]
|
||||
await fact_storage.handle_document_tracking(
|
||||
conn, bank_id, doc_id, combined_content, is_first_batch, retain_params, document_tags
|
||||
)
|
||||
actual_doc_id = original_doc_id
|
||||
if actual_doc_id is None:
|
||||
# No document_id but have chunks - generate one
|
||||
actual_doc_id = str(uuid.uuid4())
|
||||
|
||||
combined_content = "\n".join([c.get("content", "") for _, c in doc_contents])
|
||||
all_tags = set(document_tags or [])
|
||||
for _, item in doc_contents:
|
||||
item_tags = item.get("tags", []) or []
|
||||
all_tags.update(item_tags)
|
||||
merged_tags = list(all_tags)
|
||||
|
||||
retain_params = {}
|
||||
if doc_contents:
|
||||
first_item = doc_contents[0][1]
|
||||
if first_item.get("context"):
|
||||
retain_params["context"] = first_item["context"]
|
||||
if first_item.get("event_date"):
|
||||
retain_params["event_date"] = (
|
||||
first_item["event_date"].isoformat()
|
||||
if hasattr(first_item["event_date"], "isoformat")
|
||||
else str(first_item["event_date"])
|
||||
)
|
||||
if first_item.get("metadata"):
|
||||
retain_params["metadata"] = first_item["metadata"]
|
||||
await fact_storage.handle_document_tracking(
|
||||
conn,
|
||||
bank_id,
|
||||
actual_doc_id,
|
||||
combined_content,
|
||||
is_first_batch,
|
||||
retain_params,
|
||||
merged_tags,
|
||||
)
|
||||
docs_tracked += 1
|
||||
|
||||
total_time = time.time() - start_time
|
||||
doc_status = f"{docs_tracked} document(s) tracked" if docs_tracked > 0 else "no document tracked"
|
||||
logger.info(
|
||||
f"RETAIN_BATCH COMPLETE: 0 facts extracted from {len(contents)} contents in {total_time:.3f}s (document tracked, no facts)"
|
||||
f"RETAIN_BATCH COMPLETE: 0 facts extracted from {len(contents)} contents in {total_time:.3f}s ({doc_status}, no facts)"
|
||||
)
|
||||
return [[] for _ in contents], usage
|
||||
|
||||
@@ -252,6 +293,13 @@ async def retain_batch(
|
||||
# Legacy: single document_id parameter
|
||||
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
|
||||
retain_params = {}
|
||||
# Collect tags from all content items and merge with document_tags
|
||||
all_tags = set(document_tags or [])
|
||||
for item in contents_dicts:
|
||||
item_tags = item.get("tags", []) or []
|
||||
all_tags.update(item_tags)
|
||||
merged_tags = list(all_tags)
|
||||
|
||||
if contents_dicts:
|
||||
first_item = contents_dicts[0]
|
||||
if first_item.get("context"):
|
||||
@@ -266,7 +314,7 @@ async def retain_batch(
|
||||
retain_params["metadata"] = first_item["metadata"]
|
||||
|
||||
await fact_storage.handle_document_tracking(
|
||||
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, document_tags
|
||||
conn, bank_id, document_id, combined_content, is_first_batch, retain_params, merged_tags
|
||||
)
|
||||
document_ids_added.append(document_id)
|
||||
doc_id_mapping[None] = document_id # For backwards compatibility
|
||||
@@ -294,6 +342,13 @@ async def retain_batch(
|
||||
# Combine content for this document
|
||||
combined_content = "\n".join([c.get("content", "") for _, c in doc_contents])
|
||||
|
||||
# Collect tags from all content items for this document and merge with document_tags
|
||||
all_tags = set(document_tags or [])
|
||||
for _, item in doc_contents:
|
||||
item_tags = item.get("tags", []) or []
|
||||
all_tags.update(item_tags)
|
||||
merged_tags = list(all_tags)
|
||||
|
||||
# Extract retain params from first content item
|
||||
retain_params = {}
|
||||
if doc_contents:
|
||||
@@ -316,7 +371,7 @@ async def retain_batch(
|
||||
combined_content,
|
||||
is_first_batch,
|
||||
retain_params,
|
||||
document_tags,
|
||||
merged_tags,
|
||||
)
|
||||
document_ids_added.append(actual_doc_id)
|
||||
|
||||
|
||||
@@ -13,12 +13,10 @@ from .reranking import CrossEncoderReranker
|
||||
from .retrieval import (
|
||||
ParallelRetrievalResult,
|
||||
get_default_graph_retriever,
|
||||
retrieve_parallel,
|
||||
set_default_graph_retriever,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"retrieve_parallel",
|
||||
"get_default_graph_retriever",
|
||||
"set_default_graph_retriever",
|
||||
"ParallelRetrievalResult",
|
||||
|
||||
@@ -85,116 +85,6 @@ def set_default_graph_retriever(retriever: GraphRetriever) -> None:
|
||||
_default_graph_retriever = retriever
|
||||
|
||||
|
||||
async def retrieve_semantic(
|
||||
conn,
|
||||
query_emb_str: str,
|
||||
bank_id: str,
|
||||
fact_type: str,
|
||||
limit: int,
|
||||
tags: list[str] | None = None,
|
||||
) -> list[RetrievalResult]:
|
||||
"""
|
||||
Semantic retrieval via vector similarity.
|
||||
|
||||
Args:
|
||||
conn: Database connection
|
||||
query_emb_str: Query embedding as string
|
||||
agent_id: bank ID
|
||||
fact_type: Fact type to filter
|
||||
limit: Maximum results to return
|
||||
tags: Optional list of tags for visibility filtering (OR matching)
|
||||
|
||||
Returns:
|
||||
List of RetrievalResult objects
|
||||
"""
|
||||
from .tags import TagsMatch, build_tags_where_clause_simple
|
||||
|
||||
tags_clause = build_tags_where_clause_simple(tags, 5)
|
||||
params = [query_emb_str, bank_id, fact_type, limit]
|
||||
if tags:
|
||||
params.append(tags)
|
||||
|
||||
results = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
AND embedding IS NOT NULL
|
||||
AND fact_type = $3
|
||||
AND (1 - (embedding <=> $1::vector)) >= 0.3
|
||||
{tags_clause}
|
||||
ORDER BY embedding <=> $1::vector
|
||||
LIMIT $4
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
return [RetrievalResult.from_db_row(dict(r)) for r in results]
|
||||
|
||||
|
||||
async def retrieve_bm25(
|
||||
conn,
|
||||
query_text: str,
|
||||
bank_id: str,
|
||||
fact_type: str,
|
||||
limit: int,
|
||||
tags: list[str] | None = None,
|
||||
) -> list[RetrievalResult]:
|
||||
"""
|
||||
BM25 keyword retrieval via full-text search.
|
||||
|
||||
Args:
|
||||
conn: Database connection
|
||||
query_text: Query text
|
||||
agent_id: bank ID
|
||||
fact_type: Fact type to filter
|
||||
limit: Maximum results to return
|
||||
tags: Optional list of tags for visibility filtering (OR matching)
|
||||
|
||||
Returns:
|
||||
List of RetrievalResult objects
|
||||
"""
|
||||
import re
|
||||
|
||||
from .tags import TagsMatch, build_tags_where_clause_simple
|
||||
|
||||
# Sanitize query text: remove special characters that have meaning in tsquery
|
||||
# Keep only alphanumeric characters and spaces
|
||||
sanitized_text = re.sub(r"[^\w\s]", " ", query_text.lower())
|
||||
|
||||
# Split and filter empty strings
|
||||
tokens = [token for token in sanitized_text.split() if token]
|
||||
|
||||
if not tokens:
|
||||
# If no valid tokens, return empty results
|
||||
return []
|
||||
|
||||
# Convert query to tsquery using OR for more flexible matching
|
||||
# This prevents empty results when some terms are missing
|
||||
query_tsquery = " | ".join(tokens)
|
||||
|
||||
tags_clause = build_tags_where_clause_simple(tags, 5)
|
||||
params = [query_tsquery, bank_id, fact_type, limit]
|
||||
if tags:
|
||||
params.append(tags)
|
||||
|
||||
results = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
ts_rank_cd(search_vector, to_tsquery('english', $1)) AS bm25_score
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
AND fact_type = $3
|
||||
AND search_vector @@ to_tsquery('english', $1)
|
||||
{tags_clause}
|
||||
ORDER BY bm25_score DESC
|
||||
LIMIT $4
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
return [RetrievalResult.from_db_row(dict(r)) for r in results]
|
||||
|
||||
|
||||
async def retrieve_semantic_bm25_combined(
|
||||
conn,
|
||||
query_emb_str: str,
|
||||
@@ -268,18 +158,41 @@ async def retrieve_semantic_bm25_combined(
|
||||
result_dict[ft][0].append(RetrievalResult.from_db_row(row))
|
||||
return result_dict
|
||||
|
||||
query_tsquery = " | ".join(tokens)
|
||||
# Build BM25 query based on text search backend
|
||||
config = get_config()
|
||||
|
||||
# Build tags clause - param 6 if tags provided
|
||||
tags_clause = build_tags_where_clause_simple(tags, 6, match=tags_match)
|
||||
params = [query_emb_str, bank_id, fact_types, limit, query_tsquery]
|
||||
|
||||
# Build backend-specific BM25 parts
|
||||
if config.text_search_extension == "vchord":
|
||||
# VectorChord BM25: use <&> operator with to_bm25query and tokenize
|
||||
# Note: VectorChord scores are negative (higher = better, so -1 > -10)
|
||||
bm25_score_expr = "search_vector <&> to_bm25query('idx_memory_units_text_search', tokenize($5, 'llmlingua2'))"
|
||||
bm25_order_by = f"{bm25_score_expr} DESC"
|
||||
bm25_where_filter = "" # No additional WHERE filter for vchord
|
||||
params = [query_emb_str, bank_id, fact_types, limit, query_text] # Pass raw query_text for tokenization
|
||||
elif config.text_search_extension == "pg_textsearch":
|
||||
# Timescale pg_textsearch: use <@> operator with to_bm25query
|
||||
# Note: pg_textsearch scores are negative (lower/more negative = better, so -10 > -1)
|
||||
# We negate the score to maintain API consistency (higher = better)
|
||||
bm25_score_expr = "-(text <@> to_bm25query($5, 'idx_memory_units_text_search'))"
|
||||
bm25_order_by = "text <@> to_bm25query($5, 'idx_memory_units_text_search') ASC"
|
||||
bm25_where_filter = "" # No additional WHERE filter for pg_textsearch
|
||||
params = [query_emb_str, bank_id, fact_types, limit, query_text]
|
||||
else: # native
|
||||
# Native PostgreSQL: use ts_rank_cd with to_tsquery
|
||||
query_tsquery = " | ".join(tokens)
|
||||
bm25_score_expr = "ts_rank_cd(search_vector, to_tsquery('english', $5))"
|
||||
bm25_order_by = f"{bm25_score_expr} DESC"
|
||||
bm25_where_filter = "AND search_vector @@ to_tsquery('english', $5)"
|
||||
params = [query_emb_str, bank_id, fact_types, limit, query_tsquery]
|
||||
|
||||
if tags:
|
||||
params.append(tags)
|
||||
|
||||
# Combined CTE query for both semantic and BM25 across all fact types
|
||||
# Uses window functions to limit per fact_type per method
|
||||
results = await conn.fetch(
|
||||
f"""
|
||||
# Single query template with backend-specific parts injected
|
||||
query = f"""
|
||||
WITH semantic_ranked AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity,
|
||||
@@ -296,13 +209,13 @@ async def retrieve_semantic_bm25_combined(
|
||||
bm25_ranked AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
NULL::float AS similarity,
|
||||
ts_rank_cd(search_vector, to_tsquery('english', $5)) AS bm25_score,
|
||||
{bm25_score_expr} AS bm25_score,
|
||||
'bm25' AS source,
|
||||
ROW_NUMBER() OVER (PARTITION BY fact_type ORDER BY ts_rank_cd(search_vector, to_tsquery('english', $5)) DESC) AS rn
|
||||
ROW_NUMBER() OVER (PARTITION BY fact_type ORDER BY {bm25_order_by}) AS rn
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
AND fact_type = ANY($3)
|
||||
AND search_vector @@ to_tsquery('english', $5)
|
||||
{bm25_where_filter}
|
||||
{tags_clause}
|
||||
),
|
||||
semantic AS (
|
||||
@@ -318,9 +231,11 @@ async def retrieve_semantic_bm25_combined(
|
||||
SELECT * FROM semantic
|
||||
UNION ALL
|
||||
SELECT * FROM bm25
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
"""
|
||||
|
||||
# Combined CTE query for both semantic and BM25 across all fact types
|
||||
# Uses window functions to limit per fact_type per method
|
||||
results = await conn.fetch(query, *params)
|
||||
|
||||
# Group results by fact_type and source
|
||||
result_dict: dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]] = {ft: ([], []) for ft in fact_types}
|
||||
@@ -561,623 +476,6 @@ async def retrieve_temporal_combined(
|
||||
return results_by_ft
|
||||
|
||||
|
||||
async def retrieve_temporal(
|
||||
conn,
|
||||
query_emb_str: str,
|
||||
bank_id: str,
|
||||
fact_type: str,
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
budget: int,
|
||||
semantic_threshold: float = 0.1,
|
||||
tags: list[str] | None = None,
|
||||
) -> list[RetrievalResult]:
|
||||
"""
|
||||
Temporal retrieval with spreading activation.
|
||||
|
||||
Strategy:
|
||||
1. Find entry points (facts in date range with semantic relevance)
|
||||
2. Spread through temporal links to related facts
|
||||
3. Score by temporal proximity + semantic similarity + link weight
|
||||
|
||||
Args:
|
||||
conn: Database connection
|
||||
query_emb_str: Query embedding as string
|
||||
agent_id: bank ID
|
||||
fact_type: Fact type to filter
|
||||
start_date: Start of time range
|
||||
end_date: End of time range
|
||||
budget: Node budget for spreading
|
||||
semantic_threshold: Minimum semantic similarity to include
|
||||
tags: Optional list of tags for visibility filtering (OR matching)
|
||||
|
||||
Returns:
|
||||
List of RetrievalResult objects with temporal scores
|
||||
"""
|
||||
|
||||
# Ensure start_date and end_date are timezone-aware (UTC) to match database datetimes
|
||||
if start_date.tzinfo is None:
|
||||
start_date = start_date.replace(tzinfo=UTC)
|
||||
if end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=UTC)
|
||||
|
||||
from .tags import TagsMatch, build_tags_where_clause_simple
|
||||
|
||||
tags_clause = build_tags_where_clause_simple(tags, 7)
|
||||
params = [query_emb_str, bank_id, fact_type, start_date, end_date, semantic_threshold]
|
||||
if tags:
|
||||
params.append(tags)
|
||||
|
||||
entry_points = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
AND fact_type = $3
|
||||
AND embedding IS NOT NULL
|
||||
AND (
|
||||
-- Match if occurred range overlaps with query range
|
||||
(occurred_start IS NOT NULL AND occurred_end IS NOT NULL
|
||||
AND occurred_start <= $5 AND occurred_end >= $4)
|
||||
OR
|
||||
-- Match if mentioned_at falls within query range
|
||||
(mentioned_at IS NOT NULL AND mentioned_at BETWEEN $4 AND $5)
|
||||
OR
|
||||
-- Match if any occurred date is set and overlaps (even if only start or end is set)
|
||||
(occurred_start IS NOT NULL AND occurred_start BETWEEN $4 AND $5)
|
||||
OR
|
||||
(occurred_end IS NOT NULL AND occurred_end BETWEEN $4 AND $5)
|
||||
)
|
||||
AND (1 - (embedding <=> $1::vector)) >= $6
|
||||
{tags_clause}
|
||||
ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC, (embedding <=> $1::vector) ASC
|
||||
LIMIT 10
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
|
||||
if not entry_points:
|
||||
return []
|
||||
|
||||
# Calculate temporal scores for entry points
|
||||
total_days = (end_date - start_date).total_seconds() / 86400
|
||||
mid_date = start_date + (end_date - start_date) / 2 # Calculate once for all comparisons
|
||||
results = []
|
||||
visited = set()
|
||||
|
||||
for ep in entry_points:
|
||||
unit_id = str(ep["id"])
|
||||
visited.add(unit_id)
|
||||
|
||||
# Calculate temporal proximity using the most relevant date
|
||||
# Priority: occurred_start/end (event time) > mentioned_at (mention time)
|
||||
best_date = None
|
||||
if ep["occurred_start"] is not None and ep["occurred_end"] is not None:
|
||||
# Use midpoint of occurred range
|
||||
best_date = ep["occurred_start"] + (ep["occurred_end"] - ep["occurred_start"]) / 2
|
||||
elif ep["occurred_start"] is not None:
|
||||
best_date = ep["occurred_start"]
|
||||
elif ep["occurred_end"] is not None:
|
||||
best_date = ep["occurred_end"]
|
||||
elif ep["mentioned_at"] is not None:
|
||||
best_date = ep["mentioned_at"]
|
||||
|
||||
# Temporal proximity score (closer to range center = higher score)
|
||||
if best_date:
|
||||
days_from_mid = abs((best_date - mid_date).total_seconds() / 86400)
|
||||
temporal_proximity = 1.0 - min(days_from_mid / (total_days / 2), 1.0) if total_days > 0 else 1.0
|
||||
else:
|
||||
temporal_proximity = 0.5 # Fallback if no dates (shouldn't happen due to WHERE clause)
|
||||
|
||||
# Create RetrievalResult with temporal scores
|
||||
ep_result = RetrievalResult.from_db_row(dict(ep))
|
||||
ep_result.temporal_score = temporal_proximity
|
||||
ep_result.temporal_proximity = temporal_proximity
|
||||
results.append(ep_result)
|
||||
|
||||
# Spread through temporal links using BATCHED neighbor fetching
|
||||
# Map node_id -> (semantic_sim, temporal_score) for propagation
|
||||
node_scores = {str(ep["id"]): (ep["similarity"], 1.0) for ep in entry_points}
|
||||
frontier = list(node_scores.keys()) # Current batch of nodes to expand
|
||||
budget_remaining = budget - len(entry_points)
|
||||
batch_size = 20 # Process this many nodes per DB query
|
||||
|
||||
while frontier and budget_remaining > 0:
|
||||
# Take a batch from frontier
|
||||
batch_ids = frontier[:batch_size]
|
||||
frontier = frontier[batch_size:]
|
||||
|
||||
# Batch fetch all neighbors for this batch of nodes
|
||||
neighbors = await conn.fetch(
|
||||
f"""
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id,
|
||||
ml.weight, ml.link_type, ml.from_unit_id,
|
||||
1 - (mu.embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_links")} ml
|
||||
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
|
||||
WHERE ml.from_unit_id = ANY($2::uuid[])
|
||||
AND ml.link_type IN ('temporal', 'causes', 'caused_by', 'enables', 'prevents')
|
||||
AND ml.weight >= 0.1
|
||||
AND mu.fact_type = $3
|
||||
AND mu.embedding IS NOT NULL
|
||||
AND (1 - (mu.embedding <=> $1::vector)) >= $4
|
||||
ORDER BY ml.weight DESC
|
||||
LIMIT $5
|
||||
""",
|
||||
query_emb_str,
|
||||
batch_ids,
|
||||
fact_type,
|
||||
semantic_threshold,
|
||||
batch_size * 10, # Allow up to 10 neighbors per node in batch
|
||||
)
|
||||
|
||||
for n in neighbors:
|
||||
neighbor_id = str(n["id"])
|
||||
if neighbor_id in visited:
|
||||
continue
|
||||
|
||||
visited.add(neighbor_id)
|
||||
budget_remaining -= 1
|
||||
|
||||
# Get parent's scores for propagation
|
||||
parent_id = str(n["from_unit_id"])
|
||||
_, parent_temporal_score = node_scores.get(parent_id, (0.5, 0.5))
|
||||
|
||||
# Calculate temporal score for neighbor using best available date
|
||||
neighbor_best_date = None
|
||||
if n["occurred_start"] is not None and n["occurred_end"] is not None:
|
||||
neighbor_best_date = n["occurred_start"] + (n["occurred_end"] - n["occurred_start"]) / 2
|
||||
elif n["occurred_start"] is not None:
|
||||
neighbor_best_date = n["occurred_start"]
|
||||
elif n["occurred_end"] is not None:
|
||||
neighbor_best_date = n["occurred_end"]
|
||||
elif n["mentioned_at"] is not None:
|
||||
neighbor_best_date = n["mentioned_at"]
|
||||
|
||||
if neighbor_best_date:
|
||||
days_from_mid = abs((neighbor_best_date - mid_date).total_seconds() / 86400)
|
||||
neighbor_temporal_proximity = (
|
||||
1.0 - min(days_from_mid / (total_days / 2), 1.0) if total_days > 0 else 1.0
|
||||
)
|
||||
else:
|
||||
neighbor_temporal_proximity = 0.3 # Lower score if no temporal data
|
||||
|
||||
# Boost causal links (same as graph retrieval)
|
||||
link_type = n["link_type"]
|
||||
if link_type in ("causes", "caused_by"):
|
||||
causal_boost = 2.0
|
||||
elif link_type in ("enables", "prevents"):
|
||||
causal_boost = 1.5
|
||||
else:
|
||||
causal_boost = 1.0
|
||||
|
||||
# Propagate temporal score through links (decay, with causal boost)
|
||||
propagated_temporal = parent_temporal_score * n["weight"] * causal_boost * 0.7
|
||||
|
||||
# Combined temporal score
|
||||
combined_temporal = max(neighbor_temporal_proximity, propagated_temporal)
|
||||
|
||||
# Create RetrievalResult with temporal scores
|
||||
neighbor_result = RetrievalResult.from_db_row(dict(n))
|
||||
neighbor_result.temporal_score = combined_temporal
|
||||
neighbor_result.temporal_proximity = neighbor_temporal_proximity
|
||||
results.append(neighbor_result)
|
||||
|
||||
# Track scores for propagation and add to frontier
|
||||
if budget_remaining > 0 and combined_temporal > 0.2:
|
||||
node_scores[neighbor_id] = (n["similarity"], combined_temporal)
|
||||
frontier.append(neighbor_id)
|
||||
|
||||
if budget_remaining <= 0:
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def retrieve_parallel(
|
||||
pool,
|
||||
query_text: str,
|
||||
query_embedding_str: str,
|
||||
bank_id: str,
|
||||
fact_type: str,
|
||||
thinking_budget: int,
|
||||
question_date: datetime | None = None,
|
||||
query_analyzer: Optional["QueryAnalyzer"] = None,
|
||||
graph_retriever: GraphRetriever | None = None,
|
||||
temporal_constraint: tuple | None = None, # Pre-extracted temporal constraint
|
||||
tags: list[str] | None = None, # Visibility scope tags for filtering
|
||||
) -> ParallelRetrievalResult:
|
||||
"""
|
||||
Run 3-way or 4-way parallel retrieval (adds temporal if detected).
|
||||
|
||||
Args:
|
||||
pool: Database connection pool
|
||||
query_text: Query text
|
||||
query_embedding_str: Query embedding as string
|
||||
bank_id: Bank ID
|
||||
fact_type: Fact type to filter
|
||||
thinking_budget: Budget for graph traversal and retrieval limits
|
||||
question_date: Optional date when question was asked (for temporal filtering)
|
||||
query_analyzer: Query analyzer to use (defaults to TransformerQueryAnalyzer)
|
||||
graph_retriever: Graph retrieval strategy (defaults to configured retriever)
|
||||
temporal_constraint: Pre-extracted temporal constraint (optional)
|
||||
tags: Optional list of tags for visibility filtering (OR matching)
|
||||
|
||||
Returns:
|
||||
ParallelRetrievalResult with semantic, bm25, graph, temporal results and timings
|
||||
"""
|
||||
retriever = graph_retriever or get_default_graph_retriever()
|
||||
|
||||
# Use optimized parallel path for MPFP and LinkExpansion (runs all methods truly in parallel)
|
||||
# BFS uses legacy path that extracts temporal constraint upfront
|
||||
if retriever.name in ("mpfp", "link_expansion"):
|
||||
return await _retrieve_parallel_mpfp(
|
||||
pool,
|
||||
query_text,
|
||||
query_embedding_str,
|
||||
bank_id,
|
||||
fact_type,
|
||||
thinking_budget,
|
||||
temporal_constraint,
|
||||
retriever,
|
||||
question_date,
|
||||
query_analyzer,
|
||||
tags=tags,
|
||||
)
|
||||
else:
|
||||
# For BFS, extract temporal constraint upfront (legacy path)
|
||||
if temporal_constraint is None:
|
||||
from .temporal_extraction import extract_temporal_constraint
|
||||
|
||||
temporal_constraint = extract_temporal_constraint(
|
||||
query_text, reference_date=question_date, analyzer=query_analyzer
|
||||
)
|
||||
return await _retrieve_parallel_bfs(
|
||||
pool,
|
||||
query_text,
|
||||
query_embedding_str,
|
||||
bank_id,
|
||||
fact_type,
|
||||
thinking_budget,
|
||||
temporal_constraint,
|
||||
retriever,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TimedResult:
|
||||
"""Internal result with timing."""
|
||||
|
||||
results: list[RetrievalResult]
|
||||
time: float
|
||||
conn_wait: float = 0.0 # Connection acquisition wait time
|
||||
|
||||
|
||||
async def _retrieve_parallel_mpfp(
|
||||
pool,
|
||||
query_text: str,
|
||||
query_embedding_str: str,
|
||||
bank_id: str,
|
||||
fact_type: str,
|
||||
thinking_budget: int,
|
||||
temporal_constraint: tuple | None,
|
||||
retriever: GraphRetriever,
|
||||
question_date: datetime | None = None,
|
||||
query_analyzer=None,
|
||||
tags: list[str] | None = None,
|
||||
) -> ParallelRetrievalResult:
|
||||
"""
|
||||
MPFP retrieval with true parallelization.
|
||||
|
||||
All methods run independently in parallel:
|
||||
- Semantic: vector similarity search
|
||||
- BM25: keyword search
|
||||
- Graph: MPFP traversal (does its own semantic seeds internally)
|
||||
- Temporal: date extraction (if needed) + date-range search
|
||||
|
||||
Temporal extraction runs IN PARALLEL with other retrievals, so even if
|
||||
dateparser is slow, it doesn't block semantic/BM25/graph.
|
||||
"""
|
||||
import time
|
||||
|
||||
async def run_semantic() -> _TimedResult:
|
||||
"""Independent semantic retrieval."""
|
||||
start = time.time()
|
||||
acquire_start = time.time()
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
conn_wait = time.time() - acquire_start
|
||||
results = await retrieve_semantic(
|
||||
conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget, tags=tags
|
||||
)
|
||||
return _TimedResult(results, time.time() - start, conn_wait)
|
||||
|
||||
async def run_bm25() -> _TimedResult:
|
||||
"""Independent BM25 retrieval."""
|
||||
start = time.time()
|
||||
acquire_start = time.time()
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
conn_wait = time.time() - acquire_start
|
||||
results = await retrieve_bm25(conn, query_text, bank_id, fact_type, limit=thinking_budget, tags=tags)
|
||||
return _TimedResult(results, time.time() - start, conn_wait)
|
||||
|
||||
async def run_graph() -> tuple[list[RetrievalResult], float, MPFPTimings | None]:
|
||||
"""Independent graph retrieval - does its own semantic seeds."""
|
||||
start = time.time()
|
||||
|
||||
# MPFP does its own semantic seeds via _find_semantic_seeds
|
||||
# Note: temporal_seeds not used here to avoid dependency on temporal extraction
|
||||
results, mpfp_timing = await retriever.retrieve(
|
||||
pool=pool,
|
||||
query_embedding_str=query_embedding_str,
|
||||
bank_id=bank_id,
|
||||
fact_type=fact_type,
|
||||
budget=thinking_budget,
|
||||
query_text=query_text,
|
||||
semantic_seeds=None, # Let MPFP find its own seeds
|
||||
temporal_seeds=None, # Don't wait for temporal extraction
|
||||
tags=tags,
|
||||
)
|
||||
return results, time.time() - start, mpfp_timing
|
||||
|
||||
@dataclass
|
||||
class _TemporalWithConstraint:
|
||||
"""Temporal results with the extracted constraint."""
|
||||
|
||||
results: list[RetrievalResult]
|
||||
time: float
|
||||
constraint: tuple | None
|
||||
extraction_time: float # Time spent in query analyzer (dateparser)
|
||||
conn_wait: float = 0.0 # Connection acquisition wait time
|
||||
|
||||
async def run_temporal_with_extraction() -> _TemporalWithConstraint:
|
||||
"""
|
||||
Extract temporal constraint AND run temporal retrieval.
|
||||
|
||||
This runs in parallel with semantic/BM25/graph, so dateparser
|
||||
latency doesn't block other retrievals.
|
||||
"""
|
||||
start = time.time()
|
||||
|
||||
# Use pre-provided constraint if available
|
||||
tc = temporal_constraint
|
||||
extraction_time = 0.0
|
||||
|
||||
# Otherwise extract from query (this is the potentially slow dateparser call)
|
||||
if tc is None:
|
||||
from .temporal_extraction import extract_temporal_constraint
|
||||
|
||||
extraction_start = time.time()
|
||||
tc = extract_temporal_constraint(query_text, reference_date=question_date, analyzer=query_analyzer)
|
||||
extraction_time = time.time() - extraction_start
|
||||
|
||||
# If no temporal constraint found, return empty (but still report extraction time)
|
||||
if tc is None:
|
||||
return _TemporalWithConstraint([], time.time() - start, None, extraction_time, 0.0)
|
||||
|
||||
# Run temporal retrieval with the extracted constraint
|
||||
tc_start, tc_end = tc
|
||||
acquire_start = time.time()
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
conn_wait = time.time() - acquire_start
|
||||
results = await retrieve_temporal(
|
||||
conn,
|
||||
query_embedding_str,
|
||||
bank_id,
|
||||
fact_type,
|
||||
tc_start,
|
||||
tc_end,
|
||||
budget=thinking_budget,
|
||||
semantic_threshold=0.1,
|
||||
)
|
||||
return _TemporalWithConstraint(results, time.time() - start, tc, extraction_time, conn_wait)
|
||||
|
||||
# Run ALL methods in parallel (including temporal extraction!)
|
||||
semantic_result, bm25_result, graph_result, temporal_result = await asyncio.gather(
|
||||
run_semantic(),
|
||||
run_bm25(),
|
||||
run_graph(),
|
||||
run_temporal_with_extraction(),
|
||||
)
|
||||
graph_results, graph_time, mpfp_timing = graph_result
|
||||
|
||||
# Compute max connection wait across all methods (graph handles its own connections)
|
||||
max_conn_wait = max(semantic_result.conn_wait, bm25_result.conn_wait, temporal_result.conn_wait)
|
||||
|
||||
return ParallelRetrievalResult(
|
||||
semantic=semantic_result.results,
|
||||
bm25=bm25_result.results,
|
||||
graph=graph_results,
|
||||
temporal=temporal_result.results if temporal_result.results else None,
|
||||
timings={
|
||||
"semantic": semantic_result.time,
|
||||
"bm25": bm25_result.time,
|
||||
"graph": graph_time,
|
||||
"temporal": temporal_result.time,
|
||||
"temporal_extraction": temporal_result.extraction_time,
|
||||
},
|
||||
temporal_constraint=temporal_result.constraint,
|
||||
mpfp_timings=[mpfp_timing] if mpfp_timing else [],
|
||||
max_conn_wait=max_conn_wait,
|
||||
)
|
||||
|
||||
|
||||
async def _get_temporal_entry_points(
|
||||
conn,
|
||||
query_embedding_str: str,
|
||||
bank_id: str,
|
||||
fact_type: str,
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
limit: int = 20,
|
||||
semantic_threshold: float = 0.1,
|
||||
) -> list[RetrievalResult]:
|
||||
"""Get temporal entry points (facts in date range with semantic relevance)."""
|
||||
|
||||
if start_date.tzinfo is None:
|
||||
start_date = start_date.replace(tzinfo=UTC)
|
||||
if end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=UTC)
|
||||
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
embedding, fact_type, document_id, chunk_id,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
AND fact_type = $3
|
||||
AND embedding IS NOT NULL
|
||||
AND (
|
||||
(occurred_start IS NOT NULL AND occurred_end IS NOT NULL
|
||||
AND occurred_start <= $5 AND occurred_end >= $4)
|
||||
OR (mentioned_at IS NOT NULL AND mentioned_at BETWEEN $4 AND $5)
|
||||
OR (occurred_start IS NOT NULL AND occurred_start BETWEEN $4 AND $5)
|
||||
OR (occurred_end IS NOT NULL AND occurred_end BETWEEN $4 AND $5)
|
||||
)
|
||||
AND (1 - (embedding <=> $1::vector)) >= $6
|
||||
ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC,
|
||||
(embedding <=> $1::vector) ASC
|
||||
LIMIT $7
|
||||
""",
|
||||
query_embedding_str,
|
||||
bank_id,
|
||||
fact_type,
|
||||
start_date,
|
||||
end_date,
|
||||
semantic_threshold,
|
||||
limit,
|
||||
)
|
||||
|
||||
results = []
|
||||
total_days = max((end_date - start_date).total_seconds() / 86400, 1)
|
||||
mid_date = start_date + (end_date - start_date) / 2
|
||||
|
||||
for row in rows:
|
||||
result = RetrievalResult.from_db_row(dict(row))
|
||||
|
||||
# Calculate temporal proximity score
|
||||
best_date = None
|
||||
if row["occurred_start"] and row["occurred_end"]:
|
||||
best_date = row["occurred_start"] + (row["occurred_end"] - row["occurred_start"]) / 2
|
||||
elif row["occurred_start"]:
|
||||
best_date = row["occurred_start"]
|
||||
elif row["occurred_end"]:
|
||||
best_date = row["occurred_end"]
|
||||
elif row["mentioned_at"]:
|
||||
best_date = row["mentioned_at"]
|
||||
|
||||
if best_date:
|
||||
days_from_mid = abs((best_date - mid_date).total_seconds() / 86400)
|
||||
result.temporal_proximity = 1.0 - min(days_from_mid / (total_days / 2), 1.0)
|
||||
else:
|
||||
result.temporal_proximity = 0.5
|
||||
|
||||
result.temporal_score = result.temporal_proximity
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def _retrieve_parallel_bfs(
|
||||
pool,
|
||||
query_text: str,
|
||||
query_embedding_str: str,
|
||||
bank_id: str,
|
||||
fact_type: str,
|
||||
thinking_budget: int,
|
||||
temporal_constraint: tuple | None,
|
||||
retriever: GraphRetriever,
|
||||
tags: list[str] | None = None,
|
||||
) -> ParallelRetrievalResult:
|
||||
"""BFS retrieval: all methods run in parallel (original behavior)."""
|
||||
import time
|
||||
|
||||
async def run_semantic() -> _TimedResult:
|
||||
start = time.time()
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
results = await retrieve_semantic(
|
||||
conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget, tags=tags
|
||||
)
|
||||
return _TimedResult(results, time.time() - start)
|
||||
|
||||
async def run_bm25() -> _TimedResult:
|
||||
start = time.time()
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
results = await retrieve_bm25(conn, query_text, bank_id, fact_type, limit=thinking_budget, tags=tags)
|
||||
return _TimedResult(results, time.time() - start)
|
||||
|
||||
async def run_graph() -> _TimedResult:
|
||||
start = time.time()
|
||||
results, _ = await retriever.retrieve(
|
||||
pool=pool,
|
||||
query_embedding_str=query_embedding_str,
|
||||
bank_id=bank_id,
|
||||
fact_type=fact_type,
|
||||
budget=thinking_budget,
|
||||
query_text=query_text,
|
||||
tags=tags,
|
||||
)
|
||||
return _TimedResult(results, time.time() - start)
|
||||
|
||||
async def run_temporal(tc_start, tc_end) -> _TimedResult:
|
||||
start = time.time()
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
results = await retrieve_temporal(
|
||||
conn,
|
||||
query_embedding_str,
|
||||
bank_id,
|
||||
fact_type,
|
||||
tc_start,
|
||||
tc_end,
|
||||
budget=thinking_budget,
|
||||
semantic_threshold=0.1,
|
||||
tags=tags,
|
||||
)
|
||||
return _TimedResult(results, time.time() - start)
|
||||
|
||||
if temporal_constraint:
|
||||
tc_start, tc_end = temporal_constraint
|
||||
semantic_r, bm25_r, graph_r, temporal_r = await asyncio.gather(
|
||||
run_semantic(),
|
||||
run_bm25(),
|
||||
run_graph(),
|
||||
run_temporal(tc_start, tc_end),
|
||||
)
|
||||
return ParallelRetrievalResult(
|
||||
semantic=semantic_r.results,
|
||||
bm25=bm25_r.results,
|
||||
graph=graph_r.results,
|
||||
temporal=temporal_r.results,
|
||||
timings={
|
||||
"semantic": semantic_r.time,
|
||||
"bm25": bm25_r.time,
|
||||
"graph": graph_r.time,
|
||||
"temporal": temporal_r.time,
|
||||
},
|
||||
temporal_constraint=temporal_constraint,
|
||||
)
|
||||
else:
|
||||
semantic_r, bm25_r, graph_r = await asyncio.gather(
|
||||
run_semantic(),
|
||||
run_bm25(),
|
||||
run_graph(),
|
||||
)
|
||||
return ParallelRetrievalResult(
|
||||
semantic=semantic_r.results,
|
||||
bm25=bm25_r.results,
|
||||
graph=graph_r.results,
|
||||
temporal=None,
|
||||
timings={
|
||||
"semantic": semantic_r.time,
|
||||
"bm25": bm25_r.time,
|
||||
"graph": graph_r.time,
|
||||
},
|
||||
temporal_constraint=None,
|
||||
)
|
||||
|
||||
|
||||
async def retrieve_all_fact_types_parallel(
|
||||
pool,
|
||||
query_text: str,
|
||||
|
||||
@@ -188,7 +188,7 @@ def get_system_message(disposition: DispositionTraits) -> str:
|
||||
" ".join(instructions) if instructions else "Balance your disposition traits when interpreting information."
|
||||
)
|
||||
|
||||
return f"You are a person with your own thoughts, experiences, opinions, and disposition. When asked a question, respond naturally like a human would - share what you genuinely think based on what you know and have experienced. {disposition_instruction} Be direct, express your views confidently, and use 'I think', 'I believe', 'in my view', etc. Respond in plain text without markdown formatting. IMPORTANT: Detect the language of the question and respond in the SAME language. Do not translate to English if the question is in another language."
|
||||
return f"You are a person with your own thoughts, experiences, opinions, and disposition. When asked a question, respond naturally like a human would - share what you genuinely think based on what you know and have experienced. {disposition_instruction} Be direct, express your views confidently, and use 'I think', 'I believe', 'in my view', etc. Respond in plain text without markdown formatting. CRITICAL: ONLY use the facts and information provided in the prompt - do not make up names, events, or information that weren't mentioned. If you don't have enough information to answer, say so. IMPORTANT: Detect the language of the question and respond in the SAME language. Do not translate to English if the question is in another language."
|
||||
|
||||
|
||||
async def reflect(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""File storage backends for uploaded files."""
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from .base import FileStorage
|
||||
from .postgresql import PostgreSQLFileStorage
|
||||
|
||||
__all__ = ["FileStorage", "PostgreSQLFileStorage", "create_file_storage"]
|
||||
|
||||
|
||||
def create_file_storage(
|
||||
storage_type: str,
|
||||
pool_getter: Callable | None = None,
|
||||
schema: str | None = None,
|
||||
**kwargs,
|
||||
) -> FileStorage:
|
||||
"""
|
||||
Create file storage backend based on configuration.
|
||||
|
||||
Args:
|
||||
storage_type: "native" (PostgreSQL BYTEA) or "s3" (S3-compatible object storage)
|
||||
pool_getter: Database pool getter (required for native)
|
||||
schema: Database schema (for native multi-tenant)
|
||||
**kwargs: Additional args passed to storage backend
|
||||
|
||||
Returns:
|
||||
FileStorage instance
|
||||
|
||||
Raises:
|
||||
ValueError: If storage_type is unknown or required args are missing
|
||||
"""
|
||||
if storage_type == "native":
|
||||
if not pool_getter:
|
||||
raise ValueError("pool_getter required for native (PostgreSQL) storage")
|
||||
return PostgreSQLFileStorage(pool_getter=pool_getter, schema=schema)
|
||||
elif storage_type == "s3":
|
||||
from ...config import get_config
|
||||
from .s3 import S3FileStorage
|
||||
|
||||
config = get_config()
|
||||
bucket = config.file_storage_s3_bucket
|
||||
if not bucket:
|
||||
raise ValueError("HINDSIGHT_API_FILE_STORAGE_S3_BUCKET is required for S3 storage")
|
||||
return S3FileStorage(
|
||||
bucket=bucket,
|
||||
region=config.file_storage_s3_region,
|
||||
endpoint=config.file_storage_s3_endpoint,
|
||||
access_key_id=config.file_storage_s3_access_key_id,
|
||||
secret_access_key=config.file_storage_s3_secret_access_key,
|
||||
)
|
||||
elif storage_type == "gcs":
|
||||
from ...config import get_config
|
||||
from .gcs import GCSFileStorage
|
||||
|
||||
config = get_config()
|
||||
bucket = config.file_storage_gcs_bucket
|
||||
if not bucket:
|
||||
raise ValueError("HINDSIGHT_API_FILE_STORAGE_GCS_BUCKET is required for GCS storage")
|
||||
return GCSFileStorage(
|
||||
bucket=bucket,
|
||||
service_account_key=config.file_storage_gcs_service_account_key,
|
||||
)
|
||||
elif storage_type == "azure":
|
||||
from ...config import get_config
|
||||
from .azure import AzureFileStorage
|
||||
|
||||
config = get_config()
|
||||
container = config.file_storage_azure_container
|
||||
if not container:
|
||||
raise ValueError("HINDSIGHT_API_FILE_STORAGE_AZURE_CONTAINER is required for Azure storage")
|
||||
return AzureFileStorage(
|
||||
container_name=container,
|
||||
account_name=config.file_storage_azure_account_name,
|
||||
account_key=config.file_storage_azure_account_key,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown storage type: {storage_type}. Supported: 'native', 's3', 'gcs', 'azure'.")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Azure Blob Storage backend using obstore."""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import obstore as obs
|
||||
from obstore.store import AzureStore
|
||||
|
||||
from .base import FileStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AzureFileStorage(FileStorage):
|
||||
"""
|
||||
Azure Blob Storage backend.
|
||||
|
||||
Uses obstore (Rust-backed) for high-throughput async access to Azure Blob Storage.
|
||||
Supports account key, SAS token, and default Azure credentials.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
container_name: str,
|
||||
account_name: str | None = None,
|
||||
account_key: str | None = None,
|
||||
):
|
||||
kwargs: dict = {}
|
||||
if account_name:
|
||||
kwargs["account_name"] = account_name
|
||||
if account_key:
|
||||
kwargs["account_key"] = account_key
|
||||
|
||||
self._store = AzureStore(container_name, **kwargs)
|
||||
logger.info(f"Initialized Azure file storage: container={container_name}, account={account_name}")
|
||||
|
||||
async def store(self, file_data: bytes, key: str, metadata: dict[str, str] | None = None) -> str:
|
||||
await obs.put_async(self._store, key, file_data)
|
||||
logger.debug(f"Stored file {key} ({len(file_data)} bytes) in Azure")
|
||||
return key
|
||||
|
||||
async def retrieve(self, key: str) -> bytes:
|
||||
try:
|
||||
response = await obs.get_async(self._store, key)
|
||||
return await response.bytes_async()
|
||||
except Exception as e:
|
||||
if "not found" in str(e).lower() or "BlobNotFound" in str(e):
|
||||
raise FileNotFoundError(f"File not found: {key}") from e
|
||||
raise
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
await obs.delete_async(self._store, key)
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
try:
|
||||
await obs.head_async(self._store, key)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def get_download_url(self, key: str, expires_in: int = 3600) -> str:
|
||||
return await obs.sign_async(self._store, "GET", key, timedelta(seconds=expires_in))
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Abstract base class for file storage backends."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class FileStorage(ABC):
|
||||
"""Abstract base for file storage backends."""
|
||||
|
||||
@abstractmethod
|
||||
async def store(
|
||||
self,
|
||||
file_data: bytes,
|
||||
key: str,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Store file and return storage key.
|
||||
|
||||
Args:
|
||||
file_data: Raw file bytes
|
||||
key: Storage key (e.g., "banks/{bank_id}/files/{file_id}.pdf")
|
||||
metadata: Optional metadata to store with file
|
||||
|
||||
Returns:
|
||||
Storage key that can be used to retrieve the file
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def retrieve(self, key: str) -> bytes:
|
||||
"""
|
||||
Retrieve file by storage key.
|
||||
|
||||
Args:
|
||||
key: Storage key
|
||||
|
||||
Returns:
|
||||
File data as bytes
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If file does not exist
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, key: str) -> None:
|
||||
"""
|
||||
Delete file by storage key.
|
||||
|
||||
Args:
|
||||
key: Storage key
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def exists(self, key: str) -> bool:
|
||||
"""
|
||||
Check if file exists.
|
||||
|
||||
Args:
|
||||
key: Storage key
|
||||
|
||||
Returns:
|
||||
True if file exists, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def get_download_url(self, key: str, expires_in: int = 3600) -> str:
|
||||
"""
|
||||
Get a URL for downloading the file.
|
||||
|
||||
For PostgreSQL storage, this might be a relative API path.
|
||||
For S3, this would be a pre-signed URL.
|
||||
|
||||
Args:
|
||||
key: Storage key
|
||||
expires_in: Expiration time in seconds (may be ignored for some backends)
|
||||
|
||||
Returns:
|
||||
Download URL or path
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Google Cloud Storage backend using obstore."""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import obstore as obs
|
||||
from obstore.store import GCSStore
|
||||
|
||||
from .base import FileStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GCSFileStorage(FileStorage):
|
||||
"""
|
||||
Google Cloud Storage backend.
|
||||
|
||||
Uses obstore (Rust-backed) for high-throughput async access to GCS.
|
||||
Supports Application Default Credentials, service account keys, and explicit credentials.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bucket: str,
|
||||
service_account_key: str | None = None,
|
||||
):
|
||||
kwargs: dict = {}
|
||||
if service_account_key:
|
||||
kwargs["service_account_key"] = service_account_key
|
||||
|
||||
self._store = GCSStore(bucket, **kwargs)
|
||||
logger.info(f"Initialized GCS file storage: bucket={bucket}")
|
||||
|
||||
async def store(self, file_data: bytes, key: str, metadata: dict[str, str] | None = None) -> str:
|
||||
await obs.put_async(self._store, key, file_data)
|
||||
logger.debug(f"Stored file {key} ({len(file_data)} bytes) in GCS")
|
||||
return key
|
||||
|
||||
async def retrieve(self, key: str) -> bytes:
|
||||
try:
|
||||
response = await obs.get_async(self._store, key)
|
||||
return await response.bytes_async()
|
||||
except Exception as e:
|
||||
if "not found" in str(e).lower():
|
||||
raise FileNotFoundError(f"File not found: {key}") from e
|
||||
raise
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
await obs.delete_async(self._store, key)
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
try:
|
||||
await obs.head_async(self._store, key)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def get_download_url(self, key: str, expires_in: int = 3600) -> str:
|
||||
return await obs.sign_async(self._store, "GET", key, timedelta(seconds=expires_in))
|
||||
@@ -0,0 +1,139 @@
|
||||
"""PostgreSQL BYTEA-based file storage (default, zero-config)."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import asyncpg
|
||||
|
||||
from .base import FileStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fq_table(table: str, schema: str | None = None) -> str:
|
||||
"""Get fully-qualified table name with optional schema prefix."""
|
||||
if schema:
|
||||
return f'"{schema}".{table}'
|
||||
return table
|
||||
|
||||
|
||||
class PostgreSQLFileStorage(FileStorage):
|
||||
"""
|
||||
PostgreSQL BYTEA-based file storage.
|
||||
|
||||
Stores files directly in PostgreSQL using BYTEA columns.
|
||||
This is the default storage backend - zero configuration required!
|
||||
|
||||
Pros:
|
||||
- Works out of the box (no external dependencies)
|
||||
- Transactional consistency with database
|
||||
- Simple backups (included in pg_dump)
|
||||
- Good performance for <10MB files
|
||||
|
||||
Cons:
|
||||
- Database bloat for large/many files
|
||||
- Not ideal for distributed deployments
|
||||
- Higher cost than object storage at scale
|
||||
|
||||
For production/scale, consider S3FileStorage instead.
|
||||
"""
|
||||
|
||||
def __init__(self, pool_getter: Callable[[], "asyncpg.Pool"], schema: str | None = None):
|
||||
"""
|
||||
Initialize PostgreSQL file storage.
|
||||
|
||||
Args:
|
||||
pool_getter: Function that returns asyncpg connection pool
|
||||
schema: Database schema (for multi-tenant support)
|
||||
"""
|
||||
self._pool_getter = pool_getter
|
||||
self._schema = schema
|
||||
|
||||
async def store(
|
||||
self,
|
||||
file_data: bytes,
|
||||
key: str,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Store file in PostgreSQL."""
|
||||
pool = self._pool_getter()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
f"""
|
||||
INSERT INTO {fq_table("file_storage", self._schema)}
|
||||
(storage_key, data)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (storage_key) DO UPDATE SET
|
||||
data = EXCLUDED.data
|
||||
""",
|
||||
key,
|
||||
file_data,
|
||||
)
|
||||
|
||||
logger.debug(f"Stored file {key} ({len(file_data)} bytes) in PostgreSQL")
|
||||
return key
|
||||
|
||||
async def retrieve(self, key: str) -> bytes:
|
||||
"""Retrieve file from PostgreSQL."""
|
||||
pool = self._pool_getter()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT data FROM {fq_table("file_storage", self._schema)}
|
||||
WHERE storage_key = $1
|
||||
""",
|
||||
key,
|
||||
)
|
||||
|
||||
if not row:
|
||||
raise FileNotFoundError(f"File not found: {key}")
|
||||
|
||||
return bytes(row["data"])
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
"""Delete file from PostgreSQL."""
|
||||
pool = self._pool_getter()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
f"""
|
||||
DELETE FROM {fq_table("file_storage", self._schema)}
|
||||
WHERE storage_key = $1
|
||||
""",
|
||||
key,
|
||||
)
|
||||
|
||||
# Check if anything was deleted
|
||||
if result == "DELETE 0":
|
||||
logger.warning(f"Attempted to delete non-existent file: {key}")
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
"""Check if file exists in PostgreSQL."""
|
||||
pool = self._pool_getter()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT 1 FROM {fq_table("file_storage", self._schema)}
|
||||
WHERE storage_key = $1
|
||||
""",
|
||||
key,
|
||||
)
|
||||
|
||||
return row is not None
|
||||
|
||||
async def get_download_url(self, key: str, expires_in: int = 3600) -> str:
|
||||
"""
|
||||
Get download URL for PostgreSQL-stored file.
|
||||
|
||||
Returns an API endpoint path (not a pre-signed URL since the file
|
||||
is stored in the database). The expires_in parameter is ignored
|
||||
for PostgreSQL storage.
|
||||
"""
|
||||
# Return API path for download endpoint
|
||||
# (expires_in ignored for database storage - auth handled at API level)
|
||||
return f"/v1/default/files/download/{key}"
|
||||
@@ -0,0 +1,71 @@
|
||||
"""S3 object storage backend using obstore."""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import obstore as obs
|
||||
from obstore.store import S3Store
|
||||
|
||||
from .base import FileStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class S3FileStorage(FileStorage):
|
||||
"""
|
||||
S3-compatible object storage backend.
|
||||
|
||||
Uses obstore (Rust-backed) for high-throughput async access to
|
||||
Amazon S3, MinIO, Cloudflare R2, and other S3-compliant APIs.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bucket: str,
|
||||
region: str | None = None,
|
||||
endpoint: str | None = None,
|
||||
access_key_id: str | None = None,
|
||||
secret_access_key: str | None = None,
|
||||
):
|
||||
kwargs: dict = {}
|
||||
if region:
|
||||
kwargs["region"] = region
|
||||
if endpoint:
|
||||
kwargs["endpoint"] = endpoint
|
||||
# Allow plain HTTP for local S3-compatible services (MinIO, LocalStack, etc.)
|
||||
if endpoint.startswith("http://"):
|
||||
kwargs["allow_http"] = True
|
||||
if access_key_id:
|
||||
kwargs["access_key_id"] = access_key_id
|
||||
if secret_access_key:
|
||||
kwargs["secret_access_key"] = secret_access_key
|
||||
|
||||
self._store = S3Store(bucket, **kwargs)
|
||||
logger.info(f"Initialized S3 file storage: bucket={bucket}, region={region}, endpoint={endpoint}")
|
||||
|
||||
async def store(self, file_data: bytes, key: str, metadata: dict[str, str] | None = None) -> str:
|
||||
await obs.put_async(self._store, key, file_data)
|
||||
logger.debug(f"Stored file {key} ({len(file_data)} bytes) in S3")
|
||||
return key
|
||||
|
||||
async def retrieve(self, key: str) -> bytes:
|
||||
try:
|
||||
response = await obs.get_async(self._store, key)
|
||||
return await response.bytes_async()
|
||||
except Exception as e:
|
||||
if "not found" in str(e).lower() or "NoSuchKey" in str(e):
|
||||
raise FileNotFoundError(f"File not found: {key}") from e
|
||||
raise
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
await obs.delete_async(self._store, key)
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
try:
|
||||
await obs.head_async(self._store, key)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def get_download_url(self, key: str, expires_in: int = 3600) -> str:
|
||||
return await obs.sign_async(self._store, "GET", key, timedelta(seconds=expires_in))
|
||||
@@ -19,6 +19,7 @@ async def extract_facts(
|
||||
context: str = "",
|
||||
llm_config: "LLMConfig" = None,
|
||||
agent_name: str = None,
|
||||
config=None,
|
||||
) -> tuple[list["Fact"], list[tuple[str, int]]]:
|
||||
"""
|
||||
Extract semantic facts from text using LLM.
|
||||
@@ -35,6 +36,7 @@ async def extract_facts(
|
||||
context: Context about the conversation/document
|
||||
llm_config: LLM configuration to use
|
||||
agent_name: Optional agent name to help identify agent-related facts
|
||||
config: HindsightConfig to use (defaults to global config if not provided)
|
||||
|
||||
Returns:
|
||||
Tuple of (facts, chunks) where:
|
||||
@@ -47,12 +49,19 @@ async def extract_facts(
|
||||
if not text or not text.strip():
|
||||
return [], []
|
||||
|
||||
# Use provided config or fall back to global config
|
||||
if config is None:
|
||||
from ..config import _get_raw_config
|
||||
|
||||
config = _get_raw_config()
|
||||
|
||||
facts, chunks, _ = await extract_facts_from_text(
|
||||
text,
|
||||
event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name=agent_name,
|
||||
config=config,
|
||||
context=context,
|
||||
)
|
||||
|
||||
if not facts:
|
||||
|
||||
@@ -16,10 +16,11 @@ with the system (e.g., running migrations for tenant schemas).
|
||||
"""
|
||||
|
||||
from hindsight_api.extensions.base import Extension
|
||||
from hindsight_api.extensions.builtin import ApiKeyTenantExtension
|
||||
from hindsight_api.extensions.builtin import ApiKeyTenantExtension, SupabaseTenantExtension
|
||||
from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionContext
|
||||
from hindsight_api.extensions.http import HttpExtension
|
||||
from hindsight_api.extensions.loader import load_extension
|
||||
from hindsight_api.extensions.mcp import MCPExtension
|
||||
from hindsight_api.extensions.operation_validator import (
|
||||
# Consolidation operation
|
||||
ConsolidateContext,
|
||||
@@ -57,6 +58,8 @@ __all__ = [
|
||||
"DefaultExtensionContext",
|
||||
# HTTP Extension
|
||||
"HttpExtension",
|
||||
# MCP Extension
|
||||
"MCPExtension",
|
||||
# Operation Validator - Core
|
||||
"OperationValidationError",
|
||||
"OperationValidatorExtension",
|
||||
@@ -77,6 +80,7 @@ __all__ = [
|
||||
"MentalModelRefreshResult",
|
||||
# Tenant/Auth
|
||||
"ApiKeyTenantExtension",
|
||||
"SupabaseTenantExtension",
|
||||
"AuthenticationError",
|
||||
"RequestContext",
|
||||
"Tenant",
|
||||
|
||||
@@ -6,13 +6,17 @@ They can be used directly or serve as examples for custom implementations.
|
||||
|
||||
Available built-in extensions:
|
||||
- ApiKeyTenantExtension: Simple API key validation with public schema
|
||||
- SupabaseTenantExtension: Supabase JWT validation with per-user schema isolation
|
||||
|
||||
Example usage:
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension
|
||||
"""
|
||||
|
||||
from hindsight_api.extensions.builtin.supabase_tenant import SupabaseTenantExtension
|
||||
from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension
|
||||
|
||||
__all__ = [
|
||||
"ApiKeyTenantExtension",
|
||||
"SupabaseTenantExtension",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
"""
|
||||
Supabase Tenant Extension for Hindsight
|
||||
|
||||
Validates Supabase JWTs and maps authenticated users to isolated memory banks.
|
||||
Each user gets their own PostgreSQL schema based on their Supabase user ID.
|
||||
|
||||
This extension enables multi-tenant memory isolation for applications using
|
||||
Supabase Auth - each authenticated user's memories are stored in a separate
|
||||
schema, ensuring complete data isolation.
|
||||
|
||||
Features:
|
||||
- Local JWT Verification: Validates tokens locally using JWKS public keys
|
||||
(no network call per request)
|
||||
- Automatic Schema Isolation: Each user gets {prefix}_{user_id} schema
|
||||
- Zero User Management: Leverages your existing Supabase Auth setup
|
||||
- Production Ready: Includes health checks, timeouts, key rotation handling,
|
||||
and error handling
|
||||
- Built-in: Ships with Hindsight, no extra installation needed
|
||||
- Legacy Support: Falls back to /auth/v1/user endpoint for HS256 projects
|
||||
|
||||
JWT Verification Strategy:
|
||||
By default, JWTs are verified locally using public keys from the Supabase
|
||||
JWKS endpoint (/auth/v1/.well-known/jwks.json). This is the Supabase-recommended
|
||||
approach: no network call per request, fast, and secure.
|
||||
|
||||
If JWKS keys are unavailable (e.g., legacy HS256 projects), the extension
|
||||
falls back to calling /auth/v1/user per request for validation. This requires
|
||||
the service_role key to be configured.
|
||||
|
||||
Configuration via environment variables:
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension
|
||||
HINDSIGHT_API_TENANT_SUPABASE_URL=https://your-project.supabase.co
|
||||
|
||||
# Optional - only required for legacy HS256 projects or health checks
|
||||
HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY=your-service-role-key
|
||||
|
||||
# Optional
|
||||
HINDSIGHT_API_TENANT_SCHEMA_PREFIX=user # Default: "user" (creates user_<uuid> schemas)
|
||||
|
||||
Usage:
|
||||
Clients pass their Supabase JWT in the Authorization header:
|
||||
|
||||
curl -H "Authorization: Bearer <supabase_jwt>" \\
|
||||
https://your-hindsight-server/v1/default/banks/my-bank/memories/recall
|
||||
|
||||
Author: BrighterBalance (https://brighterbalance.app)
|
||||
License: MIT
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import jwt as pyjwt
|
||||
from jwt import PyJWK
|
||||
|
||||
from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["SupabaseTenantExtension"]
|
||||
|
||||
# Minimum expected JWT length (JWTs are typically 100+ characters)
|
||||
MIN_TOKEN_LENGTH = 20
|
||||
|
||||
# Timeout for Supabase API calls
|
||||
REQUEST_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
# JWKS cache TTL — Supabase Edge caches JWKS for 10 minutes, so we match that
|
||||
JWKS_CACHE_TTL_SECONDS = 600
|
||||
|
||||
# Minimum interval between JWKS refreshes to avoid hammering the endpoint
|
||||
JWKS_MIN_REFRESH_INTERVAL_SECONDS = 30
|
||||
|
||||
# Algorithms supported by Supabase Auth for asymmetric JWT signing
|
||||
SUPPORTED_ALGORITHMS = ["RS256", "ES256"]
|
||||
|
||||
# Supabase user IDs are UUIDs — validate before using in schema names
|
||||
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
|
||||
|
||||
# Schema prefix must be a valid Postgres identifier component (letters, digits, underscores)
|
||||
_SCHEMA_PREFIX_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
||||
|
||||
|
||||
class SupabaseTenantExtension(TenantExtension):
|
||||
"""
|
||||
TenantExtension that validates Supabase JWTs for multi-tenant isolation.
|
||||
|
||||
Each authenticated user gets their own PostgreSQL schema, ensuring complete
|
||||
memory isolation between users. The schema name is derived from the user's
|
||||
Supabase user ID (the ``sub`` claim in the JWT).
|
||||
|
||||
JWT verification uses JWKS (local, no network call per request) when
|
||||
asymmetric keys are configured in Supabase, and falls back to the
|
||||
``/auth/v1/user`` endpoint for legacy HS256 projects.
|
||||
|
||||
Example:
|
||||
User with ID "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||
gets schema "user_a1b2c3d4_e5f6_7890_abcd_ef1234567890"
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict[str, str]) -> None:
|
||||
"""
|
||||
Initialize with configuration from environment variables.
|
||||
|
||||
Config keys are derived from HINDSIGHT_API_TENANT_* env vars:
|
||||
- HINDSIGHT_API_TENANT_SUPABASE_URL -> config["supabase_url"] (required)
|
||||
- HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY -> config["supabase_service_key"] (optional)
|
||||
- HINDSIGHT_API_TENANT_SCHEMA_PREFIX -> config["schema_prefix"] (optional)
|
||||
|
||||
Args:
|
||||
config: Dictionary of configuration values from environment
|
||||
|
||||
Raises:
|
||||
ValueError: If required configuration is missing
|
||||
"""
|
||||
super().__init__(config)
|
||||
|
||||
self.supabase_url = (config.get("supabase_url") or "").rstrip("/")
|
||||
self.supabase_service_key = config.get("supabase_service_key")
|
||||
self.schema_prefix = config.get("schema_prefix", "user")
|
||||
|
||||
# Track initialized schemas to avoid redundant migrations
|
||||
self._initialized_schemas: set[str] = set()
|
||||
|
||||
# Reusable HTTP client (created on startup)
|
||||
self._http_client: httpx.AsyncClient | None = None
|
||||
|
||||
# JWKS state
|
||||
self._jwks_keys: dict[str, PyJWK] = {}
|
||||
self._jwks_last_fetched: float = 0
|
||||
self._use_jwks: bool = False
|
||||
|
||||
if not self.supabase_url:
|
||||
raise ValueError(
|
||||
"HINDSIGHT_API_TENANT_SUPABASE_URL is required. "
|
||||
"Set it to your Supabase project URL (e.g., https://xxx.supabase.co)"
|
||||
)
|
||||
|
||||
if not _SCHEMA_PREFIX_RE.match(self.schema_prefix):
|
||||
raise ValueError(
|
||||
f"Invalid schema_prefix '{self.schema_prefix}'. "
|
||||
"Must be a valid Postgres identifier (letters, digits, underscores, starting with a letter or underscore)."
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def on_startup(self) -> None:
|
||||
"""
|
||||
Called when Hindsight starts.
|
||||
|
||||
Creates a reusable HTTP client, fetches JWKS for local JWT verification,
|
||||
and optionally verifies connectivity to Supabase.
|
||||
"""
|
||||
logger.info("Initializing Supabase tenant extension")
|
||||
logger.info("Supabase URL: %s", self.supabase_url)
|
||||
logger.info("Schema prefix: %s_", self.schema_prefix)
|
||||
|
||||
self._http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS)
|
||||
|
||||
# Attempt to fetch JWKS for fast local JWT verification
|
||||
await self._try_init_jwks()
|
||||
|
||||
# Optional health check using service key
|
||||
if self.supabase_service_key:
|
||||
await self._health_check()
|
||||
|
||||
async def on_shutdown(self) -> None:
|
||||
"""Called when Hindsight shuts down. Closes the HTTP client."""
|
||||
logger.info("Shutting down Supabase tenant extension")
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
self._http_client = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# JWKS management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _try_init_jwks(self) -> None:
|
||||
"""Fetch JWKS and decide verification mode (local JWKS vs legacy endpoint)."""
|
||||
try:
|
||||
await self._fetch_jwks()
|
||||
if self._jwks_keys:
|
||||
self._use_jwks = True
|
||||
logger.info(
|
||||
"JWKS loaded — using local JWT verification with %d key(s)",
|
||||
len(self._jwks_keys),
|
||||
)
|
||||
return
|
||||
|
||||
# JWKS endpoint returned no keys — project likely uses legacy HS256
|
||||
logger.warning(
|
||||
"JWKS endpoint returned no signing keys. "
|
||||
"Falling back to /auth/v1/user endpoint for JWT verification. "
|
||||
"For better performance, enable asymmetric JWT signing in your "
|
||||
"Supabase dashboard (Project Settings → Auth → JWT Algorithm)."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not fetch JWKS (%s). Falling back to /auth/v1/user endpoint for JWT verification.",
|
||||
e,
|
||||
)
|
||||
|
||||
# Legacy mode requires service key
|
||||
if not self.supabase_service_key:
|
||||
raise ValueError(
|
||||
"HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY is required when JWKS "
|
||||
"is not available. Either enable asymmetric JWT signing in your "
|
||||
"Supabase project or provide the service_role key."
|
||||
)
|
||||
self._use_jwks = False
|
||||
|
||||
async def _fetch_jwks(self) -> None:
|
||||
"""Fetch public signing keys from the Supabase JWKS endpoint."""
|
||||
if self._http_client is None:
|
||||
raise RuntimeError("HTTP client not initialized")
|
||||
|
||||
url = f"{self.supabase_url}/auth/v1/.well-known/jwks.json"
|
||||
response = await self._http_client.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
jwks_data = response.json()
|
||||
keys: dict[str, PyJWK] = {}
|
||||
for key_data in jwks_data.get("keys", []):
|
||||
kid = key_data.get("kid")
|
||||
if kid:
|
||||
keys[kid] = PyJWK(key_data)
|
||||
|
||||
self._jwks_keys = keys
|
||||
self._jwks_last_fetched = time.monotonic()
|
||||
|
||||
async def _get_signing_key(self, token: str) -> PyJWK:
|
||||
"""
|
||||
Resolve the signing key for a token from the JWKS cache.
|
||||
|
||||
If the key ID (``kid``) is not in the cache, triggers one JWKS refresh
|
||||
to handle key rotation before raising an error.
|
||||
"""
|
||||
header = pyjwt.get_unverified_header(token)
|
||||
kid = header.get("kid")
|
||||
if not kid:
|
||||
raise AuthenticationError("Token missing key ID (kid) header")
|
||||
|
||||
# Refresh cache if stale
|
||||
now = time.monotonic()
|
||||
if now - self._jwks_last_fetched > JWKS_CACHE_TTL_SECONDS:
|
||||
logger.debug("JWKS cache expired, refreshing")
|
||||
await self._fetch_jwks()
|
||||
|
||||
if kid in self._jwks_keys:
|
||||
return self._jwks_keys[kid]
|
||||
|
||||
# Key not found — try one forced refresh to handle key rotation,
|
||||
# but only if we haven't just refreshed
|
||||
if now - self._jwks_last_fetched > JWKS_MIN_REFRESH_INTERVAL_SECONDS:
|
||||
logger.info("Signing key %s not in cache, refreshing JWKS for possible key rotation", kid)
|
||||
await self._fetch_jwks()
|
||||
if kid in self._jwks_keys:
|
||||
return self._jwks_keys[kid]
|
||||
|
||||
raise AuthenticationError("Unable to find signing key for token")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Authentication
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def authenticate(self, context: RequestContext) -> TenantContext:
|
||||
"""
|
||||
Validate a Supabase JWT and return tenant context.
|
||||
|
||||
Uses local JWKS verification when available (no network call per
|
||||
request), falling back to the ``/auth/v1/user`` endpoint for legacy
|
||||
HS256 projects.
|
||||
|
||||
Args:
|
||||
context: Request context containing the API key (JWT)
|
||||
|
||||
Returns:
|
||||
TenantContext with schema_name set to ``{prefix}_{user_uuid}``
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If token is missing, invalid, or expired
|
||||
"""
|
||||
token = context.api_key
|
||||
|
||||
if not token:
|
||||
raise AuthenticationError("Missing Authorization header. Expected: Bearer <supabase_jwt>")
|
||||
|
||||
if len(token) < MIN_TOKEN_LENGTH:
|
||||
raise AuthenticationError("Invalid token format")
|
||||
|
||||
if self._http_client is None:
|
||||
raise AuthenticationError("Extension not initialized")
|
||||
|
||||
# Verify the JWT and extract user ID
|
||||
if self._use_jwks:
|
||||
user_id = await self._verify_token_jwks(token)
|
||||
else:
|
||||
user_id = await self._verify_token_legacy(token)
|
||||
|
||||
# Validate user ID format before using in schema name
|
||||
if not _UUID_RE.match(user_id):
|
||||
raise AuthenticationError("Invalid user ID format in token")
|
||||
|
||||
# Build isolated schema name — hyphens to underscores for Postgres compatibility
|
||||
safe_user_id = user_id.replace("-", "_")
|
||||
schema_name = f"{self.schema_prefix}_{safe_user_id}"
|
||||
|
||||
# Initialize schema on first access
|
||||
if schema_name not in self._initialized_schemas:
|
||||
await self._initialize_schema(schema_name)
|
||||
|
||||
return TenantContext(schema_name=schema_name)
|
||||
|
||||
async def _verify_token_jwks(self, token: str) -> str:
|
||||
"""
|
||||
Verify a JWT locally using cached JWKS public keys.
|
||||
|
||||
Validates signature, expiration, issuer, and audience. Returns the
|
||||
user ID from the ``sub`` claim.
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If the token is invalid or expired.
|
||||
"""
|
||||
try:
|
||||
signing_key = await self._get_signing_key(token)
|
||||
payload = pyjwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=SUPPORTED_ALGORITHMS,
|
||||
audience="authenticated",
|
||||
issuer=f"{self.supabase_url}/auth/v1",
|
||||
)
|
||||
except pyjwt.ExpiredSignatureError:
|
||||
raise AuthenticationError("Token has expired")
|
||||
except pyjwt.InvalidAudienceError:
|
||||
raise AuthenticationError("Invalid token audience")
|
||||
except pyjwt.InvalidIssuerError:
|
||||
raise AuthenticationError("Invalid token issuer")
|
||||
except pyjwt.DecodeError:
|
||||
raise AuthenticationError("Invalid token")
|
||||
except AuthenticationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise AuthenticationError(f"Token verification failed: {e!s}")
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise AuthenticationError("Token valid but missing subject (sub) claim")
|
||||
return user_id
|
||||
|
||||
async def _verify_token_legacy(self, token: str) -> str:
|
||||
"""
|
||||
Verify a JWT by calling the Supabase ``/auth/v1/user`` endpoint.
|
||||
|
||||
This is the fallback for projects using legacy HS256 JWT signing.
|
||||
Adds a network round-trip per request.
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If the token is invalid or the request fails.
|
||||
"""
|
||||
try:
|
||||
response = await self._http_client.get(
|
||||
f"{self.supabase_url}/auth/v1/user",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"apikey": self.supabase_service_key,
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code == 401:
|
||||
raise AuthenticationError("Invalid or expired token")
|
||||
|
||||
if response.status_code != 200:
|
||||
raise AuthenticationError(f"Authentication failed: {response.status_code}")
|
||||
|
||||
user_data = response.json()
|
||||
user_id = user_data.get("id")
|
||||
|
||||
if not user_id:
|
||||
raise AuthenticationError("Token valid but no user ID found")
|
||||
|
||||
return user_id
|
||||
|
||||
except AuthenticationError:
|
||||
raise
|
||||
except httpx.TimeoutException:
|
||||
raise AuthenticationError("Authentication timeout - please retry")
|
||||
except httpx.RequestError as e:
|
||||
raise AuthenticationError(f"Connection error: {e!s}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Schema management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _initialize_schema(self, schema_name: str) -> None:
|
||||
"""Run migrations for a new tenant schema and cache the result."""
|
||||
logger.info("Initializing schema: %s", schema_name)
|
||||
try:
|
||||
await self.context.run_migration(schema_name)
|
||||
self._initialized_schemas.add(schema_name)
|
||||
logger.info("Schema ready: %s", schema_name)
|
||||
except Exception as e:
|
||||
logger.error("Schema initialization failed for %s: %s", schema_name, e)
|
||||
raise AuthenticationError(f"Failed to initialize tenant: {e!s}")
|
||||
|
||||
async def list_tenants(self) -> list[Tenant]:
|
||||
"""Return all tenant schemas that have been initialized."""
|
||||
return [Tenant(schema=schema) for schema in self._initialized_schemas]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Health check
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _health_check(self) -> None:
|
||||
"""Verify connectivity to Supabase using the auth health endpoint."""
|
||||
try:
|
||||
response = await self._http_client.get(
|
||||
f"{self.supabase_url}/auth/v1/health",
|
||||
headers={"apikey": self.supabase_service_key},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
logger.info("Supabase connection verified")
|
||||
else:
|
||||
logger.warning("Supabase health check returned %d", response.status_code)
|
||||
except Exception as e:
|
||||
logger.warning("Could not verify Supabase connection: %s", e)
|
||||
@@ -5,6 +5,42 @@ from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantC
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
|
||||
class DefaultTenantExtension(TenantExtension):
|
||||
"""
|
||||
Default single-tenant extension with no authentication.
|
||||
|
||||
This is the default extension used when no tenant extension is configured.
|
||||
It provides single-tenant behavior using the configured schema from
|
||||
HINDSIGHT_API_DATABASE_SCHEMA (defaults to 'public').
|
||||
|
||||
Features:
|
||||
- No authentication required (passes all requests)
|
||||
- Uses configured schema from environment
|
||||
- Perfect for single-tenant deployments without auth
|
||||
|
||||
Configuration:
|
||||
HINDSIGHT_API_DATABASE_SCHEMA=your-schema (optional, defaults to 'public')
|
||||
|
||||
This is automatically enabled by default. To use custom authentication,
|
||||
configure a different tenant extension:
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict[str, str]):
|
||||
super().__init__(config)
|
||||
# Cache the schema at initialization for consistency
|
||||
# Support explicit schema override via config, otherwise use environment
|
||||
self._schema = config.get("schema", get_config().database_schema)
|
||||
|
||||
async def authenticate(self, context: RequestContext) -> TenantContext:
|
||||
"""Return configured schema without any authentication."""
|
||||
return TenantContext(schema_name=self._schema)
|
||||
|
||||
async def list_tenants(self) -> list[Tenant]:
|
||||
"""Return configured schema for single-tenant setup."""
|
||||
return [Tenant(schema=self._schema)]
|
||||
|
||||
|
||||
class ApiKeyTenantExtension(TenantExtension):
|
||||
"""
|
||||
Built-in tenant extension that validates API key against an environment variable.
|
||||
@@ -18,6 +54,7 @@ class ApiKeyTenantExtension(TenantExtension):
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
||||
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
|
||||
HINDSIGHT_API_DATABASE_SCHEMA=your-schema (optional, defaults to 'public')
|
||||
HINDSIGHT_API_TENANT_MCP_AUTH_DISABLED=true (optional, disable auth for MCP endpoints)
|
||||
|
||||
For multi-tenant setups with separate schemas per tenant, implement a custom
|
||||
TenantExtension that looks up the schema based on the API key or token claims.
|
||||
@@ -28,6 +65,8 @@ class ApiKeyTenantExtension(TenantExtension):
|
||||
self.expected_api_key = config.get("api_key")
|
||||
if not self.expected_api_key:
|
||||
raise ValueError("HINDSIGHT_API_TENANT_API_KEY is required when using ApiKeyTenantExtension")
|
||||
# Allow disabling MCP auth for backwards compatibility
|
||||
self.mcp_auth_disabled = config.get("mcp_auth_disabled", "").lower() in ("true", "1", "yes")
|
||||
|
||||
async def authenticate(self, context: RequestContext) -> TenantContext:
|
||||
"""Validate API key and return configured schema context."""
|
||||
@@ -38,3 +77,14 @@ class ApiKeyTenantExtension(TenantExtension):
|
||||
async def list_tenants(self) -> list[Tenant]:
|
||||
"""Return configured schema for single-tenant setup."""
|
||||
return [Tenant(schema=get_config().database_schema)]
|
||||
|
||||
async def authenticate_mcp(self, context: RequestContext) -> TenantContext:
|
||||
"""
|
||||
Authenticate MCP requests.
|
||||
|
||||
If mcp_auth_disabled is set, skip authentication for backwards compatibility.
|
||||
Otherwise, delegate to authenticate().
|
||||
"""
|
||||
if self.mcp_auth_disabled:
|
||||
return TenantContext(schema_name=get_config().database_schema)
|
||||
return await self.authenticate(context)
|
||||
|
||||
@@ -96,7 +96,13 @@ class DefaultExtensionContext(ExtensionContext):
|
||||
|
||||
async def run_migration(self, schema: str) -> None:
|
||||
"""Run migrations for a specific schema."""
|
||||
from hindsight_api.migrations import ensure_embedding_dimension, run_migrations
|
||||
from hindsight_api.config import get_config
|
||||
from hindsight_api.migrations import (
|
||||
ensure_embedding_dimension,
|
||||
ensure_text_search_extension,
|
||||
ensure_vector_extension,
|
||||
run_migrations,
|
||||
)
|
||||
|
||||
# Prefer getting URL from memory engine (handles pg0 case where URL is set after init)
|
||||
db_url = self._database_url
|
||||
@@ -107,6 +113,9 @@ class DefaultExtensionContext(ExtensionContext):
|
||||
|
||||
run_migrations(db_url, schema=schema)
|
||||
|
||||
# Get config for vector extension setting
|
||||
config = get_config()
|
||||
|
||||
# Ensure embedding column dimension matches the model's dimension
|
||||
# This is needed because migrations create columns with default dimension
|
||||
if self._memory_engine is not None:
|
||||
@@ -114,7 +123,15 @@ class DefaultExtensionContext(ExtensionContext):
|
||||
if embeddings is not None:
|
||||
dimension = getattr(embeddings, "dimension", None)
|
||||
if dimension is not None:
|
||||
ensure_embedding_dimension(db_url, dimension, schema=schema)
|
||||
ensure_embedding_dimension(
|
||||
db_url, dimension, schema=schema, vector_extension=config.vector_extension
|
||||
)
|
||||
|
||||
# Ensure vector indexes match the configured extension
|
||||
ensure_vector_extension(db_url, vector_extension=config.vector_extension, schema=schema)
|
||||
|
||||
# Ensure text search columns/indexes match the configured extension
|
||||
ensure_text_search_extension(db_url, text_search_extension=config.text_search_extension, schema=schema)
|
||||
|
||||
def get_memory_engine(self) -> "MemoryEngineInterface":
|
||||
"""Get the memory engine interface."""
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""MCP Extension for registering additional MCP tools.
|
||||
|
||||
This extension allows external packages (like hindsight-cloud) to register
|
||||
additional MCP tools on the Hindsight MCP server.
|
||||
|
||||
Example:
|
||||
HINDSIGHT_API_MCP_EXTENSION=hindsight_cloud.extensions:CloudMCPExtension
|
||||
"""
|
||||
|
||||
import logging
|
||||
from abc import abstractmethod
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from hindsight_api import MemoryEngine
|
||||
from hindsight_api.extensions.base import Extension
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MCPExtension(Extension):
|
||||
"""Base class for MCP extensions that register additional tools.
|
||||
|
||||
Subclass this to add MCP tools in extension packages.
|
||||
|
||||
Example:
|
||||
class CloudMCPExtension(MCPExtension):
|
||||
def register_tools(self, mcp: FastMCP, memory: MemoryEngine) -> None:
|
||||
@mcp.tool()
|
||||
async def my_custom_tool(query: str) -> str:
|
||||
return "result"
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def register_tools(self, mcp: FastMCP, memory: MemoryEngine) -> None:
|
||||
"""Register additional MCP tools.
|
||||
|
||||
Args:
|
||||
mcp: FastMCP server instance to register tools on
|
||||
memory: MemoryEngine instance for accessing memory operations
|
||||
"""
|
||||
pass
|
||||
@@ -132,6 +132,10 @@ class RetainResult:
|
||||
unit_ids: list[list[str]] # List of unit IDs per content item
|
||||
success: bool = True
|
||||
error: str | None = None
|
||||
# Actual LLM token usage (populated by engine when available)
|
||||
llm_input_tokens: int | None = None
|
||||
llm_output_tokens: int | None = None
|
||||
llm_total_tokens: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from hindsight_api.extensions.base import Extension
|
||||
from hindsight_api.models import RequestContext
|
||||
@@ -87,3 +88,70 @@ class TenantExtension(Extension, ABC):
|
||||
For single-tenant setups, return [Tenant(schema="public")].
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_tenant_config(self, context: RequestContext) -> dict[str, Any]:
|
||||
"""
|
||||
Get tenant-specific configuration overrides.
|
||||
|
||||
This method is called during hierarchical configuration resolution to get
|
||||
tenant-level config overrides. The returned dict should contain Python field
|
||||
names (lowercase snake_case) as keys, not environment variable names.
|
||||
|
||||
Example:
|
||||
{"llm_model": "gpt-4", "retain_extraction_mode": "verbose"}
|
||||
|
||||
The default implementation returns an empty dict (no tenant-specific config).
|
||||
Override this method in custom extensions to provide tenant-specific configuration.
|
||||
|
||||
Args:
|
||||
context: The request context containing tenant information.
|
||||
|
||||
Returns:
|
||||
Dict of config field names to values (only configurable fields).
|
||||
Empty dict if no tenant-specific config.
|
||||
"""
|
||||
return {}
|
||||
|
||||
async def get_allowed_config_fields(self, context: RequestContext, bank_id: str) -> set[str] | None:
|
||||
"""
|
||||
Get set of config fields that this tenant/bank is allowed to modify.
|
||||
|
||||
This method controls which configurable fields can be modified via the bank config API.
|
||||
It enables fine-grained permission control per tenant or per bank.
|
||||
|
||||
Examples:
|
||||
- Return None: Allow all configurable fields (default)
|
||||
- Return {"retain_chunk_size", "retain_custom_instructions"}: Allow only these fields
|
||||
- Return set(): Allow no modifications (read-only)
|
||||
|
||||
The default implementation returns None (all configurable fields allowed).
|
||||
Override this method in custom extensions to implement custom permission logic.
|
||||
|
||||
Args:
|
||||
context: The request context containing tenant information.
|
||||
bank_id: The bank identifier for per-bank permissions.
|
||||
|
||||
Returns:
|
||||
Set of allowed field names, or None to allow all configurable fields.
|
||||
Returned fields must be a subset of HindsightConfig.get_configurable_fields().
|
||||
"""
|
||||
return None
|
||||
|
||||
async def authenticate_mcp(self, context: RequestContext) -> TenantContext:
|
||||
"""
|
||||
Authenticate MCP requests.
|
||||
|
||||
By default, this calls authenticate(). Override this method to provide
|
||||
different authentication behavior for MCP endpoints (e.g., to disable
|
||||
auth for backwards compatibility with existing MCP servers).
|
||||
|
||||
Args:
|
||||
context: The action context containing API key and other auth data.
|
||||
|
||||
Returns:
|
||||
TenantContext with the schema_name for database operations.
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If authentication fails.
|
||||
"""
|
||||
return await self.authenticate(context)
|
||||
|
||||
@@ -23,11 +23,10 @@ import uvicorn
|
||||
from . import MemoryEngine, __version__
|
||||
from .api import create_app
|
||||
from .banner import print_banner
|
||||
from .config import DEFAULT_WORKERS, ENV_WORKERS, HindsightConfig, get_config
|
||||
from .config import DEFAULT_WORKERS, ENV_WORKERS, HindsightConfig, _get_raw_config
|
||||
from .daemon import (
|
||||
DEFAULT_DAEMON_PORT,
|
||||
DEFAULT_IDLE_TIMEOUT,
|
||||
DaemonLock,
|
||||
IdleTimeoutMiddleware,
|
||||
daemonize,
|
||||
)
|
||||
@@ -69,7 +68,7 @@ def main():
|
||||
global _memory
|
||||
|
||||
# Load configuration from environment (for CLI args defaults)
|
||||
config = get_config()
|
||||
config = _get_raw_config()
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="hindsight-api",
|
||||
@@ -136,30 +135,15 @@ def main():
|
||||
|
||||
# Daemon mode handling
|
||||
if args.daemon:
|
||||
# Use fixed daemon port
|
||||
args.port = DEFAULT_DAEMON_PORT
|
||||
# Use port from args (may be custom for profiles)
|
||||
if args.port == config.port: # No custom port specified
|
||||
args.port = DEFAULT_DAEMON_PORT
|
||||
args.host = "127.0.0.1" # Only bind to localhost for security
|
||||
|
||||
# Check if another daemon is already running
|
||||
daemon_lock = DaemonLock()
|
||||
if not daemon_lock.acquire():
|
||||
print(f"Daemon already running (PID: {daemon_lock.get_pid()})", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Fork into background
|
||||
# No lockfile needed - port binding prevents duplicate daemons
|
||||
daemonize()
|
||||
|
||||
# Re-acquire lock in child process
|
||||
daemon_lock = DaemonLock()
|
||||
if not daemon_lock.acquire():
|
||||
sys.exit(1)
|
||||
|
||||
# Register cleanup to release lock
|
||||
def release_lock():
|
||||
daemon_lock.release()
|
||||
|
||||
atexit.register(release_lock)
|
||||
|
||||
# Print banner (not in daemon mode)
|
||||
if not args.daemon:
|
||||
print()
|
||||
@@ -171,6 +155,8 @@ def main():
|
||||
config = HindsightConfig(
|
||||
database_url=config.database_url,
|
||||
database_schema=config.database_schema,
|
||||
vector_extension=config.vector_extension,
|
||||
text_search_extension=config.text_search_extension,
|
||||
llm_provider=config.llm_provider,
|
||||
llm_api_key=config.llm_api_key,
|
||||
llm_model=config.llm_model,
|
||||
@@ -180,6 +166,8 @@ def main():
|
||||
llm_initial_backoff=config.llm_initial_backoff,
|
||||
llm_max_backoff=config.llm_max_backoff,
|
||||
llm_timeout=config.llm_timeout,
|
||||
llm_groq_service_tier=config.llm_groq_service_tier,
|
||||
llm_openai_service_tier=config.llm_openai_service_tier,
|
||||
llm_vertexai_project_id=config.llm_vertexai_project_id,
|
||||
llm_vertexai_region=config.llm_vertexai_region,
|
||||
llm_vertexai_service_account_key=config.llm_vertexai_service_account_key,
|
||||
@@ -213,23 +201,43 @@ def main():
|
||||
embeddings_provider=config.embeddings_provider,
|
||||
embeddings_local_model=config.embeddings_local_model,
|
||||
embeddings_local_force_cpu=config.embeddings_local_force_cpu,
|
||||
embeddings_local_trust_remote_code=config.embeddings_local_trust_remote_code,
|
||||
embeddings_tei_url=config.embeddings_tei_url,
|
||||
embeddings_openai_base_url=config.embeddings_openai_base_url,
|
||||
embeddings_cohere_api_key=config.embeddings_cohere_api_key,
|
||||
embeddings_cohere_model=config.embeddings_cohere_model,
|
||||
embeddings_cohere_base_url=config.embeddings_cohere_base_url,
|
||||
embeddings_litellm_api_base=config.embeddings_litellm_api_base,
|
||||
embeddings_litellm_api_key=config.embeddings_litellm_api_key,
|
||||
embeddings_litellm_model=config.embeddings_litellm_model,
|
||||
embeddings_litellm_sdk_api_key=config.embeddings_litellm_sdk_api_key,
|
||||
embeddings_litellm_sdk_model=config.embeddings_litellm_sdk_model,
|
||||
embeddings_litellm_sdk_api_base=config.embeddings_litellm_sdk_api_base,
|
||||
reranker_provider=config.reranker_provider,
|
||||
reranker_local_model=config.reranker_local_model,
|
||||
reranker_local_force_cpu=config.reranker_local_force_cpu,
|
||||
reranker_local_max_concurrent=config.reranker_local_max_concurrent,
|
||||
reranker_local_trust_remote_code=config.reranker_local_trust_remote_code,
|
||||
reranker_tei_url=config.reranker_tei_url,
|
||||
reranker_tei_batch_size=config.reranker_tei_batch_size,
|
||||
reranker_tei_max_concurrent=config.reranker_tei_max_concurrent,
|
||||
reranker_max_candidates=config.reranker_max_candidates,
|
||||
reranker_cohere_api_key=config.reranker_cohere_api_key,
|
||||
reranker_cohere_model=config.reranker_cohere_model,
|
||||
reranker_cohere_base_url=config.reranker_cohere_base_url,
|
||||
reranker_litellm_api_base=config.reranker_litellm_api_base,
|
||||
reranker_litellm_api_key=config.reranker_litellm_api_key,
|
||||
reranker_litellm_model=config.reranker_litellm_model,
|
||||
reranker_litellm_sdk_api_key=config.reranker_litellm_sdk_api_key,
|
||||
reranker_litellm_sdk_model=config.reranker_litellm_sdk_model,
|
||||
reranker_litellm_sdk_api_base=config.reranker_litellm_sdk_api_base,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
base_path=config.base_path,
|
||||
log_level=args.log_level,
|
||||
log_format=config.log_format,
|
||||
mcp_enabled=config.mcp_enabled,
|
||||
enable_bank_config_api=config.enable_bank_config_api,
|
||||
graph_retriever=config.graph_retriever,
|
||||
mpfp_top_k_neighbors=config.mpfp_top_k_neighbors,
|
||||
recall_max_concurrent=config.recall_max_concurrent,
|
||||
@@ -239,6 +247,27 @@ def main():
|
||||
retain_extract_causal_links=config.retain_extract_causal_links,
|
||||
retain_extraction_mode=config.retain_extraction_mode,
|
||||
retain_custom_instructions=config.retain_custom_instructions,
|
||||
retain_batch_tokens=config.retain_batch_tokens,
|
||||
retain_batch_enabled=config.retain_batch_enabled,
|
||||
retain_batch_poll_interval_seconds=config.retain_batch_poll_interval_seconds,
|
||||
file_storage_type=config.file_storage_type,
|
||||
file_storage_s3_bucket=config.file_storage_s3_bucket,
|
||||
file_storage_s3_region=config.file_storage_s3_region,
|
||||
file_storage_s3_endpoint=config.file_storage_s3_endpoint,
|
||||
file_storage_s3_access_key_id=config.file_storage_s3_access_key_id,
|
||||
file_storage_s3_secret_access_key=config.file_storage_s3_secret_access_key,
|
||||
file_storage_gcs_bucket=config.file_storage_gcs_bucket,
|
||||
file_storage_gcs_service_account_key=config.file_storage_gcs_service_account_key,
|
||||
file_storage_azure_container=config.file_storage_azure_container,
|
||||
file_storage_azure_account_name=config.file_storage_azure_account_name,
|
||||
file_storage_azure_account_key=config.file_storage_azure_account_key,
|
||||
file_parser=config.file_parser,
|
||||
file_parser_iris_token=config.file_parser_iris_token,
|
||||
file_parser_iris_org_id=config.file_parser_iris_org_id,
|
||||
file_conversion_max_batch_size_mb=config.file_conversion_max_batch_size_mb,
|
||||
file_conversion_max_batch_size=config.file_conversion_max_batch_size,
|
||||
enable_file_upload_api=config.enable_file_upload_api,
|
||||
file_delete_after_retain=config.file_delete_after_retain,
|
||||
enable_observations=config.enable_observations,
|
||||
consolidation_batch_size=config.consolidation_batch_size,
|
||||
consolidation_max_tokens=config.consolidation_max_tokens,
|
||||
@@ -258,6 +287,11 @@ def main():
|
||||
worker_consolidation_max_slots=config.worker_consolidation_max_slots,
|
||||
reflect_max_iterations=config.reflect_max_iterations,
|
||||
mental_model_refresh_concurrency=config.mental_model_refresh_concurrency,
|
||||
otel_traces_enabled=config.otel_traces_enabled,
|
||||
otel_exporter_otlp_endpoint=config.otel_exporter_otlp_endpoint,
|
||||
otel_exporter_otlp_headers=config.otel_exporter_otlp_headers,
|
||||
otel_service_name=config.otel_service_name,
|
||||
otel_deployment_environment=config.otel_deployment_environment,
|
||||
)
|
||||
config.configure_logging()
|
||||
if not args.daemon:
|
||||
@@ -335,6 +369,7 @@ def main():
|
||||
"proxy_headers": args.proxy_headers,
|
||||
"ws": "wsproto", # Use wsproto instead of websockets to avoid deprecation warnings
|
||||
"loop": loop_impl, # Explicitly set event loop implementation
|
||||
"timeout_keep_alive": 30, # Exceed aiohttp's 15s client timeout so the client always closes first
|
||||
}
|
||||
|
||||
# Add optional parameters if provided
|
||||
@@ -363,6 +398,8 @@ def main():
|
||||
reranker_provider=config.reranker_provider,
|
||||
mcp_enabled=config.mcp_enabled,
|
||||
version=__version__,
|
||||
vector_extension=config.vector_extension,
|
||||
text_search_extension=config.text_search_extension,
|
||||
)
|
||||
|
||||
# Start idle checker in daemon mode
|
||||
|
||||
@@ -35,6 +35,12 @@ class MCPToolsConfig:
|
||||
# How to resolve API key for tenant auth (optional)
|
||||
api_key_resolver: Callable[[], str | None] | None = None
|
||||
|
||||
# How to resolve tenant_id for usage metering (set by MCP middleware after auth)
|
||||
tenant_id_resolver: Callable[[], str | None] | None = None
|
||||
|
||||
# How to resolve api_key_id for usage metering (set by MCP middleware after auth)
|
||||
api_key_id_resolver: Callable[[], str | None] | None = None
|
||||
|
||||
# Whether to include bank_id as a parameter on tools (for multi-bank support)
|
||||
include_bank_id_param: bool = False
|
||||
|
||||
@@ -50,13 +56,15 @@ class MCPToolsConfig:
|
||||
|
||||
|
||||
def _get_request_context(config: MCPToolsConfig) -> RequestContext:
|
||||
"""Create RequestContext with API key from resolver if available.
|
||||
"""Create RequestContext with auth details from resolvers.
|
||||
|
||||
This enables tenant auth to work with MCP tools by propagating
|
||||
the Bearer token from the MCP middleware to the memory engine.
|
||||
This enables tenant auth and usage metering to work with MCP tools by propagating
|
||||
the authentication results from the MCP middleware to the memory engine.
|
||||
"""
|
||||
api_key = config.api_key_resolver() if config.api_key_resolver else None
|
||||
return RequestContext(api_key=api_key)
|
||||
tenant_id = config.tenant_id_resolver() if config.tenant_id_resolver else None
|
||||
api_key_id = config.api_key_id_resolver() if config.api_key_id_resolver else None
|
||||
return RequestContext(api_key=api_key, tenant_id=tenant_id, api_key_id=api_key_id)
|
||||
|
||||
|
||||
def parse_timestamp(timestamp: str) -> datetime | None:
|
||||
@@ -119,7 +127,19 @@ def register_mcp_tools(
|
||||
memory: MemoryEngine instance
|
||||
config: Tool configuration
|
||||
"""
|
||||
tools_to_register = config.tools or {"retain", "recall", "reflect", "list_banks", "create_bank"}
|
||||
tools_to_register = config.tools or {
|
||||
"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",
|
||||
}
|
||||
|
||||
if "retain" in tools_to_register:
|
||||
_register_retain(mcp, memory, config)
|
||||
@@ -136,6 +156,25 @@ def register_mcp_tools(
|
||||
if "create_bank" in tools_to_register:
|
||||
_register_create_bank(mcp, memory, config)
|
||||
|
||||
# Mental model tools
|
||||
if "list_mental_models" in tools_to_register:
|
||||
_register_list_mental_models(mcp, memory, config)
|
||||
|
||||
if "get_mental_model" in tools_to_register:
|
||||
_register_get_mental_model(mcp, memory, config)
|
||||
|
||||
if "create_mental_model" in tools_to_register:
|
||||
_register_create_mental_model(mcp, memory, config)
|
||||
|
||||
if "update_mental_model" in tools_to_register:
|
||||
_register_update_mental_model(mcp, memory, config)
|
||||
|
||||
if "delete_mental_model" in tools_to_register:
|
||||
_register_delete_mental_model(mcp, memory, config)
|
||||
|
||||
if "refresh_mental_model" in tools_to_register:
|
||||
_register_refresh_mental_model(mcp, memory, config)
|
||||
|
||||
|
||||
def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the retain tool."""
|
||||
@@ -511,3 +550,567 @@ def _register_create_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating bank: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
|
||||
def _validate_mental_model_inputs(
|
||||
name: str | None = None, source_query: str | None = None, max_tokens: int | None = None
|
||||
) -> str | None:
|
||||
"""Validate mental model inputs, returning an error message or None if valid."""
|
||||
if name is not None and not name.strip():
|
||||
return "name cannot be empty"
|
||||
if source_query is not None and not source_query.strip():
|
||||
return "source_query cannot be empty"
|
||||
if max_tokens is not None and (max_tokens < 256 or max_tokens > 8192):
|
||||
return f"max_tokens must be between 256 and 8192, got {max_tokens}"
|
||||
return None
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# MENTAL MODEL TOOLS
|
||||
# =========================================================================
|
||||
|
||||
|
||||
def _register_list_mental_models(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the list_mental_models tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool()
|
||||
async def list_mental_models(
|
||||
tags: list[str] | None = None,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
List mental models (pinned reflections) for a memory bank.
|
||||
|
||||
Mental models are living documents that stay current by periodically re-running
|
||||
a source query through reflect. Use them to maintain up-to-date summaries,
|
||||
preferences, or synthesized knowledge.
|
||||
|
||||
Args:
|
||||
tags: Optional tags to filter by (returns models matching any tag)
|
||||
bank_id: Optional bank to list from (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured", "items": []}'
|
||||
|
||||
models = await memory.list_mental_models(
|
||||
bank_id=target_bank,
|
||||
tags=tags,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
return json.dumps({"items": models}, indent=2, default=str)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing mental models: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}", "items": []}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool()
|
||||
async def list_mental_models(
|
||||
tags: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
List mental models (pinned reflections) for this memory bank.
|
||||
|
||||
Mental models are living documents that stay current by periodically re-running
|
||||
a source query through reflect. Use them to maintain up-to-date summaries,
|
||||
preferences, or synthesized knowledge.
|
||||
|
||||
Args:
|
||||
tags: Optional tags to filter by (returns models matching any tag)
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured", "items": []}
|
||||
|
||||
models = await memory.list_mental_models(
|
||||
bank_id=target_bank,
|
||||
tags=tags,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
return {"items": models}
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing mental models: {e}", exc_info=True)
|
||||
return {"error": str(e), "items": []}
|
||||
|
||||
|
||||
def _register_get_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the get_mental_model tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool()
|
||||
async def get_mental_model(
|
||||
mental_model_id: str,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get a specific mental model by ID.
|
||||
|
||||
Returns the full mental model including its generated content, source query,
|
||||
and metadata. Use list_mental_models first to discover available model IDs.
|
||||
|
||||
Args:
|
||||
mental_model_id: The ID of the mental model to retrieve
|
||||
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured"}'
|
||||
|
||||
model = await memory.get_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=mental_model_id,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
if model is None:
|
||||
return json.dumps({"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"})
|
||||
return json.dumps(model, indent=2, default=str)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting mental model: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool()
|
||||
async def get_mental_model(
|
||||
mental_model_id: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Get a specific mental model by ID.
|
||||
|
||||
Returns the full mental model including its generated content, source query,
|
||||
and metadata. Use list_mental_models first to discover available model IDs.
|
||||
|
||||
Args:
|
||||
mental_model_id: The ID of the mental model to retrieve
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured"}
|
||||
|
||||
model = await memory.get_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=mental_model_id,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
if model is None:
|
||||
return {"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"}
|
||||
return model
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting mental model: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the create_mental_model tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool()
|
||||
async def create_mental_model(
|
||||
name: str,
|
||||
source_query: str,
|
||||
mental_model_id: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
max_tokens: int = 2048,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a new mental model (pinned reflection).
|
||||
|
||||
A mental model is a living document generated by running the source_query through
|
||||
reflect. The content is auto-generated asynchronously - use the returned operation_id
|
||||
to track progress.
|
||||
|
||||
EXAMPLES:
|
||||
- name="Coding Preferences", source_query="What coding patterns and tools does the user prefer?"
|
||||
- name="Project Goals", source_query="What are the user's current project goals and priorities?"
|
||||
- name="Communication Style", source_query="How does the user prefer to communicate?"
|
||||
|
||||
Args:
|
||||
name: Human-readable name for the mental model
|
||||
source_query: The query to run through reflect to generate content
|
||||
mental_model_id: Optional custom ID (alphanumeric lowercase with hyphens). Auto-generated if not provided.
|
||||
tags: Optional tags for scoped visibility filtering
|
||||
max_tokens: Maximum tokens for generated content (256-8192, default: 2048)
|
||||
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured"}'
|
||||
|
||||
validation_error = _validate_mental_model_inputs(
|
||||
name=name, source_query=source_query, max_tokens=max_tokens
|
||||
)
|
||||
if validation_error:
|
||||
return json.dumps({"error": validation_error})
|
||||
|
||||
request_context = _get_request_context(config)
|
||||
|
||||
# Create with placeholder content
|
||||
model = await memory.create_mental_model(
|
||||
bank_id=target_bank,
|
||||
name=name,
|
||||
source_query=source_query,
|
||||
content="Generating content...",
|
||||
mental_model_id=mental_model_id,
|
||||
tags=tags,
|
||||
max_tokens=max_tokens,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Schedule async refresh to generate actual content
|
||||
result = await memory.submit_async_refresh_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=model["id"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"mental_model_id": model["id"],
|
||||
"operation_id": result["operation_id"],
|
||||
"status": "created",
|
||||
"message": f"Mental model '{name}' created. Content is being generated asynchronously.",
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating mental model: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool()
|
||||
async def create_mental_model(
|
||||
name: str,
|
||||
source_query: str,
|
||||
mental_model_id: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
max_tokens: int = 2048,
|
||||
) -> dict:
|
||||
"""
|
||||
Create a new mental model (pinned reflection).
|
||||
|
||||
A mental model is a living document generated by running the source_query through
|
||||
reflect. The content is auto-generated asynchronously - use the returned operation_id
|
||||
to track progress.
|
||||
|
||||
EXAMPLES:
|
||||
- name="Coding Preferences", source_query="What coding patterns and tools does the user prefer?"
|
||||
- name="Project Goals", source_query="What are the user's current project goals and priorities?"
|
||||
- name="Communication Style", source_query="How does the user prefer to communicate?"
|
||||
|
||||
Args:
|
||||
name: Human-readable name for the mental model
|
||||
source_query: The query to run through reflect to generate content
|
||||
mental_model_id: Optional custom ID (alphanumeric lowercase with hyphens). Auto-generated if not provided.
|
||||
tags: Optional tags for scoped visibility filtering
|
||||
max_tokens: Maximum tokens for generated content (256-8192, default: 2048)
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured"}
|
||||
|
||||
validation_error = _validate_mental_model_inputs(
|
||||
name=name, source_query=source_query, max_tokens=max_tokens
|
||||
)
|
||||
if validation_error:
|
||||
return {"error": validation_error}
|
||||
|
||||
request_context = _get_request_context(config)
|
||||
|
||||
model = await memory.create_mental_model(
|
||||
bank_id=target_bank,
|
||||
name=name,
|
||||
source_query=source_query,
|
||||
content="Generating content...",
|
||||
mental_model_id=mental_model_id,
|
||||
tags=tags,
|
||||
max_tokens=max_tokens,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
result = await memory.submit_async_refresh_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=model["id"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
return {
|
||||
"mental_model_id": model["id"],
|
||||
"operation_id": result["operation_id"],
|
||||
"status": "created",
|
||||
"message": f"Mental model '{name}' created. Content is being generated asynchronously.",
|
||||
}
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating mental model: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def _register_update_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the update_mental_model tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool()
|
||||
async def update_mental_model(
|
||||
mental_model_id: str,
|
||||
name: str | None = None,
|
||||
source_query: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
tags: list[str] | None = None,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Update a mental model's metadata.
|
||||
|
||||
Changes the name, source query, or tags of an existing mental model.
|
||||
To regenerate the content, use refresh_mental_model after updating the source query.
|
||||
|
||||
Args:
|
||||
mental_model_id: The ID of the mental model to update
|
||||
name: New name (leave None to keep current)
|
||||
source_query: New source query (leave None to keep current)
|
||||
max_tokens: New max tokens for content generation (256-8192, leave None to keep current)
|
||||
tags: New tags (leave None to keep current)
|
||||
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured"}'
|
||||
|
||||
validation_error = _validate_mental_model_inputs(
|
||||
name=name, source_query=source_query, max_tokens=max_tokens
|
||||
)
|
||||
if validation_error:
|
||||
return json.dumps({"error": validation_error})
|
||||
|
||||
model = await memory.update_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=mental_model_id,
|
||||
name=name,
|
||||
source_query=source_query,
|
||||
max_tokens=max_tokens,
|
||||
tags=tags,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
if model is None:
|
||||
return json.dumps({"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"})
|
||||
return json.dumps(model, indent=2, default=str)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating mental model: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool()
|
||||
async def update_mental_model(
|
||||
mental_model_id: str,
|
||||
name: str | None = None,
|
||||
source_query: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Update a mental model's metadata.
|
||||
|
||||
Changes the name, source query, or tags of an existing mental model.
|
||||
To regenerate the content, use refresh_mental_model after updating the source query.
|
||||
|
||||
Args:
|
||||
mental_model_id: The ID of the mental model to update
|
||||
name: New name (leave None to keep current)
|
||||
source_query: New source query (leave None to keep current)
|
||||
max_tokens: New max tokens for content generation (256-8192, leave None to keep current)
|
||||
tags: New tags (leave None to keep current)
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured"}
|
||||
|
||||
validation_error = _validate_mental_model_inputs(
|
||||
name=name, source_query=source_query, max_tokens=max_tokens
|
||||
)
|
||||
if validation_error:
|
||||
return {"error": validation_error}
|
||||
|
||||
model = await memory.update_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=mental_model_id,
|
||||
name=name,
|
||||
source_query=source_query,
|
||||
max_tokens=max_tokens,
|
||||
tags=tags,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
if model is None:
|
||||
return {"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"}
|
||||
return model
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating mental model: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def _register_delete_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the delete_mental_model tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_mental_model(
|
||||
mental_model_id: str,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Delete a mental model.
|
||||
|
||||
Permanently removes a mental model and its generated content.
|
||||
|
||||
Args:
|
||||
mental_model_id: The ID of the mental model to delete
|
||||
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured"}'
|
||||
|
||||
deleted = await memory.delete_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=mental_model_id,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
if not deleted:
|
||||
return json.dumps({"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"})
|
||||
return json.dumps({"status": "deleted", "mental_model_id": mental_model_id})
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting mental model: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_mental_model(
|
||||
mental_model_id: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Delete a mental model.
|
||||
|
||||
Permanently removes a mental model and its generated content.
|
||||
|
||||
Args:
|
||||
mental_model_id: The ID of the mental model to delete
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured"}
|
||||
|
||||
deleted = await memory.delete_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=mental_model_id,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
if not deleted:
|
||||
return {"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"}
|
||||
return {"status": "deleted", "mental_model_id": mental_model_id}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting mental model: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def _register_refresh_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the refresh_mental_model tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool()
|
||||
async def refresh_mental_model(
|
||||
mental_model_id: str,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Refresh a mental model by re-running its source query.
|
||||
|
||||
Schedules an async task to re-run the source query through reflect and update the
|
||||
mental model's content with fresh results. Use this after adding new memories or
|
||||
when the mental model's content may be stale.
|
||||
|
||||
Args:
|
||||
mental_model_id: The ID of the mental model to refresh
|
||||
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured"}'
|
||||
|
||||
result = await memory.submit_async_refresh_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=mental_model_id,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"operation_id": result["operation_id"],
|
||||
"status": "queued",
|
||||
"message": f"Refresh queued for mental model '{mental_model_id}'.",
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing mental model: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool()
|
||||
async def refresh_mental_model(
|
||||
mental_model_id: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Refresh a mental model by re-running its source query.
|
||||
|
||||
Schedules an async task to re-run the source query through reflect and update the
|
||||
mental model's content with fresh results. Use this after adding new memories or
|
||||
when the mental model's content may be stale.
|
||||
|
||||
Args:
|
||||
mental_model_id: The ID of the mental model to refresh
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured"}
|
||||
|
||||
result = await memory.submit_async_refresh_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=mental_model_id,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
return {
|
||||
"operation_id": result["operation_id"],
|
||||
"status": "queued",
|
||||
"message": f"Refresh queued for mental model '{mental_model_id}'.",
|
||||
}
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing mental model: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
@@ -25,12 +25,77 @@ from alembic.config import Config
|
||||
from alembic.script.revision import ResolutionError
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from .utils import mask_network_location
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Advisory lock ID for migrations (arbitrary unique number)
|
||||
MIGRATION_LOCK_ID = 123456789
|
||||
|
||||
|
||||
def _detect_vector_extension(conn, vector_extension: str = "pgvector") -> str:
|
||||
"""
|
||||
Validate vector extension: 'pgvector', 'vchord', or 'pgvectorscale'.
|
||||
|
||||
Args:
|
||||
conn: SQLAlchemy connection object
|
||||
vector_extension: Configured extension ("pgvector", "vchord", or "pgvectorscale")
|
||||
|
||||
Returns:
|
||||
"pgvector", "vchord", "pgvectorscale", or "pg_diskann"
|
||||
|
||||
Raises:
|
||||
RuntimeError: If configured extension is not installed
|
||||
"""
|
||||
# Verify the configured extension is installed
|
||||
if vector_extension == "pgvectorscale":
|
||||
# pgvectorscale/DiskANN requires pgvector to be installed first
|
||||
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
|
||||
if not pgvector_check:
|
||||
raise RuntimeError(
|
||||
"DiskANN (pgvectorscale/pg_diskann) requires pgvector to be installed. "
|
||||
"Install it with: CREATE EXTENSION vector; then CREATE EXTENSION vectorscale CASCADE; (or pg_diskann on Azure)"
|
||||
)
|
||||
|
||||
# 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:
|
||||
logger.debug("Using vector extension: pgvectorscale (DiskANN)")
|
||||
return "pgvectorscale"
|
||||
elif pg_diskann_check:
|
||||
logger.debug("Using vector extension: pg_diskann (Azure DiskANN)")
|
||||
return "pg_diskann" # Return distinct name for parameter handling
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"Configured vector extension 'pgvectorscale' not found. "
|
||||
"Install either:\n"
|
||||
" - pgvectorscale (open source): 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;"
|
||||
)
|
||||
logger.debug("Using configured vector extension: vchord")
|
||||
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;"
|
||||
)
|
||||
logger.debug("Using configured vector extension: pgvector")
|
||||
return "pgvector"
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid vector_extension: {vector_extension}. Must be 'pgvector', 'vchord', or 'pgvectorscale'"
|
||||
)
|
||||
|
||||
|
||||
def _get_schema_lock_id(schema: str) -> int:
|
||||
"""
|
||||
Generate a unique advisory lock ID for a schema.
|
||||
@@ -54,7 +119,7 @@ def _run_migrations_internal(database_url: str, script_location: str, schema: st
|
||||
"""
|
||||
schema_name = schema or "public"
|
||||
logger.info(f"Running database migrations to head for schema '{schema_name}'...")
|
||||
logger.info(f"Database URL: {database_url}")
|
||||
logger.info(f"Database URL: {mask_network_location(database_url)}")
|
||||
logger.info(f"Script location: {script_location}")
|
||||
|
||||
# Create Alembic configuration programmatically (no alembic.ini needed)
|
||||
@@ -165,6 +230,123 @@ def run_migrations(
|
||||
logger.debug("Migration advisory lock acquired")
|
||||
|
||||
try:
|
||||
# Ensure pgvector extension is installed globally BEFORE schema migrations
|
||||
# This is critical: the extension must exist database-wide before any schema
|
||||
# migrations run, otherwise custom schemas won't have access to vector types
|
||||
logger.debug("Checking pgvector extension availability...")
|
||||
|
||||
# First, check if extension already exists
|
||||
ext_check = conn.execute(
|
||||
text(
|
||||
"SELECT extname, nspname FROM pg_extension e "
|
||||
"JOIN pg_namespace n ON e.extnamespace = n.oid "
|
||||
"WHERE extname = 'vector'"
|
||||
)
|
||||
).fetchone()
|
||||
|
||||
if ext_check:
|
||||
# Extension exists - check if in correct schema
|
||||
ext_schema = ext_check[1]
|
||||
if ext_schema == "public":
|
||||
logger.info("pgvector extension found in public schema - ready to use")
|
||||
else:
|
||||
# Extension in wrong schema - try to fix if we have permissions
|
||||
logger.warning(
|
||||
f"pgvector extension found in schema '{ext_schema}' instead of 'public'. "
|
||||
f"Attempting to relocate..."
|
||||
)
|
||||
try:
|
||||
conn.execute(text("DROP EXTENSION vector CASCADE"))
|
||||
conn.execute(text("SET search_path TO public"))
|
||||
conn.execute(text("CREATE EXTENSION vector"))
|
||||
conn.commit()
|
||||
logger.info("pgvector extension relocated to public schema")
|
||||
except Exception as e:
|
||||
# Failed to relocate - log but don't fail if extension exists somewhere
|
||||
logger.warning(
|
||||
f"Could not relocate pgvector extension to public schema: {e}. "
|
||||
f"Continuing with extension in '{ext_schema}' schema."
|
||||
)
|
||||
conn.rollback()
|
||||
else:
|
||||
# Extension doesn't exist - try to install
|
||||
logger.info("pgvector extension not found, attempting to install...")
|
||||
try:
|
||||
conn.execute(text("SET search_path TO public"))
|
||||
conn.execute(text("CREATE EXTENSION vector"))
|
||||
conn.commit()
|
||||
logger.info("pgvector extension installed in public schema")
|
||||
except Exception as e:
|
||||
# Installation failed - this is only fatal if extension truly doesn't exist
|
||||
# Check one more time in case another process installed it
|
||||
conn.rollback()
|
||||
ext_recheck = conn.execute(
|
||||
text(
|
||||
"SELECT nspname FROM pg_extension e "
|
||||
"JOIN pg_namespace n ON e.extnamespace = n.oid "
|
||||
"WHERE extname = 'vector'"
|
||||
)
|
||||
).fetchone()
|
||||
|
||||
if ext_recheck:
|
||||
logger.warning(
|
||||
f"Could not install pgvector extension (permission denied?), "
|
||||
f"but extension exists in '{ext_recheck[0]}' schema. Continuing..."
|
||||
)
|
||||
else:
|
||||
# Extension truly doesn't exist and we can't install it
|
||||
logger.error(
|
||||
f"pgvector extension is not installed and cannot be installed: {e}. "
|
||||
f"Please ensure pgvector is installed by a database administrator. "
|
||||
f"See: https://github.com/pgvector/pgvector#installation"
|
||||
)
|
||||
raise RuntimeError(
|
||||
"pgvector extension is required but not installed. "
|
||||
"Please install it with: CREATE EXTENSION vector;"
|
||||
) from e
|
||||
|
||||
# If using pgvectorscale, ensure vectorscale extension is also installed
|
||||
vector_extension = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
|
||||
if vector_extension == "pgvectorscale":
|
||||
logger.debug("Checking pgvectorscale (vectorscale) extension availability...")
|
||||
|
||||
vectorscale_check = conn.execute(
|
||||
text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")
|
||||
).scalar()
|
||||
|
||||
if vectorscale_check:
|
||||
logger.info("pgvectorscale extension already installed")
|
||||
else:
|
||||
# Extension doesn't exist - try to install
|
||||
logger.info("pgvectorscale extension not found, attempting to install...")
|
||||
try:
|
||||
conn.execute(text("CREATE EXTENSION vectorscale CASCADE"))
|
||||
conn.commit()
|
||||
logger.info("pgvectorscale extension installed successfully")
|
||||
except Exception as e:
|
||||
# Installation failed - check one more time in case another process installed it
|
||||
conn.rollback()
|
||||
vectorscale_recheck = conn.execute(
|
||||
text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")
|
||||
).fetchone()
|
||||
|
||||
if vectorscale_recheck:
|
||||
logger.warning(
|
||||
"Could not install pgvectorscale extension (permission denied?), "
|
||||
"but extension exists. Continuing..."
|
||||
)
|
||||
else:
|
||||
# Extension truly doesn't exist and we can't install it
|
||||
logger.error(
|
||||
f"pgvectorscale extension is not installed and cannot be installed: {e}. "
|
||||
f"Please ensure pgvectorscale is installed by a database administrator. "
|
||||
f"See: https://github.com/timescale/pgvectorscale#installation"
|
||||
)
|
||||
raise RuntimeError(
|
||||
"pgvectorscale extension is required but not installed. "
|
||||
"Please install it with: CREATE EXTENSION vectorscale CASCADE;"
|
||||
) from e
|
||||
|
||||
# Run migrations while holding the lock
|
||||
_run_migrations_internal(database_url, script_location, schema=schema)
|
||||
finally:
|
||||
@@ -247,6 +429,7 @@ def ensure_embedding_dimension(
|
||||
database_url: str,
|
||||
required_dimension: int,
|
||||
schema: str | None = None,
|
||||
vector_extension: str = "pgvector",
|
||||
) -> None:
|
||||
"""
|
||||
Ensure the embedding column dimension matches the model's dimension.
|
||||
@@ -261,6 +444,7 @@ def ensure_embedding_dimension(
|
||||
database_url: SQLAlchemy database URL
|
||||
required_dimension: The embedding dimension required by the model
|
||||
schema: Target PostgreSQL schema name (None for public)
|
||||
vector_extension: Configured vector extension ("pgvector" or "vchord")
|
||||
|
||||
Raises:
|
||||
RuntimeError: If dimension mismatch with existing data
|
||||
@@ -284,6 +468,10 @@ def ensure_embedding_dimension(
|
||||
logger.debug(f"memory_units table does not exist in schema '{schema_name}', skipping dimension check")
|
||||
return
|
||||
|
||||
# Detect which vector extension is available
|
||||
vector_ext = _detect_vector_extension(conn, vector_extension)
|
||||
logger.info(f"Using vector extension: {vector_ext}")
|
||||
|
||||
# Get current column dimension from pg_attribute
|
||||
# pgvector stores dimension in atttypmod
|
||||
current_dim = conn.execute(
|
||||
@@ -331,8 +519,7 @@ def ensure_embedding_dimension(
|
||||
# Table is empty, safe to alter column
|
||||
logger.info(f"Altering embedding column dimension from {current_dimension} to {required_dimension}")
|
||||
|
||||
# Drop the HNSW index on embedding column if it exists
|
||||
# Only drop indexes that use 'hnsw' and reference the 'embedding' column
|
||||
# Drop existing vector index (works for both HNSW and vchordrq)
|
||||
conn.execute(
|
||||
text(f"""
|
||||
DO $$
|
||||
@@ -342,7 +529,7 @@ def ensure_embedding_dimension(
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE schemaname = '{schema_name}'
|
||||
AND tablename = 'memory_units'
|
||||
AND indexdef LIKE '%hnsw%'
|
||||
AND (indexdef LIKE '%hnsw%' OR indexdef LIKE '%vchordrq%')
|
||||
AND indexdef LIKE '%embedding%'
|
||||
LOOP
|
||||
EXECUTE 'DROP INDEX IF EXISTS {schema_name}.' || idx_name;
|
||||
@@ -357,15 +544,452 @@ def ensure_embedding_dimension(
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Recreate the HNSW index
|
||||
conn.execute(
|
||||
text(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_units_embedding_hnsw
|
||||
ON {schema_name}.memory_units
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
WITH (m = 16, ef_construction = 64)
|
||||
""")
|
||||
)
|
||||
# Recreate index with appropriate type based on detected extension
|
||||
if vector_ext == "pgvectorscale":
|
||||
conn.execute(
|
||||
text(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_units_embedding_diskann
|
||||
ON {schema_name}.memory_units
|
||||
USING diskann (embedding vector_cosine_ops)
|
||||
WITH (num_neighbors = 50)
|
||||
""")
|
||||
)
|
||||
logger.info(f"Created DiskANN index for {required_dimension}-dimensional embeddings")
|
||||
elif vector_ext == "vchord":
|
||||
conn.execute(
|
||||
text(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_units_embedding_vchordrq
|
||||
ON {schema_name}.memory_units
|
||||
USING vchordrq (embedding vector_l2_ops)
|
||||
""")
|
||||
)
|
||||
logger.info(f"Created vchordrq index for {required_dimension}-dimensional embeddings")
|
||||
else: # pgvector
|
||||
conn.execute(
|
||||
text(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_units_embedding_hnsw
|
||||
ON {schema_name}.memory_units
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
WITH (m = 16, ef_construction = 64)
|
||||
""")
|
||||
)
|
||||
logger.info(f"Created HNSW index for {required_dimension}-dimensional embeddings")
|
||||
conn.commit()
|
||||
|
||||
logger.info(f"Successfully changed embedding dimension to {required_dimension}")
|
||||
|
||||
|
||||
def ensure_vector_extension(
|
||||
database_url: str,
|
||||
vector_extension: str = "pgvector",
|
||||
schema: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Ensure the vector indexes match the configured vector extension.
|
||||
|
||||
This function checks the current vector index type in the database
|
||||
and adjusts it if necessary:
|
||||
- If index type matches configured extension: no action needed
|
||||
- If they differ and tables are empty: drop old indexes, recreate with new type
|
||||
- If they differ and tables have data: raise error with migration guidance
|
||||
|
||||
Args:
|
||||
database_url: SQLAlchemy database URL
|
||||
vector_extension: Configured vector extension ("pgvector" or "vchord")
|
||||
schema: Target PostgreSQL schema name (None for public)
|
||||
|
||||
Raises:
|
||||
RuntimeError: If extension mismatch with existing data
|
||||
"""
|
||||
schema_name = schema or "public"
|
||||
|
||||
engine = create_engine(database_url)
|
||||
with engine.connect() as conn:
|
||||
# Detect which vector extension should be used
|
||||
target_ext = _detect_vector_extension(conn, vector_extension)
|
||||
logger.info(f"Target vector extension: {target_ext}")
|
||||
|
||||
# Tables with vector indexes to check
|
||||
tables_to_check = [
|
||||
("memory_units", "idx_memory_units_embedding"),
|
||||
("learnings", "idx_learnings_embedding"),
|
||||
("pinned_reflections", "idx_pinned_reflections_embedding"),
|
||||
]
|
||||
|
||||
# Determine target index type
|
||||
if target_ext in ("pgvectorscale", "pg_diskann"):
|
||||
target_index_type = "diskann"
|
||||
elif target_ext == "vchord":
|
||||
target_index_type = "vchordrq"
|
||||
else:
|
||||
target_index_type = "hnsw"
|
||||
|
||||
mismatched_tables = []
|
||||
tables_with_data = []
|
||||
|
||||
for table_name, index_name in tables_to_check:
|
||||
# Check if table exists
|
||||
table_exists = conn.execute(
|
||||
text("""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = :schema AND table_name = :table_name
|
||||
)
|
||||
"""),
|
||||
{"schema": schema_name, "table_name": table_name},
|
||||
).scalar()
|
||||
|
||||
if not table_exists:
|
||||
logger.debug(f"Table {table_name} does not exist in schema '{schema_name}', skipping")
|
||||
continue
|
||||
|
||||
# Check current index type by querying pg_indexes
|
||||
current_index_info = conn.execute(
|
||||
text("""
|
||||
SELECT indexdef
|
||||
FROM pg_indexes
|
||||
WHERE schemaname = :schema
|
||||
AND tablename = :table_name
|
||||
AND indexname LIKE :index_pattern
|
||||
"""),
|
||||
{"schema": schema_name, "table_name": table_name, "index_pattern": "%embedding%"},
|
||||
).fetchone()
|
||||
|
||||
if not current_index_info:
|
||||
logger.warning(f"No embedding index found for {table_name}, will create it")
|
||||
mismatched_tables.append((table_name, index_name, None))
|
||||
continue
|
||||
|
||||
indexdef = current_index_info[0].lower()
|
||||
if "diskann" in indexdef:
|
||||
current_index_type = "diskann"
|
||||
elif "vchordrq" in indexdef:
|
||||
current_index_type = "vchordrq"
|
||||
elif "hnsw" in indexdef:
|
||||
current_index_type = "hnsw"
|
||||
else:
|
||||
logger.warning(f"Unknown index type for {table_name}: {indexdef}")
|
||||
continue
|
||||
|
||||
# Check if index type matches target
|
||||
if current_index_type != target_index_type:
|
||||
logger.info(
|
||||
f"Index type mismatch on {table_name}: current={current_index_type}, target={target_index_type}"
|
||||
)
|
||||
mismatched_tables.append((table_name, index_name, current_index_type))
|
||||
|
||||
# Check if table has data
|
||||
row_count = conn.execute(
|
||||
text(f"SELECT COUNT(*) FROM {schema_name}.{table_name} WHERE embedding IS NOT NULL")
|
||||
).scalar()
|
||||
|
||||
if row_count > 0:
|
||||
tables_with_data.append((table_name, row_count))
|
||||
else:
|
||||
logger.debug(f"Index type OK for {table_name}: {current_index_type}")
|
||||
|
||||
# If no mismatches, we're done
|
||||
if not mismatched_tables:
|
||||
logger.debug(f"All vector indexes match configured extension: {target_ext}")
|
||||
return
|
||||
|
||||
# If there's data in any mismatched table, raise error
|
||||
if tables_with_data:
|
||||
table_list = ", ".join([f"{table}({count} rows)" for table, count in tables_with_data])
|
||||
# Map index type back to extension name for error message
|
||||
current_ext_name = {"diskann": "pgvectorscale", "vchordrq": "vchord", "hnsw": "pgvector"}.get(
|
||||
current_index_type, current_index_type
|
||||
)
|
||||
|
||||
raise RuntimeError(
|
||||
f"Cannot change vector extension from {current_index_type} to {target_index_type}: "
|
||||
f"the following tables contain data: {table_list}. "
|
||||
f"To change vector extension, you must either:\n"
|
||||
f" 1. Re-embed all data: DELETE FROM {schema_name}.memory_units; "
|
||||
f"DELETE FROM {schema_name}.learnings; DELETE FROM {schema_name}.pinned_reflections; then restart\n"
|
||||
f" 2. Use the current vector extension (set HINDSIGHT_API_VECTOR_EXTENSION='{current_ext_name}')"
|
||||
)
|
||||
|
||||
# Tables are empty, safe to recreate indexes
|
||||
logger.info(f"Recreating vector indexes for {target_ext}")
|
||||
|
||||
for table_name, index_name, current_type in mismatched_tables:
|
||||
# Drop existing index if it exists
|
||||
if current_type:
|
||||
logger.info(f"Dropping {current_type} index on {table_name}")
|
||||
conn.execute(text(f"DROP INDEX IF EXISTS {schema_name}.{index_name}"))
|
||||
|
||||
# Create new index with appropriate type
|
||||
if target_ext == "pgvectorscale":
|
||||
logger.info(f"Creating DiskANN index on {table_name} (pgvectorscale)")
|
||||
conn.execute(
|
||||
text(f"""
|
||||
CREATE INDEX IF NOT EXISTS {index_name}
|
||||
ON {schema_name}.{table_name}
|
||||
USING diskann (embedding vector_cosine_ops)
|
||||
WITH (num_neighbors = 50)
|
||||
""")
|
||||
)
|
||||
elif target_ext == "pg_diskann":
|
||||
logger.info(f"Creating DiskANN index on {table_name} (pg_diskann/Azure)")
|
||||
conn.execute(
|
||||
text(f"""
|
||||
CREATE INDEX IF NOT EXISTS {index_name}
|
||||
ON {schema_name}.{table_name}
|
||||
USING diskann (embedding vector_cosine_ops)
|
||||
WITH (max_neighbors = 50)
|
||||
""")
|
||||
)
|
||||
elif target_ext == "vchord":
|
||||
logger.info(f"Creating vchordrq index on {table_name}")
|
||||
conn.execute(
|
||||
text(f"""
|
||||
CREATE INDEX IF NOT EXISTS {index_name}
|
||||
ON {schema_name}.{table_name}
|
||||
USING vchordrq (embedding vector_l2_ops)
|
||||
""")
|
||||
)
|
||||
else: # pgvector
|
||||
logger.info(f"Creating HNSW index on {table_name}")
|
||||
conn.execute(
|
||||
text(f"""
|
||||
CREATE INDEX IF NOT EXISTS {index_name}
|
||||
ON {schema_name}.{table_name}
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
WITH (m = 16, ef_construction = 64)
|
||||
""")
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"Successfully migrated vector indexes to {target_ext}")
|
||||
|
||||
|
||||
def ensure_text_search_extension(
|
||||
database_url: str,
|
||||
text_search_extension: str = "native",
|
||||
schema: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Ensure the text search columns and indexes match the configured extension.
|
||||
|
||||
This function checks the current search_vector column type and index type
|
||||
in the database and adjusts them if necessary:
|
||||
- If they match configured extension: no action needed
|
||||
- If they differ and tables are empty: drop old column/index, recreate with new type
|
||||
- If they differ and tables have data: raise error with migration guidance
|
||||
|
||||
Args:
|
||||
database_url: SQLAlchemy database URL
|
||||
text_search_extension: Configured text search extension ("native" or "vchord")
|
||||
schema: Target PostgreSQL schema name (None for public)
|
||||
|
||||
Raises:
|
||||
RuntimeError: If extension mismatch with existing data
|
||||
"""
|
||||
schema_name = schema or "public"
|
||||
|
||||
engine = create_engine(database_url)
|
||||
with engine.connect() as conn:
|
||||
# Tables with search_vector columns to check
|
||||
tables_to_check = [
|
||||
"memory_units",
|
||||
"reflections", # Renamed from pinned_reflections in p1k2l3m4n5o6 migration
|
||||
]
|
||||
|
||||
# Determine target column type and index type
|
||||
if text_search_extension == "vchord":
|
||||
target_column_type = "bm25vector"
|
||||
target_index_type = "bm25"
|
||||
elif text_search_extension == "pg_textsearch":
|
||||
target_column_type = "text"
|
||||
target_index_type = "bm25"
|
||||
else: # native
|
||||
target_column_type = "tsvector"
|
||||
target_index_type = "gin"
|
||||
|
||||
mismatched_tables = []
|
||||
tables_with_data = []
|
||||
|
||||
for table_name in tables_to_check:
|
||||
# Check if table exists
|
||||
table_exists = conn.execute(
|
||||
text("""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = :schema AND table_name = :table_name
|
||||
)
|
||||
"""),
|
||||
{"schema": schema_name, "table_name": table_name},
|
||||
).scalar()
|
||||
|
||||
if not table_exists:
|
||||
logger.debug(f"Table {table_name} does not exist in schema '{schema_name}', skipping")
|
||||
continue
|
||||
|
||||
# Get current column type from information_schema
|
||||
current_column_info = conn.execute(
|
||||
text("""
|
||||
SELECT data_type, udt_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = :schema
|
||||
AND table_name = :table_name
|
||||
AND column_name = 'search_vector'
|
||||
"""),
|
||||
{"schema": schema_name, "table_name": table_name},
|
||||
).fetchone()
|
||||
|
||||
if not current_column_info:
|
||||
logger.warning(f"No search_vector column found for {table_name}, will create it")
|
||||
mismatched_tables.append((table_name, None, None))
|
||||
continue
|
||||
|
||||
# Check column type (udt_name contains the actual type: tsvector, bm25vector, etc.)
|
||||
current_column_type = current_column_info[1] # udt_name
|
||||
|
||||
# Get current index type
|
||||
current_index_info = conn.execute(
|
||||
text("""
|
||||
SELECT am.amname
|
||||
FROM pg_indexes pi
|
||||
JOIN pg_class c ON c.relname = pi.indexname
|
||||
JOIN pg_am am ON am.oid = c.relam
|
||||
WHERE pi.schemaname = :schema
|
||||
AND pi.tablename = :table_name
|
||||
AND pi.indexname LIKE '%text_search%'
|
||||
"""),
|
||||
{"schema": schema_name, "table_name": table_name},
|
||||
).fetchone()
|
||||
|
||||
current_index_type = current_index_info[0] if current_index_info else None
|
||||
|
||||
# Check if column and index types match target
|
||||
column_matches = current_column_type == target_column_type
|
||||
index_matches = current_index_type == target_index_type if current_index_type else False
|
||||
|
||||
if not (column_matches and index_matches):
|
||||
logger.info(
|
||||
f"Text search mismatch on {table_name}: "
|
||||
f"column={current_column_type} (want {target_column_type}), "
|
||||
f"index={current_index_type} (want {target_index_type})"
|
||||
)
|
||||
mismatched_tables.append((table_name, current_column_type, current_index_type))
|
||||
|
||||
# Check if table has data
|
||||
row_count = conn.execute(text(f"SELECT COUNT(*) FROM {schema_name}.{table_name}")).scalar()
|
||||
|
||||
if row_count > 0:
|
||||
tables_with_data.append((table_name, row_count))
|
||||
else:
|
||||
logger.debug(f"Text search OK for {table_name}: {current_column_type}/{current_index_type}")
|
||||
|
||||
# If no mismatches, we're done
|
||||
if not mismatched_tables:
|
||||
logger.debug(f"All text search columns/indexes match configured extension: {text_search_extension}")
|
||||
return
|
||||
|
||||
# If there's data in any mismatched table, raise error
|
||||
if tables_with_data:
|
||||
table_list = ", ".join([f"{table}({count} rows)" for table, count in tables_with_data])
|
||||
# Detect current extension from column type
|
||||
current_col_type = mismatched_tables[0][1]
|
||||
if current_col_type == "tsvector":
|
||||
current_ext = "native"
|
||||
elif current_col_type == "bm25vector":
|
||||
current_ext = "vchord"
|
||||
elif current_col_type == "text":
|
||||
current_ext = "pg_textsearch"
|
||||
else:
|
||||
current_ext = "unknown"
|
||||
raise RuntimeError(
|
||||
f"Cannot change text search extension from {current_ext} to {text_search_extension}: "
|
||||
f"the following tables contain data: {table_list}. "
|
||||
f"To change text search extension, you must either:\n"
|
||||
f" 1. Clear all data: DELETE FROM {schema_name}.memory_units; "
|
||||
f"DELETE FROM {schema_name}.reflections; then restart\n"
|
||||
f" 2. Use the current text search extension (set HINDSIGHT_API_TEXT_SEARCH_EXTENSION='{current_ext}')"
|
||||
)
|
||||
|
||||
# Tables are empty, safe to recreate columns/indexes
|
||||
logger.info(f"Recreating text search columns/indexes for {text_search_extension}")
|
||||
|
||||
for table_name, current_col_type, current_idx_type in mismatched_tables:
|
||||
# Drop existing index if it exists
|
||||
if current_idx_type:
|
||||
logger.info(f"Dropping {current_idx_type} index on {table_name}")
|
||||
conn.execute(
|
||||
text(f"""
|
||||
DROP INDEX IF EXISTS {schema_name}.idx_{table_name.replace(".", "_")}_text_search
|
||||
""")
|
||||
)
|
||||
|
||||
# Drop existing column if it exists
|
||||
if current_col_type:
|
||||
logger.info(f"Dropping {current_col_type} column on {table_name}")
|
||||
conn.execute(text(f"ALTER TABLE {schema_name}.{table_name} DROP COLUMN IF EXISTS search_vector"))
|
||||
|
||||
# Create new column with appropriate type
|
||||
if text_search_extension == "vchord":
|
||||
logger.info(f"Creating bm25vector column on {table_name}")
|
||||
# Note: vchord_bm25 extension creates types in bm25_catalog schema
|
||||
conn.execute(
|
||||
text(f"ALTER TABLE {schema_name}.{table_name} ADD COLUMN search_vector bm25_catalog.bm25vector")
|
||||
)
|
||||
|
||||
# Create BM25 index
|
||||
logger.info(f"Creating BM25 index on {table_name}")
|
||||
conn.execute(
|
||||
text(f"""
|
||||
CREATE INDEX idx_{table_name.replace(".", "_")}_text_search
|
||||
ON {schema_name}.{table_name}
|
||||
USING bm25 (search_vector bm25_catalog.bm25_ops)
|
||||
""")
|
||||
)
|
||||
elif text_search_extension == "pg_textsearch":
|
||||
logger.info(f"Creating TEXT column on {table_name}")
|
||||
# Dummy TEXT column for consistency (indexes operate on base columns)
|
||||
conn.execute(text(f"ALTER TABLE {schema_name}.{table_name} ADD COLUMN search_vector TEXT"))
|
||||
|
||||
# Create BM25 index on expression
|
||||
logger.info(f"Creating BM25 index on {table_name}")
|
||||
# Different expression for each table
|
||||
if table_name == "memory_units":
|
||||
index_expr = "(COALESCE(text, '') || ' ' || COALESCE(context, ''))"
|
||||
else: # reflections
|
||||
index_expr = "(COALESCE(name, '') || ' ' || content)"
|
||||
|
||||
conn.execute(
|
||||
text(f"""
|
||||
CREATE INDEX idx_{table_name.replace(".", "_")}_text_search
|
||||
ON {schema_name}.{table_name}
|
||||
USING bm25({index_expr})
|
||||
WITH (text_config='english')
|
||||
""")
|
||||
)
|
||||
else: # native
|
||||
logger.info(f"Creating tsvector column on {table_name}")
|
||||
# Different GENERATED expression for each table
|
||||
if table_name == "memory_units":
|
||||
generated_expr = "to_tsvector('english', COALESCE(text, '') || ' ' || COALESCE(context, ''))"
|
||||
else: # reflections
|
||||
generated_expr = "to_tsvector('english', COALESCE(name, '') || ' ' || content)"
|
||||
|
||||
conn.execute(
|
||||
text(f"""
|
||||
ALTER TABLE {schema_name}.{table_name}
|
||||
ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS ({generated_expr}) STORED
|
||||
""")
|
||||
)
|
||||
|
||||
# Create GIN index
|
||||
logger.info(f"Creating GIN index on {table_name}")
|
||||
conn.execute(
|
||||
text(f"""
|
||||
CREATE INDEX idx_{table_name.replace(".", "_")}_text_search
|
||||
ON {schema_name}.{table_name}
|
||||
USING gin(search_vector)
|
||||
""")
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"Successfully migrated text search to {text_search_extension}")
|
||||
|
||||
@@ -20,7 +20,8 @@ class RequestContext:
|
||||
api_key: str | None = None
|
||||
api_key_id: str | None = None # UUID of the API key used for authentication
|
||||
tenant_id: str | None = None # Tenant identifier (set by extension after auth)
|
||||
internal: bool = False # True for background/internal operations (not user-visible)
|
||||
internal: bool = False # True for background/internal operations (skips extension auth)
|
||||
user_initiated: bool = False # True for async operations that originated from a user request
|
||||
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
"""
|
||||
OpenTelemetry distributed tracing instrumentation for Hindsight API.
|
||||
|
||||
This module provides tracing for:
|
||||
- LLM API calls with full prompts/completions following GenAI semantic conventions
|
||||
- Token usage and model information
|
||||
- Error tracking and finish reasons
|
||||
|
||||
Tracing is conditional and disabled by default. When enabled, traces are exported
|
||||
to Langfuse (or any OTLP-compatible backend) via OTLP HTTP protocol.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from opentelemetry.trace import Status, StatusCode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _serialize_for_span(obj: Any) -> str:
|
||||
"""Serialize an object for span recording, handling Pydantic models."""
|
||||
if isinstance(obj, str):
|
||||
return obj
|
||||
if hasattr(obj, "model_dump_json"):
|
||||
# Pydantic v2 model
|
||||
return obj.model_dump_json()
|
||||
if hasattr(obj, "json"):
|
||||
# Pydantic v1 model
|
||||
return obj.json()
|
||||
if hasattr(obj, "model_dump"):
|
||||
# Pydantic v2 model - convert to dict then json
|
||||
return json.dumps(obj.model_dump())
|
||||
if hasattr(obj, "dict"):
|
||||
# Pydantic v1 model - convert to dict then json
|
||||
return json.dumps(obj.dict())
|
||||
# Fallback to json.dumps for dicts and other types
|
||||
return json.dumps(obj)
|
||||
|
||||
|
||||
# No-op tracer for when tracing is disabled
|
||||
class NoOpTracer:
|
||||
"""No-op tracer that provides the same interface as OpenTelemetry Tracer but does nothing."""
|
||||
|
||||
def start_as_current_span(self, name: str, **kwargs):
|
||||
"""Return a no-op context manager that yields a NoOpSpan."""
|
||||
from contextlib import contextmanager
|
||||
|
||||
@contextmanager
|
||||
def noop_span_context():
|
||||
yield NoOpSpan()
|
||||
|
||||
return noop_span_context()
|
||||
|
||||
def start_span(self, name: str, **kwargs):
|
||||
"""Return a no-op span."""
|
||||
return NoOpSpan()
|
||||
|
||||
|
||||
class NoOpSpan:
|
||||
"""No-op span that provides the same interface as OpenTelemetry Span but does nothing."""
|
||||
|
||||
def set_attribute(self, key: str, value: Any) -> None:
|
||||
"""No-op."""
|
||||
pass
|
||||
|
||||
def set_status(self, status: Any) -> None:
|
||||
"""No-op."""
|
||||
pass
|
||||
|
||||
def record_exception(self, exception: Exception) -> None:
|
||||
"""No-op."""
|
||||
pass
|
||||
|
||||
def add_event(self, name: str, attributes: dict | None = None) -> None:
|
||||
"""No-op."""
|
||||
pass
|
||||
|
||||
def end(self, end_time: int | None = None) -> None:
|
||||
"""No-op."""
|
||||
pass
|
||||
|
||||
|
||||
# Global tracer instance
|
||||
_tracer: trace.Tracer | NoOpTracer = NoOpTracer()
|
||||
_tracing_enabled: bool = False
|
||||
|
||||
|
||||
# GenAI semantic convention attribute names (based on v1.37 spec)
|
||||
class GenAIAttributes:
|
||||
"""GenAI semantic convention attribute names."""
|
||||
|
||||
# Operation and provider
|
||||
OPERATION_NAME = "gen_ai.operation.name"
|
||||
PROVIDER_NAME = "gen_ai.provider.name"
|
||||
|
||||
# Model information
|
||||
REQUEST_MODEL = "gen_ai.request.model"
|
||||
RESPONSE_MODEL = "gen_ai.response.model"
|
||||
|
||||
# Token usage
|
||||
USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
|
||||
USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
|
||||
|
||||
# Messages and prompts
|
||||
SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
|
||||
INPUT_MESSAGES = "gen_ai.input.messages"
|
||||
OUTPUT_MESSAGES = "gen_ai.output.messages"
|
||||
|
||||
# Response metadata
|
||||
FINISH_REASONS = "gen_ai.response.finish_reasons"
|
||||
|
||||
# Error tracking
|
||||
ERROR_TYPE = "error.type"
|
||||
|
||||
|
||||
# Provider name mapping (Hindsight internal -> GenAI semantic convention)
|
||||
PROVIDER_NAME_MAPPING = {
|
||||
"openai": "openai",
|
||||
"anthropic": "anthropic",
|
||||
"gemini": "google",
|
||||
"vertexai": "google",
|
||||
"groq": "groq",
|
||||
"ollama": "ollama",
|
||||
"lmstudio": "lmstudio",
|
||||
"openai-codex": "openai",
|
||||
"claude-code": "anthropic",
|
||||
"mock": "mock",
|
||||
}
|
||||
|
||||
|
||||
def initialize_tracing(
|
||||
service_name: str,
|
||||
endpoint: str,
|
||||
headers: Optional[str] = None,
|
||||
deployment_environment: str = "development",
|
||||
) -> None:
|
||||
"""
|
||||
Initialize OpenTelemetry tracing with OTLP exporter.
|
||||
|
||||
Args:
|
||||
service_name: Name of the service for resource attributes
|
||||
endpoint: OTLP endpoint URL (e.g., https://cloud.langfuse.com/api/public/otel)
|
||||
headers: Optional headers in format "key1=value1,key2=value2"
|
||||
deployment_environment: Deployment environment (e.g., development, staging, production)
|
||||
"""
|
||||
global _tracer, _tracing_enabled
|
||||
|
||||
# Create resource with service information
|
||||
resource = Resource.create(
|
||||
{
|
||||
"service.name": service_name,
|
||||
"service.version": "0.4.8", # Could import from __version__
|
||||
"deployment.environment.name": deployment_environment,
|
||||
}
|
||||
)
|
||||
|
||||
# Parse headers
|
||||
headers_dict = {}
|
||||
if headers:
|
||||
for pair in headers.split(","):
|
||||
if "=" in pair:
|
||||
key, value = pair.split("=", 1)
|
||||
headers_dict[key.strip()] = value.strip()
|
||||
|
||||
# Create OTLP HTTP exporter
|
||||
# Note: Langfuse expects /v1/traces path appended to base endpoint
|
||||
otlp_endpoint = endpoint if endpoint.endswith("/v1/traces") else f"{endpoint}/v1/traces"
|
||||
otlp_exporter = OTLPSpanExporter(
|
||||
endpoint=otlp_endpoint,
|
||||
headers=headers_dict,
|
||||
)
|
||||
|
||||
# Create tracer provider with batch processor
|
||||
provider = TracerProvider(resource=resource)
|
||||
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
|
||||
|
||||
# Set global tracer provider
|
||||
trace.set_tracer_provider(provider)
|
||||
|
||||
# Get tracer for this application
|
||||
_tracer = trace.get_tracer(__name__)
|
||||
_tracing_enabled = True
|
||||
|
||||
logger.info(f"Tracing initialized: endpoint={otlp_endpoint}, service={service_name}")
|
||||
|
||||
|
||||
def get_tracer() -> trace.Tracer | NoOpTracer:
|
||||
"""
|
||||
Get the global tracer instance.
|
||||
|
||||
Returns a no-op tracer if tracing is disabled, so callers don't need to check for None.
|
||||
This improves code readability by allowing direct use without null checks.
|
||||
"""
|
||||
return _tracer
|
||||
|
||||
|
||||
def create_operation_span(operation: str, bank_id: str | None = None):
|
||||
"""
|
||||
Create a parent span for a Hindsight operation (retain, reflect, consolidation, etc.).
|
||||
|
||||
This creates the span hierarchy:
|
||||
- hindsight.{operation} (parent)
|
||||
- chat {model} (child LLM calls)
|
||||
|
||||
Args:
|
||||
operation: Operation name (retain, reflect, consolidation, mental_model_refresh)
|
||||
bank_id: Optional bank ID for context
|
||||
|
||||
Returns:
|
||||
Span context manager
|
||||
"""
|
||||
if not _tracing_enabled or _tracer is None:
|
||||
# Return a no-op context manager
|
||||
from contextlib import nullcontext
|
||||
|
||||
return nullcontext()
|
||||
|
||||
span_name = f"hindsight.{operation}"
|
||||
span = _tracer.start_as_current_span(span_name)
|
||||
|
||||
# Add operation-specific attributes
|
||||
if span and hasattr(span, "set_attribute"):
|
||||
span.set_attribute("hindsight.operation", operation)
|
||||
if bank_id:
|
||||
span.set_attribute("hindsight.bank_id", bank_id)
|
||||
|
||||
return span
|
||||
|
||||
|
||||
def is_tracing_enabled() -> bool:
|
||||
"""Check if tracing is enabled."""
|
||||
return _tracing_enabled
|
||||
|
||||
|
||||
# Maximum content length before truncation (to stay within span size limits)
|
||||
MAX_CONTENT_LENGTH = 100_000 # characters
|
||||
|
||||
|
||||
def _truncate_content(content: str) -> str:
|
||||
"""Truncate content if too large for span."""
|
||||
if len(content) > MAX_CONTENT_LENGTH:
|
||||
return content[:MAX_CONTENT_LENGTH] + f"\n\n[TRUNCATED: {len(content) - MAX_CONTENT_LENGTH} chars omitted]"
|
||||
return content
|
||||
|
||||
|
||||
class LLMSpanRecorder:
|
||||
"""
|
||||
Records OpenTelemetry spans for LLM calls following GenAI semantic conventions.
|
||||
"""
|
||||
|
||||
def __init__(self, tracer: trace.Tracer):
|
||||
self.tracer = tracer
|
||||
|
||||
def record_llm_call(
|
||||
self,
|
||||
provider: str,
|
||||
model: str,
|
||||
scope: str,
|
||||
messages: list[dict[str, str]],
|
||||
response_content: Optional[str],
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
duration: float,
|
||||
finish_reason: Optional[str] = None,
|
||||
error: Optional[Exception] = None,
|
||||
tool_calls: Optional[list[dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Record a completed LLM call as a span with GenAI semantic conventions.
|
||||
|
||||
This creates a span AFTER the call completes, using timestamps to
|
||||
set the correct start/end times. This approach works better with
|
||||
the existing sync metrics recording pattern.
|
||||
|
||||
Args:
|
||||
provider: Hindsight provider name
|
||||
model: Model name
|
||||
scope: Scope identifier (memory, reflect, consolidation, etc.)
|
||||
messages: Input messages (chat history)
|
||||
response_content: Response text from LLM
|
||||
input_tokens: Input token count
|
||||
output_tokens: Output token count
|
||||
duration: Call duration in seconds
|
||||
finish_reason: Reason the model stopped (stop, length, tool_calls, etc.)
|
||||
error: Exception if call failed
|
||||
tool_calls: List of tool calls made (for function calling)
|
||||
"""
|
||||
try:
|
||||
# Map provider name to GenAI semantic convention
|
||||
genai_provider = PROVIDER_NAME_MAPPING.get(provider.lower(), provider.lower())
|
||||
|
||||
# Determine operation name based on scope/context
|
||||
operation_name = "chat" # Default for GenAI semantic conventions
|
||||
|
||||
# Create span name: "hindsight.{scope}" for consistency with parent spans
|
||||
# Model info is available in span attributes (gen_ai.request.model)
|
||||
if scope:
|
||||
span_name = f"hindsight.{scope}"
|
||||
else:
|
||||
# Fallback to chat {model} if no scope provided
|
||||
span_name = f"{operation_name} {model}"
|
||||
|
||||
# Calculate timestamps
|
||||
end_time_ns = time.time_ns()
|
||||
start_time_ns = end_time_ns - int(duration * 1_000_000_000)
|
||||
|
||||
# Create span with explicit timestamps
|
||||
with self.tracer.start_as_current_span(
|
||||
span_name,
|
||||
start_time=start_time_ns,
|
||||
end_on_exit=False, # We'll set end time manually
|
||||
) as span:
|
||||
# Set required attributes
|
||||
span.set_attribute(GenAIAttributes.OPERATION_NAME, operation_name)
|
||||
span.set_attribute(GenAIAttributes.PROVIDER_NAME, genai_provider)
|
||||
span.set_attribute(GenAIAttributes.REQUEST_MODEL, model)
|
||||
span.set_attribute(GenAIAttributes.RESPONSE_MODEL, model)
|
||||
span.set_attribute(GenAIAttributes.USAGE_INPUT_TOKENS, input_tokens)
|
||||
span.set_attribute(GenAIAttributes.USAGE_OUTPUT_TOKENS, output_tokens)
|
||||
|
||||
# Add custom attributes for Hindsight context
|
||||
span.set_attribute("hindsight.scope", scope)
|
||||
span.set_attribute("hindsight.provider.internal", provider)
|
||||
|
||||
# Add tool call information if present
|
||||
if tool_calls:
|
||||
span.set_attribute("gen_ai.tool_calls.count", len(tool_calls))
|
||||
# Add tool names as comma-separated list
|
||||
tool_names = [tc.get("name", "") for tc in tool_calls]
|
||||
span.set_attribute("gen_ai.tool_calls.names", ",".join(tool_names))
|
||||
|
||||
# Format messages for GenAI conventions (as JSON)
|
||||
input_messages_json = self._format_messages(messages)
|
||||
output_messages_json = self._format_output(response_content, finish_reason)
|
||||
|
||||
# Extract system instructions if present
|
||||
system_instructions = self._extract_system_instructions(messages)
|
||||
|
||||
# Add event with prompts/completions following v1.37 conventions
|
||||
event_attrs = {}
|
||||
if input_messages_json:
|
||||
event_attrs[GenAIAttributes.INPUT_MESSAGES] = input_messages_json
|
||||
if output_messages_json:
|
||||
event_attrs[GenAIAttributes.OUTPUT_MESSAGES] = output_messages_json
|
||||
if system_instructions:
|
||||
event_attrs[GenAIAttributes.SYSTEM_INSTRUCTIONS] = system_instructions
|
||||
if finish_reason:
|
||||
event_attrs[GenAIAttributes.FINISH_REASONS] = json.dumps([finish_reason])
|
||||
|
||||
span.add_event(
|
||||
"gen_ai.client.inference.operation.details",
|
||||
attributes=event_attrs,
|
||||
)
|
||||
|
||||
# Add individual tool call events with details
|
||||
if tool_calls:
|
||||
for i, tc in enumerate(tool_calls):
|
||||
tool_event_attrs = {
|
||||
"tool.name": tc.get("name", ""),
|
||||
"tool.id": tc.get("id", ""),
|
||||
"tool.arguments": json.dumps(tc.get("arguments", {})),
|
||||
}
|
||||
span.add_event(f"gen_ai.tool_call.{i}", attributes=tool_event_attrs)
|
||||
|
||||
# Handle errors
|
||||
if error:
|
||||
span.set_status(Status(StatusCode.ERROR, str(error)))
|
||||
span.set_attribute(GenAIAttributes.ERROR_TYPE, type(error).__name__)
|
||||
span.record_exception(error)
|
||||
else:
|
||||
span.set_status(Status(StatusCode.OK))
|
||||
|
||||
# Set end time
|
||||
span.end(end_time=end_time_ns)
|
||||
|
||||
except Exception as e:
|
||||
# Don't let tracing errors break LLM calls
|
||||
logger.error(f"Failed to record LLM span: {e}", exc_info=True)
|
||||
|
||||
def _format_messages(self, messages: list[dict[str, str]]) -> str:
|
||||
"""
|
||||
Format messages into GenAI semantic convention format (JSON array).
|
||||
|
||||
Returns JSON string representation of message array.
|
||||
"""
|
||||
try:
|
||||
formatted = []
|
||||
for msg in messages:
|
||||
content = msg.get("content", "")
|
||||
# Truncate if needed
|
||||
if isinstance(content, str):
|
||||
content = _truncate_content(content)
|
||||
|
||||
formatted.append(
|
||||
{
|
||||
"role": msg.get("role", "user"),
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
|
||||
return json.dumps(formatted)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to format input messages: {e}")
|
||||
return "[]"
|
||||
|
||||
def _format_output(
|
||||
self,
|
||||
content: Optional[str],
|
||||
finish_reason: Optional[str],
|
||||
) -> str:
|
||||
"""Format output message into GenAI semantic convention format."""
|
||||
try:
|
||||
if content is None:
|
||||
return "[]"
|
||||
|
||||
# Truncate if needed
|
||||
if isinstance(content, str):
|
||||
content = _truncate_content(content)
|
||||
|
||||
return json.dumps(
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
}
|
||||
]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to format output message: {e}")
|
||||
return "[]"
|
||||
|
||||
def _extract_system_instructions(self, messages: list[dict[str, str]]) -> Optional[str]:
|
||||
"""Extract system instructions from messages if present."""
|
||||
try:
|
||||
for msg in messages:
|
||||
if msg.get("role") == "system":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str):
|
||||
return _truncate_content(content)
|
||||
return str(content)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract system instructions: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class NoOpLLMSpanRecorder:
|
||||
"""No-op span recorder for when tracing is disabled."""
|
||||
|
||||
def record_llm_call(self, **kwargs) -> None:
|
||||
"""No-op."""
|
||||
pass
|
||||
|
||||
|
||||
# Global span recorder instance
|
||||
_span_recorder: Optional[LLMSpanRecorder] = None
|
||||
|
||||
|
||||
def get_span_recorder() -> LLMSpanRecorder | NoOpLLMSpanRecorder:
|
||||
"""Get the global span recorder (NoOp if tracing disabled)."""
|
||||
if _span_recorder is None:
|
||||
return NoOpLLMSpanRecorder()
|
||||
return _span_recorder
|
||||
|
||||
|
||||
def create_span_recorder() -> LLMSpanRecorder:
|
||||
"""Create and set the global span recorder."""
|
||||
global _span_recorder
|
||||
tracer = get_tracer()
|
||||
if tracer is None:
|
||||
raise RuntimeError("Tracing not initialized. Call initialize_tracing() first.")
|
||||
_span_recorder = LLMSpanRecorder(tracer)
|
||||
return _span_recorder
|
||||
@@ -0,0 +1,13 @@
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
|
||||
def mask_network_location(url):
|
||||
if not url:
|
||||
return url
|
||||
parsed_url = urlparse(url)
|
||||
masked_network_location = parsed_url.hostname or ""
|
||||
if parsed_url.port:
|
||||
masked_network_location += f":{parsed_url.port}"
|
||||
if parsed_url.username or parsed_url.password:
|
||||
masked_network_location = f"***:***@{masked_network_location}"
|
||||
return urlunparse(parsed_url._replace(netloc=masked_network_location))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user