Compare commits

..
Author SHA1 Message Date
Derek Bouius 06d11cf867 change the example openai model 2025-12-11 16:19:53 -05:00
Derek Bouius 7f5576cdee add best-match for the test-python-client too 2025-12-11 15:58:34 -05:00
Derek Bouius 3572387051 add best match strategy to uv 2025-12-11 15:51:16 -05:00
Derek Bouius 3a91c0b87f rework to use only CPU version of pytorch 2025-12-11 15:43:32 -05:00
Derek Bouius 706204bc4f force the CPU only version of Pytorch for tests 2025-12-11 14:58:43 -05:00
Derek Bouius 147d46fc91 Revert "use the CPU only version of pytorch to avoid pulling cuda libraries"
This reverts commit 583683b0a2.
2025-12-11 14:44:59 -05:00
Derek Bouius 285bed65f9 Revert "just use uv sync, not pip"
This reverts commit c85a1ca58b.
2025-12-11 14:44:51 -05:00
Derek Bouius c85a1ca58b just use uv sync, not pip 2025-12-11 14:21:45 -05:00
Derek Bouius 583683b0a2 use the CPU only version of pytorch to avoid pulling cuda libraries 2025-12-11 14:12:43 -05:00
Derek Bouius 0d5503c892 add debug to figure out why docker build fails sometimes 2025-12-11 13:51:06 -05:00
Derek Bouius af2756f2da remove assert that is a race condition
The test was checking that the bank count increased, but with parallel tests (-n 8), other tests can delete their banks while this test is running, causing a race condition. The important assertion is assert test_bank_id in final_banks - which verifies the bank was actually created.
2025-12-11 13:19:09 -05:00
Derek Bouius b1e380bdae Merge branch 'update-example-env' of https://github.com/vectorize-io/hindsight into update-example-env 2025-12-11 13:08:42 -05:00
Derek Bouius b8ec743962 pre-cache the model so CI doesn't need workarounds 2025-12-11 13:08:39 -05:00
Derek Bouius d891124835 Revert "trial to fix failing client tests"
This reverts commit c0093f1a97.
2025-12-11 16:08:22 +01:00
Derek Bouius 04ff24be8d trial to fix failing client tests
NotImplementedError: Cannot copy out of meta tensor; no data! Please use torch.nn.Module.to_empty() instead of torch.nn.Module.to() when moving module from meta to a different device.

Added backend_kwargs={"low_cpu_mem_usage": False} to the SentenceTransformer initialization in embeddings.py
This should ensure the low_cpu_mem_usage=False setting is passed through both model_kwargs and backend_kwargs, which are different code paths in sentence-transformers 3.x.
2025-12-11 16:08:22 +01:00
Derek Bouius 076c33e854 fix the assert in testing recall 2025-12-11 16:08:22 +01:00
Derek Bouius a03c942296 Add the LLM_PROVIDER in example 2025-12-11 16:08:22 +01:00
Derek Bouius b4e42bd0c6 lock the sentence transformer packages to align with the breaking changes around lazy tensor loading 2025-12-11 09:46:28 -05:00
Derek Bouius 95b2b7e78f Merge branch 'main' into update-example-env 2025-12-11 09:22:35 -05:00
Derek Bouius 2aa8700db8 Revert "trial to fix failing client tests"
This reverts commit c0093f1a97.
2025-12-10 23:11:04 -05:00
Derek Bouius c0093f1a97 trial to fix failing client tests
NotImplementedError: Cannot copy out of meta tensor; no data! Please use torch.nn.Module.to_empty() instead of torch.nn.Module.to() when moving module from meta to a different device.

Added backend_kwargs={"low_cpu_mem_usage": False} to the SentenceTransformer initialization in embeddings.py
This should ensure the low_cpu_mem_usage=False setting is passed through both model_kwargs and backend_kwargs, which are different code paths in sentence-transformers 3.x.
2025-12-10 23:04:42 -05:00
Derek Bouius 460f045f16 fix the assert in testing recall 2025-12-10 22:36:40 -05:00
Derek Bouius 0673d4813d Add the LLM_PROVIDER in example 2025-12-10 22:22:26 -05:00
426 changed files with 43510 additions and 53929 deletions
-27
View File
@@ -1,27 +0,0 @@
#!/bin/bash
# Pre-commit hook - runs all scripts in scripts/hooks/
set -e
REPO_ROOT="$(git rev-parse --show-toplevel)"
HOOKS_DIR="$REPO_ROOT/scripts/hooks"
if [ ! -d "$HOOKS_DIR" ]; then
exit 0
fi
echo ""
echo "=== Running pre-commit hooks ==="
echo ""
# Run all executable scripts in hooks directory
for hook in "$HOOKS_DIR"/*.sh; do
if [ -x "$hook" ]; then
echo "[hook] $(basename "$hook")"
(cd "$REPO_ROOT" && "$hook")
fi
done
echo ""
echo "=== Pre-commit hooks completed ==="
echo ""
+11
View File
@@ -0,0 +1,11 @@
name: 'Setup pg0'
description: 'Install pg0 embedded PostgreSQL'
runs:
using: 'composite'
steps:
- name: Install pg0
shell: bash
run: |
curl -fsSL https://raw.githubusercontent.com/vectorize-io/pg0/main/install.sh | bash
echo "$HOME/.pg0/bin" >> $GITHUB_PATH
+6 -5
View File
@@ -20,17 +20,18 @@ concurrency:
jobs:
build:
runs-on: ubuntu-latest
defaults:
run:
working-directory: hindsight-docs
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: package-lock.json
- uses: astral-sh/setup-uv@v4
- run: npm ci --workspace=hindsight-docs
- run: uv run generate-llms-full
- run: npm run build --workspace=hindsight-docs
cache-dependency-path: hindsight-docs/package-lock.json
- run: npm ci
- run: npm run build
- uses: actions/upload-pages-artifact@v3
with:
path: hindsight-docs/build
+53 -134
View File
@@ -38,14 +38,6 @@ jobs:
working-directory: ./hindsight
run: uv build --out-dir dist
- name: Build hindsight-litellm
working-directory: ./hindsight-integrations/litellm
run: uv build --out-dir dist
- name: Build hindsight-embed
working-directory: ./hindsight-embed
run: uv build --out-dir dist
# Publish in order (client and api first, then hindsight-all which depends on them)
- name: Publish hindsight-client to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
@@ -65,18 +57,6 @@ jobs:
packages-dir: ./hindsight/dist
skip-existing: true
- name: Publish hindsight-litellm to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-integrations/litellm/dist
skip-existing: true
- name: Publish hindsight-embed to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: ./hindsight-embed/dist
skip-existing: true
# Upload artifacts for GitHub release
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -86,8 +66,6 @@ jobs:
hindsight-clients/python/dist/*
hindsight-api/dist/*
hindsight/dist/*
hindsight-integrations/litellm/dist/*
hindsight-embed/dist/*
retention-days: 1
release-typescript-client:
@@ -102,29 +80,18 @@ jobs:
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace=hindsight-clients/typescript
working-directory: ./hindsight-clients/typescript
run: npm ci
- name: Build
run: npm run build --workspace=hindsight-clients/typescript
working-directory: ./hindsight-clients/typescript
run: npm run build
- name: Publish to npm
working-directory: ./hindsight-clients/typescript
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
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -139,65 +106,6 @@ jobs:
path: hindsight-clients/typescript/*.tgz
retention-days: 1
release-control-plane:
runs-on: ubuntu-latest
environment: npm
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Build TypeScript client (dependency)
run: npm run build --workspace=hindsight-clients/typescript
- name: Fix platform-specific native modules
run: |
# npm ci installs from lockfile which may have wrong platform binaries
# Delete hoisted native modules and reinstall for current platform
rm -rf node_modules/lightningcss node_modules/@tailwindcss
npm install lightningcss @tailwindcss/postcss @tailwindcss/node
- name: Build
run: npm run build --workspace=hindsight-control-plane
- name: Publish to npm
working-directory: ./hindsight-control-plane
run: |
set +e
OUTPUT=$(npm publish --access public 2>&1)
EXIT_CODE=$?
echo "$OUTPUT"
if [ $EXIT_CODE -ne 0 ]; then
if echo "$OUTPUT" | grep -q "cannot publish over"; then
echo "Package version already published, skipping..."
exit 0
fi
exit $EXIT_CODE
fi
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Pack for GitHub release
working-directory: ./hindsight-control-plane
run: npm pack
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: control-plane
path: hindsight-control-plane/*.tgz
retention-days: 1
release-rust-cli:
runs-on: ${{ matrix.os }}
strategy:
@@ -262,7 +170,7 @@ jobs:
- name: Free Disk Space
uses: jlumbroso/free-disk-space@main
with:
tool-cache: true
tool-cache: false
android: true
dotnet: true
haskell: true
@@ -287,7 +195,7 @@ jobs:
id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Extract metadata for release tags
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
@@ -298,29 +206,7 @@ jobs:
type=semver,pattern={{major}},value=${{ steps.get_version.outputs.VERSION }}
type=raw,value=latest
# TODO: Re-enable smoke test when disk space issue is resolved
# # Step 1: Build for local testing (single platform, no push)
# # This creates an identical image to what will be released, just for one platform
# - name: Build image for testing
# uses: docker/build-push-action@v6
# with:
# context: .
# file: docker/standalone/Dockerfile
# target: ${{ matrix.target }}
# push: false
# load: true
# tags: ${{ matrix.image_name }}:test
# cache-from: type=gha
# cache-to: type=gha,mode=max
# # Step 2: Test the image before pushing anything
# - name: Smoke test - verify container starts
# env:
# GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
# run: ./scripts/docker-smoke-test.sh "${{ matrix.image_name }}:test" "${{ matrix.target }}"
# Build multi-platform and push to release tags
- name: Build and push release images
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
@@ -366,7 +252,7 @@ jobs:
create-github-release:
runs-on: ubuntu-latest
needs: [release-python-packages, release-typescript-client, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
needs: [release-python-packages, release-typescript-client, release-rust-cli, release-docker-images, release-helm-chart]
permissions:
contents: write
@@ -389,12 +275,6 @@ jobs:
name: typescript-client
path: ./artifacts/typescript-client
- name: Download Control Plane
uses: actions/download-artifact@v4
with:
name: control-plane
path: ./artifacts/control-plane
- name: Download Rust CLI (Linux)
uses: actions/download-artifact@v4
with:
@@ -426,12 +306,8 @@ jobs:
cp artifacts/python-packages/hindsight-clients/python/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-api/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-integrations/litellm/dist/* release-assets/ || true
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
# TypeScript client
cp artifacts/typescript-client/*.tgz release-assets/ || true
# Control Plane
cp artifacts/control-plane/*.tgz release-assets/ || true
# Rust CLI binaries
cp artifacts/rust-cli-linux/hindsight-linux-amd64 release-assets/ || true
cp artifacts/rust-cli-darwin-amd64/hindsight-darwin-amd64 release-assets/ || true
@@ -440,11 +316,54 @@ jobs:
cp artifacts/helm-chart/*.tgz release-assets/ || true
ls -la release-assets/
- name: Generate release notes
run: |
cat << 'EOF' > release-notes.md
## Quick Start
```bash
# Install the CLI
curl -fsSL https://raw.githubusercontent.com/vectorize-io/hindsight/refs/heads/main/hindsight-cli/install.sh | bash
# Start the server
docker run -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_PROVIDER=openai \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=gpt-4o-mini \
ghcr.io/${{ github.repository_owner }}/hindsight:${{ steps.get_version.outputs.VERSION }}
```
## Docker Images
- `ghcr.io/${{ github.repository_owner }}/hindsight:${{ steps.get_version.outputs.VERSION }}` - Standalone (recommended)
- `ghcr.io/${{ github.repository_owner }}/hindsight-api:${{ steps.get_version.outputs.VERSION }}` - API only
- `ghcr.io/${{ github.repository_owner }}/hindsight-control-plane:${{ steps.get_version.outputs.VERSION }}` - Web UI only
## CLI
```bash
curl -fsSL https://raw.githubusercontent.com/vectorize-io/hindsight/refs/heads/main/hindsight-cli/install.sh | bash
```
## Python
```bash
pip install hindsight-all # or hindsight-api, hindsight-client
```
## TypeScript/JavaScript
```bash
npm install @vectorize-io/hindsight-client
```
## Helm
```bash
helm install hindsight oci://ghcr.io/${{ github.repository_owner }}/charts/hindsight --version ${{ steps.get_version.outputs.VERSION }}
```
EOF
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: release-assets/*
generate_release_notes: true
body_path: release-notes.md
draft: false
prerelease: false
env:
+17 -405
View File
@@ -9,131 +9,6 @@ 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']
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Build hindsight-api
working-directory: ./hindsight-api
run: uv build
build-typescript-client:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace=hindsight-clients/typescript
- name: Build TypeScript client
run: npm run build --workspace=hindsight-clients/typescript
build-control-plane:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install SDK dependencies
run: npm ci --workspace=hindsight-clients/typescript
- name: Build SDK
run: npm run build --workspace=hindsight-clients/typescript
# Install control plane deps and fix hoisted lightningcss binary
# lightningcss gets hoisted to root node_modules, so we need to reinstall it there
- name: Install Control Plane dependencies
run: |
npm install --workspace=hindsight-control-plane
rm -rf node_modules/lightningcss node_modules/@tailwindcss
npm install lightningcss @tailwindcss/postcss @tailwindcss/node
- name: Build Control Plane
run: npm run build --workspace=hindsight-control-plane
- name: Verify standalone build
run: |
test -f hindsight-control-plane/standalone/server.js || exit 1
test -d hindsight-control-plane/standalone/node_modules || exit 1
node hindsight-control-plane/bin/cli.js --help
- name: Smoke test - verify server starts
run: |
cd hindsight-control-plane
node bin/cli.js --port 9999 &
SERVER_PID=$!
sleep 5
if curl -sf http://localhost:9999 > /dev/null 2>&1; then
echo "Server started successfully"
kill $SERVER_PID 2>/dev/null || true
exit 0
else
echo "Server failed to respond"
kill $SERVER_PID 2>/dev/null || true
exit 1
fi
build-docs:
runs-on: ubuntu-latest
@@ -144,14 +19,14 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace=hindsight-docs
working-directory: ./hindsight-docs
run: npm ci
- name: Build docs
run: npm run build --workspace=hindsight-docs
working-directory: ./hindsight-docs
run: npm run build
build-rust-cli:
runs-on: ubuntu-latest
@@ -175,90 +50,6 @@ jobs:
working-directory: hindsight-cli
run: cargo build --release
- name: Upload CLI artifact
uses: actions/upload-artifact@v4
with:
name: hindsight-cli
path: hindsight-cli/target/release/hindsight
retention-days: 1
test-rust-cli:
runs-on: ubuntu-latest
needs: build-rust-cli
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
- name: Download CLI artifact
uses: actions/download-artifact@v4
with:
name: hindsight-cli
path: /tmp/cli
- name: Make CLI executable
run: chmod +x /tmp/cli/hindsight
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Build API
working-directory: ./hindsight-api
run: uv build
- name: Install API dependencies
working-directory: ./hindsight-api
run: uv sync --no-install-project --index-strategy unsafe-best-match
- name: Create .env file
run: |
cat > .env << EOF
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }}
EOF
- name: Start API server
run: |
./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 &
echo "Waiting for API server to be ready..."
for i in {1..60}; do
if curl -sf http://localhost:8888/health > /dev/null 2>&1; then
echo "API server is ready after ${i}s"
break
fi
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
sleep 1
done
- name: Run CLI smoke test
run: |
HINDSIGHT_CLI=/tmp/cli/hindsight ./hindsight-cli/smoke-test.sh
- name: Show API server logs
if: always()
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
lint-helm-chart:
runs-on: ubuntu-latest
@@ -291,7 +82,7 @@ jobs:
- name: Free Disk Space
uses: jlumbroso/free-disk-space@main
with:
tool-cache: true
tool-cache: false
android: true
dotnet: true
haskell: true
@@ -309,22 +100,12 @@ jobs:
file: docker/standalone/Dockerfile
target: ${{ matrix.target }}
push: false
load: false
# TODO: Re-enable smoke test when disk space issue is resolved
# - name: Smoke test - verify container starts
# env:
# GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
# run: ./scripts/docker-smoke-test.sh "hindsight-${{ matrix.name }}:test" "${{ matrix.target }}"
test-api:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
@@ -344,6 +125,9 @@ jobs:
with:
python-version-file: ".python-version"
- name: Install pg0
uses: ./.github/actions/setup-pg0
- name: Build API
working-directory: ./hindsight-api
run: uv build
@@ -401,6 +185,9 @@ jobs:
with:
python-version-file: ".python-version"
- name: Install pg0
uses: ./.github/actions/setup-pg0
- name: Build API
working-directory: ./hindsight-api
run: uv build
@@ -482,6 +269,9 @@ jobs:
with:
node-version: '20'
- name: Install pg0
uses: ./.github/actions/setup-pg0
- name: Build API
working-directory: ./hindsight-api
run: uv build
@@ -570,6 +360,9 @@ jobs:
hindsight-clients/rust/target
key: ${{ runner.os }}-cargo-client-${{ hashFiles('hindsight-clients/rust/Cargo.lock') }}
- name: Install pg0
uses: ./.github/actions/setup-pg0
- name: Build API
working-directory: ./hindsight-api
run: uv build
@@ -612,184 +405,3 @@ jobs:
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-litellm-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 litellm integration
working-directory: ./hindsight-integrations/litellm
run: uv build
- name: Install dependencies
working-directory: ./hindsight-integrations/litellm
run: uv sync --extra dev
- name: Run tests
working-directory: ./hindsight-integrations/litellm
run: uv run pytest tests -v
test-embed:
runs-on: ubuntu-latest
env:
HINDSIGHT_EMBED_LLM_PROVIDER: groq
HINDSIGHT_EMBED_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_EMBED_LLM_MODEL: openai/gpt-oss-20b
# Prefer CPU-only PyTorch in CI
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Install dependencies
working-directory: ./hindsight-embed
run: uv sync --index-strategy unsafe-best-match
- name: Cache HuggingFace models
uses: actions/cache@v4
with:
path: ~/.cache/huggingface
key: ${{ runner.os }}-huggingface-embed-${{ hashFiles('hindsight-embed/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-huggingface-embed-
${{ runner.os }}-huggingface-
- name: Run smoke test
working-directory: ./hindsight-embed
run: ./test.sh
test-doc-examples:
runs-on: ubuntu-latest
needs: build-rust-cli
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
steps:
- uses: actions/checkout@v4
- name: Download CLI artifact
uses: actions/download-artifact@v4
with:
name: hindsight-cli
path: /usr/local/bin
- name: Make CLI executable
run: chmod +x /usr/local/bin/hindsight
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Build and install API
working-directory: ./hindsight-api
run: |
uv build
uv sync --no-install-project --index-strategy unsafe-best-match
- name: Install Python client dependencies
working-directory: ./hindsight-clients/python
run: uv sync --extra test --index-strategy unsafe-best-match
- name: Install TypeScript client
run: |
npm ci --workspace=hindsight-clients/typescript
npm run build --workspace=hindsight-clients/typescript
- 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 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: Show API server logs
if: always()
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
-6
View File
@@ -9,9 +9,6 @@ wheels/
# Virtual environments
.venv
# Node
node_modules/
# Environment variables
.env
@@ -32,9 +29,6 @@ logs/
.DS_Store
# Generated docs files
hindsight-docs/static/llms-full.txt
hindsight-dev/benchmarks/locomo/results/
hindsight-dev/benchmarks/longmemeval/results/
+1 -3
View File
@@ -14,7 +14,6 @@ This document captures architectural decisions and coding conventions for the Hi
hindsight/ # Python package for embedded usage
hindsight-api/ # FastAPI server (core memory engine)
hindsight-cli/ # Rust CLI client
hindsight-embed/ # Embedded CLI (no server needed)
hindsight-control-plane/ # Next.js admin UI
hindsight-docs/ # Docusaurus documentation site
hindsight-dev/ # Development tools and benchmarks
@@ -149,5 +148,4 @@ Note: The maintained wrapper `hindsight_client.py` and `README.md` are preserved
# Branding
## Colors
- Primary: gradient from #0074d9 to #009296
- Primary: gradient from #0074d9 to #009296
+4 -14
View File
@@ -5,23 +5,13 @@ Thanks for your interest in contributing to Hindsight!
## Getting Started
1. Fork and clone the repository
2. Install dependencies:
```bash
git clone [email protected]:vectorize-io/hindsight.git
cd hindsight
cd hindsight-api && uv sync
```
2. Set up your environment:
3. Set up your environment:
```bash
cp .env.example .env
```
Edit the .env to add LLM API key and config as required
3. Install dependencies:
```bash
# Python dependencies
uv sync --directory hindsight-api/
# Node dependencies (uses npm workspaces)
npm install
export OPENAI_API_KEY=your-key
```
## Development
+10 -34
View File
@@ -1,14 +1,15 @@
<div align="center">
![Hindsight Banner](./hindsight-docs/static/img/banner.svg)
![Hindsight Banner](./hindsight-docs/static/img/banner.webp)
[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://vectorize-io.github.io/hindsight) • [Paper](#coming-soon) • [Examples](https://github.com/vectorize-io/hindsight-cookbook)
[![CI](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml/badge.svg)](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
[![Slack Community](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/hindsight-space/shared_invite/zt-3klo21kua-VUCC_zHP5rIcXFB1_5yw6A)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
![PyPI - Downloads](https://img.shields.io/pypi/dm/hindsight-api?label=PyPI)
![NPM Downloads](https://img.shields.io/npm/dm/%40vectorize-io%2Fhindsight-client?logoColor=orange&label=NPM&color=blue&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40vectorize-io%2Fhindsight-client)
[![PyPI - hindsight-api](https://img.shields.io/pypi/v/hindsight-api?label=hindsight-api)](https://pypi.org/project/hindsight-api/)
[![PyPI - hindsight-client](https://img.shields.io/pypi/v/hindsight-client?label=hindsight-client)](https://pypi.org/project/hindsight-client/)
[![npm - @vectorize-io/hindsight-client](https://img.shields.io/npm/v/@vectorize-io/hindsight-client)](https://www.npmjs.com/package/@vectorize-io/hindsight-client)
[![Slack Community](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/hindsight-space/shared_invite/zt-3klo21kua-VUCC_zHP5rIcXFB1_5yw6A)
</div>
@@ -17,7 +18,7 @@
## What is Hindsight?
Hindsight is an agent memory system built to create smarter agents that learn over time. It eliminates the shortcomings of alternative techniques such as RAG and knowledge graph and delivers state-of-the-art performance on long term memory tasks.
Hindsight is an agent memory system built to create smarter agents that learn over time. It eliminates the shortcomings of alternative techniques such as RAG and knowledge graph.
Hindsight addresses common challenges that have frustrated AI engineers building agents to automate tasks and assist users with conversational interfaces. Many of these challenges stem directly from a lack of memory.
@@ -25,48 +26,27 @@ Hindsight addresses common challenges that have frustrated AI engineers building
- **Hallucinations:** Long term memory can be seeded with external knowledge to ground agent behavior in reliable sources to augment training data.
- **Cognitive Overload:** As workflows get complex, retrievals, tool calls, user messages and agent responses can grow to fill the context window leading to context rot. Short term memory optimization allows agents to reduce tokens and focus context by removing irrelevant details.
## How is Hindsight Different From Other Memory Systems?
## How Hindsight Works
![Overview](./hindsight-docs/static/img/hindsight-overview.webp)
Most agent memory implementation rely on basic vector search or sometimes use a knowledge graph. Hindsight uses biomimetic data structures to organize agent memories in a way that is more like how human memory works:
Hindsight organizes memory into four networks to mimic the way human memory works:
- **World:** Facts about the world ("The stove gets hot")
- **Experiences:** Agent's own experiences ("I touched the stove and it really hurt")
- **Opinion:** Beliefs with confidence scores ("I shouldn't touch the stove again" - .99 confidence)
- **Observation:** Complex mental models derived by reflecting on facts and experiences ("Curling irons, ovens, and fire are also hot. I shouldn't touch those either.")
Memories in Hindsight are stored in banks (i.e. memory banks). When memories are added to Hindsight, they are pushed into either the world facts or experiences memory pathway. They are then represented as a combination of entities, relationships, and time series with sparse/dense vector representations to aid in later recall.
Hindsight provides three simple methods to interact with the system:
- **Retain:** Provide information to Hindsight that you want it to remember
- **Recall:** Retrieve memories from Hindsight
- **Reflect:** Reflect on memories and experiences to generate new observations and insights from existing memories.
### Agent Memory That Learns
A key goal of Hindsight is to build agent memory that enables agents to learn and improve over time. This is the role of the `reflect` operation which provides the agent to form broader opinions and observations over time.
For example, imagine a product support agent that is helping a user troubleshoot a problem. It uses a `search-documentation` tool it found on an MCP server. Later in the conversation, the agent discovers that the documentation returned from the tool wasn't for the product the user was asking about. The agent now has an experience in its memory bank. And just like humans, we want that agent to learn from its experience.
As the agent gains more experiences, `reflect` allows the agent to form observations about what worked, what didn't, and what to do differently the next time it encounters a similar task.
Memories in Hindsight are stored in banks (e.g. memory banks). When memories are retained, they are transformed to construct a series of search indexes, time series data, and entity/relationship graphs.
---
## Memory Performance & Accuracy
Hindsight has achieved state-of-the-art performance on the LongMemEval benchmark, widely used to assess memory system performance across a variety of conversational
AI scenarios. The current reported performance of Hindsight and other agent memory solutions as of December 2025 is shown here:
![Overview](./hindsight-docs/static/img/hindsight-bench.jpg)
The benchmark performance data for Hindsight and GPT-4o (full context) have been reproduced by research collaborators at the Virginia Tech [Sanghani Center for Artificial Intelligence and Data Analytics](https://sanghani.cs.vt.edu/) and The Washington Post. Other scores are self-reported by software vendors.
A thorough examination of the techniques implemented in Hindsight and detailed breakdowns of benchmark performance are [available on arXiv](https://arxiv.org/abs/2512.12818). This research is currently being prepared for conference submission and the wider peer review process.
The benchmark results from this research can be inspected in our [visual benchmark explorer](https://hindsight-benchmarks.vercel.app). As additional improvements are made to Hindsight, new benchmark data will be available for review using this same tool.
## Quick Start
### Docker (recommended)
@@ -243,10 +223,6 @@ client.reflect(bank_id="my-bank", query="What should I know about Alice?")
- [Slack](https://join.slack.com/t/hindsight-space/shared_invite/zt-3klo21kua-VUCC_zHP5rIcXFB1_5yw6A)
- [GitHub Issues](https://github.com/vectorize-io/hindsight/issues)
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=vectorize-io/hindsight&type=date&legend=top-left)](https://www.star-history.com/#vectorize-io/hindsight&type=date&legend=top-left)
---
## Contributing
+99 -83
View File
@@ -2,19 +2,16 @@
# Supports building API-only, Control Plane-only, or both
#
# Build args:
# INCLUDE_API=true/false - Include API (default: true)
# INCLUDE_CP=true/false - Include Control Plane (default: true)
# PRELOAD_ML_MODELS=true/false - Pre-download ML models during build (default: true)
# INCLUDE_API=true/false - Include API (default: true)
# INCLUDE_CP=true/false - Include Control Plane (default: true)
#
# Examples:
# docker build -t hindsight . # Both (standalone)
# docker build -t hindsight-api --build-arg INCLUDE_CP=false . # API only
# docker build -t hindsight-cp --build-arg INCLUDE_API=false . # Control Plane only
# docker build -t hindsight --build-arg PRELOAD_ML_MODELS=false . # Skip ML model preload
# docker build -t hindsight . # Both (standalone)
# docker build -t hindsight-api --build-arg INCLUDE_CP=false . # API only
# docker build -t hindsight-cp --build-arg INCLUDE_API=false . # Control Plane only
ARG INCLUDE_API=true
ARG INCLUDE_CP=true
ARG PRELOAD_ML_MODELS=true
# =============================================================================
# Stage: API Builder
@@ -57,15 +54,13 @@ FROM node:20-slim AS sdk-builder
ARG INCLUDE_CP
RUN if [ "$INCLUDE_CP" != "true" ]; then echo "Skipping SDK build" && exit 0; fi
WORKDIR /app
WORKDIR /app/sdk
# Copy root package files for npm workspaces
COPY package.json package-lock.json ./
COPY hindsight-clients/typescript/ ./hindsight-clients/typescript/
COPY hindsight-clients/typescript/package*.json ./
RUN npm ci
# Install and build SDK using workspace (--ignore-scripts skips git hooks setup)
RUN npm ci --ignore-scripts -w @vectorize-io/hindsight-client
RUN npm run build -w @vectorize-io/hindsight-client
COPY hindsight-clients/typescript/ ./
RUN npm run build
# =============================================================================
# Stage: Control Plane Builder
@@ -75,48 +70,30 @@ FROM node:20-slim AS cp-builder
ARG INCLUDE_CP
RUN if [ "$INCLUDE_CP" != "true" ]; then echo "Skipping CP build" && exit 0; fi
# Create directory structure matching the monorepo layout
# This is required because build:standalone script expects .next/standalone/memory-poc/hindsight-control-plane
WORKDIR /app/memory-poc/hindsight-control-plane
WORKDIR /app
# Copy built SDK
COPY --from=sdk-builder /app/sdk /app/sdk
# Install Control Plane dependencies
# Only copy package.json (not package-lock.json) to ensure npm installs
# correct platform-specific native bindings for lightningcss/tailwindcss
COPY hindsight-control-plane/package.json ./
# Remove the file: dependency on SDK (we'll copy it directly later)
RUN sed -i '/"@vectorize-io\/hindsight-client":/d' package.json
RUN npm install
# Copy Control Plane source (excluding node_modules via .dockerignore)
COPY hindsight-control-plane/ ./
# Remove package-lock.json to avoid conflicts with installed native bindings
# Also remove the file: dependency from package.json (restored by COPY above)
RUN rm -f package-lock.json && sed -i '/"@vectorize-io\/hindsight-client":/d' package.json
RUN rm -f package-lock.json
# 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
# Link SDK (temporary for build)
RUN cd /app/sdk && npm link && cd /app && npm link @vectorize-io/hindsight-client
# 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
# Build Control Plane
RUN npm run build
# Create standalone directory structure manually
# Note: Must exclude node_modules from find to avoid wrong server.js from next/dist/experimental/testmode/
# Note: Must explicitly copy .next since glob * doesn't match hidden directories
RUN STANDALONE_ROOT=$(find .next/standalone -path '*/node_modules' -prune -o -name 'server.js' -print | head -1 | xargs dirname) && \
mkdir -p standalone && \
cp -r "$STANDALONE_ROOT"/* standalone/ && \
cp -r "$STANDALONE_ROOT"/.next standalone/.next && \
# Copy node_modules if separate from app dir (monorepo structure)
if [ -d ".next/standalone/node_modules" ] && [ "$STANDALONE_ROOT" != ".next/standalone" ]; then \
cp -r .next/standalone/node_modules standalone/node_modules; \
fi && \
cp -r .next/static standalone/.next/static && \
mkdir -p standalone/public && \
cp -r public/* standalone/public/ 2>/dev/null || true && \
# Verify required files exist
test -f standalone/server.js || (echo "ERROR: server.js missing!" && exit 1) && \
test -f standalone/.next/BUILD_ID || (echo "ERROR: BUILD_ID missing!" && exit 1)
# Create public directory if it doesn't exist
RUN mkdir -p public
# =============================================================================
# Stage: Final Image - API Only
@@ -125,16 +102,14 @@ FROM python:3.11-slim AS api-only
WORKDIR /app
# Install pg0 dependencies (procps provides 'kill' command needed by pg0)
# Note: libicu version varies by Debian version - try common versions in order
# Install pg0 dependencies
RUN apt-get update && apt-get install -y \
curl \
procps \
libxml2 \
libssl3 \
libgssapi-krb5-2 \
libossp-uuid16 \
&& (apt-get install -y libicu72 2>/dev/null || apt-get install -y libicu74 2>/dev/null || apt-get install -y libicu76 2>/dev/null || true) \
&& apt-get install -y libicu72 || apt-get install -y libicu74 || apt-get install -y libicu* \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir uv
@@ -155,24 +130,49 @@ RUN mkdir -p /app/data && chown -R hindsight:hindsight /app
USER hindsight
# Set PATH for hindsight user
ENV PATH="/app/api/.venv/bin:${PATH}"
ENV PATH="/home/hindsight/.hindsight/bin:/app/api/.venv/bin:${PATH}"
# Pre-cache PostgreSQL binaries by starting/stopping pg0-embedded
# Install pg0 binary
RUN mkdir -p /home/hindsight/.hindsight/bin && \
ARCH=$(uname -m) && \
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then \
PG0_BINARY="pg0-linux-aarch64-gnu"; \
elif [ "$ARCH" = "x86_64" ]; then \
PG0_BINARY="pg0-linux-x86_64-gnu"; \
else \
echo "Unsupported architecture: $ARCH" && exit 1; \
fi && \
echo "Installing pg0 binary: $PG0_BINARY" && \
for i in 1 2 3 4 5; do \
curl -fsSL -o /home/hindsight/.hindsight/bin/pg0 \
"https://github.com/vectorize-io/pg0/releases/latest/download/$PG0_BINARY" && \
chmod +x /home/hindsight/.hindsight/bin/pg0 && \
ls -lh /home/hindsight/.hindsight/bin/pg0 && \
file /home/hindsight/.hindsight/bin/pg0 && \
ldd /home/hindsight/.hindsight/bin/pg0 2>&1 || true && \
break || (echo "Retry $i failed, waiting..." && sleep 10); \
done && \
echo "Testing pg0 binary..." && \
/home/hindsight/.hindsight/bin/pg0 --version || (echo "pg0 --version failed with exit code $?"; ldd /home/hindsight/.hindsight/bin/pg0; exit 1)
# Pre-download PostgreSQL binaries
ENV PG0_HOME=/home/hindsight/.pg0-cache
RUN pg0 start --help && \
(pg0 start --name hindsight --port 5555 --username hindsight --password hindsight --database hindsight && \
sleep 2 && \
pg0 stop --name hindsight && \
echo "PostgreSQL pre-cached to $PG0_HOME") || echo "Pre-download skipped"
ENV PG0_HOME=/home/hindsight/.pg0
# Pre-download ML models to avoid runtime download (conditional)
ARG PRELOAD_ML_MODELS
RUN if [ "$PRELOAD_ML_MODELS" = "true" ]; then \
/app/api/.venv/bin/python -c "\
# Pre-download ML models to avoid runtime download
RUN /app/api/.venv/bin/python -c "\
from sentence_transformers import SentenceTransformer, CrossEncoder; \
print('Downloading embedding model...'); \
SentenceTransformer('BAAI/bge-small-en-v1.5'); \
print('Downloading cross-encoder model...'); \
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); \
print('Models cached successfully')"; \
else echo "Skipping ML model preload"; fi
print('Models cached successfully')"
EXPOSE 8888
@@ -193,13 +193,13 @@ FROM node:20-alpine AS cp-only
WORKDIR /app
# Copy built SDK
COPY --from=sdk-builder /app/hindsight-clients/typescript /app/sdk
COPY --from=sdk-builder /app/sdk /app/sdk
# Copy Control Plane standalone build
WORKDIR /app/control-plane
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/standalone ./
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/.next/static ./.next/static
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/public ./public
COPY --from=cp-builder /app/.next/standalone ./
COPY --from=cp-builder /app/.next/static ./.next/static
COPY --from=cp-builder /app/public ./public
WORKDIR /app
@@ -226,16 +226,14 @@ FROM python:3.11-slim AS standalone
WORKDIR /app
# Install Node.js, curl, uv, and pg0 dependencies (procps provides 'kill' command needed by pg0)
# Note: libicu version varies by Debian version - try common versions in order
# Install Node.js, curl, uv, and pg0 dependencies
RUN apt-get update && apt-get install -y \
curl \
procps \
libxml2 \
libssl3 \
libgssapi-krb5-2 \
libossp-uuid16 \
&& (apt-get install -y libicu72 2>/dev/null || apt-get install -y libicu74 2>/dev/null || apt-get install -y libicu76 2>/dev/null || true) \
&& apt-get install -y libicu72 || apt-get install -y libicu74 || apt-get install -y libicu* \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/* \
@@ -248,13 +246,13 @@ RUN useradd -m -s /bin/bash hindsight
COPY --from=api-builder /app/api /app/api
# Copy built SDK
COPY --from=sdk-builder /app/hindsight-clients/typescript /app/sdk
COPY --from=sdk-builder /app/sdk /app/sdk
# Copy Control Plane standalone build
WORKDIR /app/control-plane
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/standalone ./
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/.next/static ./.next/static
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/public ./public
COPY --from=cp-builder /app/.next/standalone ./
COPY --from=cp-builder /app/.next/static ./.next/static
COPY --from=cp-builder /app/public ./public
WORKDIR /app
@@ -269,31 +267,49 @@ RUN mkdir -p /app/data && chown -R hindsight:hindsight /app
USER hindsight
# Set PATH for hindsight user
ENV PATH="/app/api/.venv/bin:${PATH}"
ENV PATH="/home/hindsight/.hindsight/bin:/app/api/.venv/bin:${PATH}"
# Pre-cache PostgreSQL binaries by starting/stopping pg0-embedded
# Install pg0 binary
RUN mkdir -p /home/hindsight/.hindsight/bin && \
ARCH=$(uname -m) && \
if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then \
PG0_BINARY="pg0-linux-aarch64-gnu"; \
elif [ "$ARCH" = "x86_64" ]; then \
PG0_BINARY="pg0-linux-x86_64-gnu"; \
else \
echo "Unsupported architecture: $ARCH" && exit 1; \
fi && \
echo "Installing pg0 binary: $PG0_BINARY" && \
for i in 1 2 3 4 5; do \
curl -fsSL -o /home/hindsight/.hindsight/bin/pg0 \
"https://github.com/vectorize-io/pg0/releases/latest/download/$PG0_BINARY" && \
chmod +x /home/hindsight/.hindsight/bin/pg0 && \
ls -lh /home/hindsight/.hindsight/bin/pg0 && \
file /home/hindsight/.hindsight/bin/pg0 && \
ldd /home/hindsight/.hindsight/bin/pg0 2>&1 || true && \
break || (echo "Retry $i failed, waiting..." && sleep 10); \
done && \
echo "Testing pg0 binary..." && \
/home/hindsight/.hindsight/bin/pg0 --version || (echo "pg0 --version failed with exit code $?"; ldd /home/hindsight/.hindsight/bin/pg0; exit 1)
# Pre-download PostgreSQL binaries
ENV PG0_HOME=/home/hindsight/.pg0-cache
RUN /app/api/.venv/bin/python -c "\
from pg0 import Pg0; \
print('Pre-caching PostgreSQL binaries...'); \
pg = Pg0(name='hindsight', port=5555, username='hindsight', password='hindsight', database='hindsight'); \
pg.start(); \
pg.stop(); \
print('PostgreSQL pre-cached to PG0_HOME')" || echo "Pre-download skipped"
RUN pg0 start --help && \
(pg0 start --name hindsight --port 5555 --username hindsight --password hindsight --database hindsight && \
sleep 2 && \
pg0 stop --name hindsight && \
echo "PostgreSQL pre-cached to $PG0_HOME") || echo "Pre-download skipped"
ENV PG0_HOME=/home/hindsight/.pg0
# Pre-download ML models to avoid runtime download (conditional)
ARG PRELOAD_ML_MODELS
RUN if [ "$PRELOAD_ML_MODELS" = "true" ]; then \
/app/api/.venv/bin/python -c "\
# Pre-download ML models to avoid runtime download
RUN /app/api/.venv/bin/python -c "\
from sentence_transformers import SentenceTransformer, CrossEncoder; \
print('Downloading embedding model...'); \
SentenceTransformer('BAAI/bge-small-en-v1.5'); \
print('Downloading cross-encoder model...'); \
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); \
print('Models cached successfully')"; \
else echo "Skipping ML model preload"; fi
print('Models cached successfully')"
EXPOSE 8888 9999
+2 -3
View File
@@ -23,8 +23,7 @@ PIDS=()
# Start API if enabled
if [ "$ENABLE_API" = "true" ]; then
cd /app/api
# Run API directly - Python's PYTHONUNBUFFERED=1 handles output buffering
hindsight-api &
hindsight-api 2>&1 | sed -u 's/^/[api] /' &
API_PID=$!
PIDS+=($API_PID)
@@ -43,7 +42,7 @@ fi
if [ "$ENABLE_CP" = "true" ]; then
echo "🎛️ Starting Control Plane..."
cd /app/control-plane
PORT=9999 node server.js &
PORT=9999 node server.js 2>&1 | grep -v -E "^[[:space:]]*(▲|✓|-|$)" | sed -u 's/^/[control-plane] /' &
CP_PID=$!
PIDS+=($CP_PID)
else
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: Hindsight helm chart
type: application
version: 0.1.16
appVersion: "0.1.16"
version: 0.1.4
appVersion: "0.1.4"
keywords:
- ai
- memory
+1 -137
View File
@@ -1,137 +1 @@
# Hindsight API
**Memory System for AI Agents** — Temporal + Semantic + Entity Memory Architecture using PostgreSQL with pgvector.
Hindsight gives AI agents persistent memory that works like human memory: it stores facts, tracks entities and relationships, handles temporal reasoning ("what happened last spring?"), and forms opinions based on configurable disposition traits.
## Installation
```bash
pip install hindsight-api
```
## Quick Start
### Run the Server
```bash
# Set your LLM provider
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_API_KEY=sk-xxxxxxxxxxxx
# Start the server (uses embedded PostgreSQL by default)
hindsight-api
```
The server starts at http://localhost:8888 with:
- REST API for memory operations
- MCP server at `/mcp` for tool-use integration
### Use the Python API
```python
from hindsight_api import MemoryEngine
# Create and initialize the memory engine
memory = MemoryEngine()
await memory.initialize()
# Create a memory bank for your agent
bank = await memory.create_memory_bank(
name="my-assistant",
background="A helpful coding assistant"
)
# Store a memory
await memory.retain(
memory_bank_id=bank.id,
content="The user prefers Python for data science projects"
)
# Recall memories
results = await memory.recall(
memory_bank_id=bank.id,
query="What programming language does the user prefer?"
)
# Reflect with reasoning
response = await memory.reflect(
memory_bank_id=bank.id,
query="Should I recommend Python or R for this ML project?"
)
```
## CLI Options
```bash
hindsight-api --help
# Common options
hindsight-api --port 9000 # Custom port (default: 8888)
hindsight-api --host 127.0.0.1 # Bind to localhost only
hindsight-api --workers 4 # Multiple worker processes
hindsight-api --log-level debug # Verbose logging
```
## Configuration
Configure via environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_DATABASE_URL` | PostgreSQL connection string | `pg0` (embedded) |
| `HINDSIGHT_API_LLM_PROVIDER` | `openai`, `groq`, `gemini`, `ollama` | `openai` |
| `HINDSIGHT_API_LLM_API_KEY` | API key for LLM provider | - |
| `HINDSIGHT_API_LLM_MODEL` | Model name | `gpt-4o-mini` |
| `HINDSIGHT_API_HOST` | Server bind address | `0.0.0.0` |
| `HINDSIGHT_API_PORT` | Server port | `8888` |
### Example with External PostgreSQL
```bash
export HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@localhost:5432/hindsight
export HINDSIGHT_API_LLM_PROVIDER=groq
export HINDSIGHT_API_LLM_API_KEY=gsk_xxxxxxxxxxxx
hindsight-api
```
## Docker
```bash
docker run --rm -it -p 8888:8888 \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight:latest
```
## MCP Server
For local MCP integration without running the full API server:
```bash
hindsight-local-mcp
```
This runs a stdio-based MCP server that can be used directly with MCP-compatible clients.
## Key Features
- **Multi-Strategy Retrieval (TEMPR)** — Semantic, keyword, graph, and temporal search combined with RRF fusion
- **Entity Graph** — Automatic entity extraction and relationship tracking
- **Temporal Reasoning** — Native support for time-based queries
- **Disposition Traits** — Configurable skepticism, literalism, and empathy influence opinion formation
- **Three Memory Types** — World facts, bank actions, and formed opinions with confidence scores
## Documentation
Full documentation: [https://hindsight.vectorize.io](https://hindsight.vectorize.io)
- [Installation Guide](https://hindsight.vectorize.io/developer/installation)
- [Configuration Reference](https://hindsight.vectorize.io/developer/configuration)
- [API Reference](https://hindsight.vectorize.io/api-reference)
- [Python SDK](https://hindsight.vectorize.io/sdks/python)
## License
Apache 2.0
# Memory
+11 -14
View File
@@ -3,29 +3,26 @@ Memory System for AI Agents.
Temporal + Semantic Memory Architecture using PostgreSQL with pgvector.
"""
from .config import HindsightConfig, get_config
from .engine.cross_encoder import CrossEncoderModel, LocalSTCrossEncoder, RemoteTEICrossEncoder
from .engine.embeddings import Embeddings, LocalSTEmbeddings, RemoteTEIEmbeddings
from .engine.llm_wrapper import LLMConfig
from .engine.memory_engine import MemoryEngine
from .engine.search.trace import (
EntryPoint,
LinkInfo,
NodeVisit,
PruningDecision,
QueryInfo,
SearchPhaseMetrics,
SearchSummary,
SearchTrace,
QueryInfo,
EntryPoint,
NodeVisit,
WeightComponents,
LinkInfo,
PruningDecision,
SearchSummary,
SearchPhaseMetrics,
)
from .engine.search.tracer import SearchTracer
from .models import RequestContext
from .engine.embeddings import Embeddings, LocalSTEmbeddings, RemoteTEIEmbeddings
from .engine.cross_encoder import CrossEncoderModel, LocalSTCrossEncoder, RemoteTEICrossEncoder
from .engine.llm_wrapper import LLMConfig
from .config import HindsightConfig, get_config
__all__ = [
"MemoryEngine",
"RequestContext",
"HindsightConfig",
"get_config",
"SearchTrace",
+8 -28
View File
@@ -2,19 +2,20 @@
Alembic environment configuration for SQLAlchemy with pgvector.
Uses synchronous psycopg2 driver for migrations to avoid pgbouncer issues.
"""
import logging
import os
import sys
from pathlib import Path
from sqlalchemy import pool, engine_from_config
from sqlalchemy.engine import Connection
from alembic import context
from dotenv import load_dotenv
from sqlalchemy import engine_from_config, pool
# Import your models here
from hindsight_api.models import Base
# Load environment variables based on HINDSIGHT_API_DATABASE_URL env var or default to local
def load_env():
"""Load environment variables from .env"""
@@ -29,7 +30,6 @@ def load_env():
if env_file.exists():
load_dotenv(env_file)
load_env()
# this is the Alembic Config object, which provides
@@ -109,9 +109,6 @@ def run_migrations_online() -> None:
get_database_url() # Process and set the database URL in config
# Check if we're targeting a specific schema (for multi-tenant isolation)
target_schema = config.get_main_option("target_schema")
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
@@ -124,34 +121,17 @@ def run_migrations_online() -> None:
def set_read_write_mode(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE")
# If targeting a specific schema, set search_path
# Include public in search_path for access to shared extensions (pgvector)
if target_schema:
cursor.execute(f'CREATE SCHEMA IF NOT EXISTS "{target_schema}"')
cursor.execute(f'SET search_path TO "{target_schema}", public')
cursor.close()
with connectable.connect() as connection:
# Also explicitly set read-write mode on this connection
connection.execute(text("SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE"))
# If targeting a specific schema, set search_path
# Include public in search_path for access to shared extensions (pgvector)
if target_schema:
connection.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{target_schema}"'))
connection.execute(text(f'SET search_path TO "{target_schema}", public'))
connection.commit() # Commit the SET command
# Configure context with version_table_schema if using a specific schema
context_opts = {
"connection": connection,
"target_metadata": target_metadata,
}
if target_schema:
context_opts["version_table_schema"] = target_schema
context.configure(**context_opts)
context.configure(
connection=connection,
target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
@@ -5,150 +5,120 @@ Revises:
Create Date: 2025-11-27 11:54:19.228030
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from pgvector.sqlalchemy import Vector
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from pgvector.sqlalchemy import Vector
# revision identifiers, used by Alembic.
revision: str = "5a366d414dce"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
revision: str = '5a366d414dce'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema - create all tables from scratch."""
# Enable required extensions
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
op.execute('CREATE EXTENSION IF NOT EXISTS vector')
# Create banks table
op.create_table(
"banks",
sa.Column("bank_id", sa.Text(), nullable=False),
sa.Column("name", sa.Text(), nullable=True),
sa.Column(
"personality",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column("background", sa.Text(), nullable=True),
sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("bank_id", name=op.f("pk_banks")),
'banks',
sa.Column('bank_id', sa.Text(), nullable=False),
sa.Column('name', sa.Text(), nullable=True),
sa.Column('personality', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column('background', sa.Text(), nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('bank_id', name=op.f('pk_banks'))
)
# Create documents table
op.create_table(
"documents",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("bank_id", sa.Text(), nullable=False),
sa.Column("original_text", sa.Text(), nullable=True),
sa.Column("content_hash", sa.Text(), nullable=True),
sa.Column(
"metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False
),
sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id", "bank_id", name=op.f("pk_documents")),
'documents',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('bank_id', sa.Text(), nullable=False),
sa.Column('original_text', sa.Text(), nullable=True),
sa.Column('content_hash', sa.Text(), nullable=True),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.PrimaryKeyConstraint('id', 'bank_id', name=op.f('pk_documents'))
)
op.create_index("idx_documents_bank_id", "documents", ["bank_id"])
op.create_index("idx_documents_content_hash", "documents", ["content_hash"])
op.create_index('idx_documents_bank_id', 'documents', ['bank_id'])
op.create_index('idx_documents_content_hash', 'documents', ['content_hash'])
# Create async_operations table
op.create_table(
"async_operations",
sa.Column(
"operation_id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False
),
sa.Column("bank_id", sa.Text(), nullable=False),
sa.Column("operation_type", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), server_default="pending", nullable=False),
sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("completed_at", postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column(
"result_metadata",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.PrimaryKeyConstraint("operation_id", name=op.f("pk_async_operations")),
sa.CheckConstraint(
"status IN ('pending', 'processing', 'completed', 'failed')", name="async_operations_status_check"
),
'async_operations',
sa.Column('operation_id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False),
sa.Column('bank_id', sa.Text(), nullable=False),
sa.Column('operation_type', sa.Text(), nullable=False),
sa.Column('status', sa.Text(), server_default='pending', nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('completed_at', postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('result_metadata', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.PrimaryKeyConstraint('operation_id', name=op.f('pk_async_operations')),
sa.CheckConstraint("status IN ('pending', 'processing', 'completed', 'failed')", name='async_operations_status_check')
)
op.create_index("idx_async_operations_bank_id", "async_operations", ["bank_id"])
op.create_index("idx_async_operations_status", "async_operations", ["status"])
op.create_index("idx_async_operations_bank_status", "async_operations", ["bank_id", "status"])
op.create_index('idx_async_operations_bank_id', 'async_operations', ['bank_id'])
op.create_index('idx_async_operations_status', 'async_operations', ['status'])
op.create_index('idx_async_operations_bank_status', 'async_operations', ['bank_id', 'status'])
# Create entities table
op.create_table(
"entities",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("canonical_name", sa.Text(), nullable=False),
sa.Column("bank_id", sa.Text(), nullable=False),
sa.Column(
"metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False
),
sa.Column("first_seen", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("last_seen", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("mention_count", sa.Integer(), server_default="1", nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_entities")),
'entities',
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False),
sa.Column('canonical_name', sa.Text(), nullable=False),
sa.Column('bank_id', sa.Text(), nullable=False),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column('first_seen', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('last_seen', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('mention_count', sa.Integer(), server_default='1', nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('pk_entities'))
)
op.create_index("idx_entities_bank_id", "entities", ["bank_id"])
op.create_index("idx_entities_canonical_name", "entities", ["canonical_name"])
op.create_index("idx_entities_bank_name", "entities", ["bank_id", "canonical_name"])
op.create_index('idx_entities_bank_id', 'entities', ['bank_id'])
op.create_index('idx_entities_canonical_name', 'entities', ['canonical_name'])
op.create_index('idx_entities_bank_name', 'entities', ['bank_id', 'canonical_name'])
# Create unique index on (bank_id, LOWER(canonical_name)) for entity resolution
op.execute("CREATE UNIQUE INDEX idx_entities_bank_lower_name ON entities (bank_id, LOWER(canonical_name))")
op.execute('CREATE UNIQUE INDEX idx_entities_bank_lower_name ON entities (bank_id, LOWER(canonical_name))')
# Create memory_units table
op.create_table(
"memory_units",
sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column("bank_id", sa.Text(), nullable=False),
sa.Column("document_id", sa.Text(), nullable=True),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("embedding", Vector(384), nullable=True),
sa.Column("context", sa.Text(), nullable=True),
sa.Column("event_date", postgresql.TIMESTAMP(timezone=True), nullable=False),
sa.Column("occurred_start", postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column("occurred_end", postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column("mentioned_at", postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column("fact_type", sa.Text(), server_default="world", nullable=False),
sa.Column("confidence_score", sa.Float(), nullable=True),
sa.Column("access_count", sa.Integer(), server_default="0", nullable=False),
sa.Column(
"metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False
),
sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["document_id", "bank_id"],
["documents.id", "documents.bank_id"],
name="memory_units_document_fkey",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_memory_units")),
sa.CheckConstraint(
"fact_type IN ('world', 'bank', 'opinion', 'observation')", name="memory_units_fact_type_check"
),
sa.CheckConstraint(
"confidence_score IS NULL OR (confidence_score >= 0.0 AND confidence_score <= 1.0)",
name="memory_units_confidence_range_check",
),
'memory_units',
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), nullable=False),
sa.Column('bank_id', sa.Text(), nullable=False),
sa.Column('document_id', sa.Text(), nullable=True),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('embedding', Vector(384), nullable=True),
sa.Column('context', sa.Text(), nullable=True),
sa.Column('event_date', postgresql.TIMESTAMP(timezone=True), nullable=False),
sa.Column('occurred_start', postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column('occurred_end', postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column('mentioned_at', postgresql.TIMESTAMP(timezone=True), nullable=True),
sa.Column('fact_type', sa.Text(), server_default='world', nullable=False),
sa.Column('confidence_score', sa.Float(), nullable=True),
sa.Column('access_count', sa.Integer(), server_default='0', nullable=False),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['document_id', 'bank_id'], ['documents.id', 'documents.bank_id'], name='memory_units_document_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_memory_units')),
sa.CheckConstraint("fact_type IN ('world', 'bank', 'opinion', 'observation')", name='memory_units_fact_type_check'),
sa.CheckConstraint("confidence_score IS NULL OR (confidence_score >= 0.0 AND confidence_score <= 1.0)", name='memory_units_confidence_range_check'),
sa.CheckConstraint(
"(fact_type = 'opinion' AND confidence_score IS NOT NULL) OR "
"(fact_type = 'observation') OR "
"(fact_type NOT IN ('opinion', 'observation') AND confidence_score IS NULL)",
name="confidence_score_fact_type_check",
),
name='confidence_score_fact_type_check'
)
)
# Add search_vector column for full-text search
@@ -158,41 +128,18 @@ def upgrade() -> None:
GENERATED ALWAYS AS (to_tsvector('english', COALESCE(text, '') || ' ' || COALESCE(context, ''))) STORED
""")
op.create_index("idx_memory_units_bank_id", "memory_units", ["bank_id"])
op.create_index("idx_memory_units_document_id", "memory_units", ["document_id"])
op.create_index("idx_memory_units_event_date", "memory_units", [sa.text("event_date DESC")])
op.create_index("idx_memory_units_bank_date", "memory_units", ["bank_id", sa.text("event_date DESC")])
op.create_index("idx_memory_units_access_count", "memory_units", [sa.text("access_count DESC")])
op.create_index("idx_memory_units_fact_type", "memory_units", ["fact_type"])
op.create_index("idx_memory_units_bank_fact_type", "memory_units", ["bank_id", "fact_type"])
op.create_index(
"idx_memory_units_bank_type_date", "memory_units", ["bank_id", "fact_type", sa.text("event_date DESC")]
)
op.create_index(
"idx_memory_units_opinion_confidence",
"memory_units",
["bank_id", sa.text("confidence_score DESC")],
postgresql_where=sa.text("fact_type = 'opinion'"),
)
op.create_index(
"idx_memory_units_opinion_date",
"memory_units",
["bank_id", sa.text("event_date DESC")],
postgresql_where=sa.text("fact_type = 'opinion'"),
)
op.create_index(
"idx_memory_units_observation_date",
"memory_units",
["bank_id", sa.text("event_date DESC")],
postgresql_where=sa.text("fact_type = 'observation'"),
)
op.create_index(
"idx_memory_units_embedding",
"memory_units",
["embedding"],
postgresql_using="hnsw",
postgresql_ops={"embedding": "vector_cosine_ops"},
)
op.create_index('idx_memory_units_bank_id', 'memory_units', ['bank_id'])
op.create_index('idx_memory_units_document_id', 'memory_units', ['document_id'])
op.create_index('idx_memory_units_event_date', 'memory_units', [sa.text('event_date DESC')])
op.create_index('idx_memory_units_bank_date', 'memory_units', ['bank_id', sa.text('event_date DESC')])
op.create_index('idx_memory_units_access_count', 'memory_units', [sa.text('access_count DESC')])
op.create_index('idx_memory_units_fact_type', 'memory_units', ['fact_type'])
op.create_index('idx_memory_units_bank_fact_type', 'memory_units', ['bank_id', 'fact_type'])
op.create_index('idx_memory_units_bank_type_date', 'memory_units', ['bank_id', 'fact_type', sa.text('event_date DESC')])
op.create_index('idx_memory_units_opinion_confidence', 'memory_units', ['bank_id', sa.text('confidence_score DESC')], postgresql_where=sa.text("fact_type = 'opinion'"))
op.create_index('idx_memory_units_opinion_date', 'memory_units', ['bank_id', sa.text('event_date DESC')], postgresql_where=sa.text("fact_type = 'opinion'"))
op.create_index('idx_memory_units_observation_date', 'memory_units', ['bank_id', sa.text('event_date DESC')], postgresql_where=sa.text("fact_type = 'observation'"))
op.create_index('idx_memory_units_embedding', 'memory_units', ['embedding'], postgresql_using='hnsw', postgresql_ops={'embedding': 'vector_cosine_ops'})
# Create BM25 full-text search index on search_vector
op.execute("""
@@ -211,149 +158,116 @@ def upgrade() -> None:
FROM memory_units
""")
op.create_index("idx_memory_units_bm25_bank", "memory_units_bm25", ["bank_id"])
op.create_index("idx_memory_units_bm25_text_vector", "memory_units_bm25", ["text_vector"], postgresql_using="gin")
op.create_index('idx_memory_units_bm25_bank', 'memory_units_bm25', ['bank_id'])
op.create_index('idx_memory_units_bm25_text_vector', 'memory_units_bm25', ['text_vector'], postgresql_using='gin')
# Create entity_cooccurrences table
op.create_table(
"entity_cooccurrences",
sa.Column("entity_id_1", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("entity_id_2", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("cooccurrence_count", sa.Integer(), server_default="1", nullable=False),
sa.Column(
"last_cooccurred", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False
),
sa.ForeignKeyConstraint(
["entity_id_1"],
["entities.id"],
name=op.f("fk_entity_cooccurrences_entity_id_1_entities"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["entity_id_2"],
["entities.id"],
name=op.f("fk_entity_cooccurrences_entity_id_2_entities"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("entity_id_1", "entity_id_2", name=op.f("pk_entity_cooccurrences")),
sa.CheckConstraint("entity_id_1 < entity_id_2", name="entity_cooccurrence_order_check"),
'entity_cooccurrences',
sa.Column('entity_id_1', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('entity_id_2', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('cooccurrence_count', sa.Integer(), server_default='1', nullable=False),
sa.Column('last_cooccurred', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['entity_id_1'], ['entities.id'], name=op.f('fk_entity_cooccurrences_entity_id_1_entities'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['entity_id_2'], ['entities.id'], name=op.f('fk_entity_cooccurrences_entity_id_2_entities'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('entity_id_1', 'entity_id_2', name=op.f('pk_entity_cooccurrences')),
sa.CheckConstraint('entity_id_1 < entity_id_2', name='entity_cooccurrence_order_check')
)
op.create_index("idx_entity_cooccurrences_entity1", "entity_cooccurrences", ["entity_id_1"])
op.create_index("idx_entity_cooccurrences_entity2", "entity_cooccurrences", ["entity_id_2"])
op.create_index("idx_entity_cooccurrences_count", "entity_cooccurrences", [sa.text("cooccurrence_count DESC")])
op.create_index('idx_entity_cooccurrences_entity1', 'entity_cooccurrences', ['entity_id_1'])
op.create_index('idx_entity_cooccurrences_entity2', 'entity_cooccurrences', ['entity_id_2'])
op.create_index('idx_entity_cooccurrences_count', 'entity_cooccurrences', [sa.text('cooccurrence_count DESC')])
# Create memory_links table
op.create_table(
"memory_links",
sa.Column("from_unit_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("to_unit_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("link_type", sa.Text(), nullable=False),
sa.Column("entity_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("weight", sa.Float(), server_default="1.0", nullable=False),
sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["entity_id"], ["entities.id"], name=op.f("fk_memory_links_entity_id_entities"), ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["from_unit_id"],
["memory_units.id"],
name=op.f("fk_memory_links_from_unit_id_memory_units"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["to_unit_id"],
["memory_units.id"],
name=op.f("fk_memory_links_to_unit_id_memory_units"),
ondelete="CASCADE",
),
sa.CheckConstraint(
"link_type IN ('temporal', 'semantic', 'entity', 'causes', 'caused_by', 'enables', 'prevents')",
name="memory_links_link_type_check",
),
sa.CheckConstraint("weight >= 0.0 AND weight <= 1.0", name="memory_links_weight_check"),
'memory_links',
sa.Column('from_unit_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('to_unit_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('link_type', sa.Text(), nullable=False),
sa.Column('entity_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('weight', sa.Float(), server_default='1.0', nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], name=op.f('fk_memory_links_entity_id_entities'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['from_unit_id'], ['memory_units.id'], name=op.f('fk_memory_links_from_unit_id_memory_units'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['to_unit_id'], ['memory_units.id'], name=op.f('fk_memory_links_to_unit_id_memory_units'), ondelete='CASCADE'),
sa.CheckConstraint("link_type IN ('temporal', 'semantic', 'entity', 'causes', 'caused_by', 'enables', 'prevents')", name='memory_links_link_type_check'),
sa.CheckConstraint('weight >= 0.0 AND weight <= 1.0', name='memory_links_weight_check')
)
# Create unique constraint using COALESCE for nullable entity_id
op.execute(
"CREATE UNIQUE INDEX idx_memory_links_unique ON memory_links (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid))"
)
op.create_index("idx_memory_links_from_unit", "memory_links", ["from_unit_id"])
op.create_index("idx_memory_links_to_unit", "memory_links", ["to_unit_id"])
op.create_index("idx_memory_links_entity", "memory_links", ["entity_id"])
op.create_index("idx_memory_links_link_type", "memory_links", ["link_type"])
op.execute("CREATE UNIQUE INDEX idx_memory_links_unique ON memory_links (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid))")
op.create_index('idx_memory_links_from_unit', 'memory_links', ['from_unit_id'])
op.create_index('idx_memory_links_to_unit', 'memory_links', ['to_unit_id'])
op.create_index('idx_memory_links_entity', 'memory_links', ['entity_id'])
op.create_index('idx_memory_links_link_type', 'memory_links', ['link_type'])
# Create unit_entities table
op.create_table(
"unit_entities",
sa.Column("unit_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("entity_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(
["entity_id"], ["entities.id"], name=op.f("fk_unit_entities_entity_id_entities"), ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["unit_id"], ["memory_units.id"], name=op.f("fk_unit_entities_unit_id_memory_units"), ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("unit_id", "entity_id", name=op.f("pk_unit_entities")),
'unit_entities',
sa.Column('unit_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('entity_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(['entity_id'], ['entities.id'], name=op.f('fk_unit_entities_entity_id_entities'), ondelete='CASCADE'),
sa.ForeignKeyConstraint(['unit_id'], ['memory_units.id'], name=op.f('fk_unit_entities_unit_id_memory_units'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('unit_id', 'entity_id', name=op.f('pk_unit_entities'))
)
op.create_index("idx_unit_entities_unit", "unit_entities", ["unit_id"])
op.create_index("idx_unit_entities_entity", "unit_entities", ["entity_id"])
op.create_index('idx_unit_entities_unit', 'unit_entities', ['unit_id'])
op.create_index('idx_unit_entities_entity', 'unit_entities', ['entity_id'])
def downgrade() -> None:
"""Downgrade schema - drop all tables."""
# Drop tables in reverse dependency order
op.drop_index("idx_unit_entities_entity", table_name="unit_entities")
op.drop_index("idx_unit_entities_unit", table_name="unit_entities")
op.drop_table("unit_entities")
op.drop_index('idx_unit_entities_entity', table_name='unit_entities')
op.drop_index('idx_unit_entities_unit', table_name='unit_entities')
op.drop_table('unit_entities')
op.drop_index("idx_memory_links_link_type", table_name="memory_links")
op.drop_index("idx_memory_links_entity", table_name="memory_links")
op.drop_index("idx_memory_links_to_unit", table_name="memory_links")
op.drop_index("idx_memory_links_from_unit", table_name="memory_links")
op.execute("DROP INDEX IF EXISTS idx_memory_links_unique")
op.drop_table("memory_links")
op.drop_index('idx_memory_links_link_type', table_name='memory_links')
op.drop_index('idx_memory_links_entity', table_name='memory_links')
op.drop_index('idx_memory_links_to_unit', table_name='memory_links')
op.drop_index('idx_memory_links_from_unit', table_name='memory_links')
op.execute('DROP INDEX IF EXISTS idx_memory_links_unique')
op.drop_table('memory_links')
op.drop_index("idx_entity_cooccurrences_count", table_name="entity_cooccurrences")
op.drop_index("idx_entity_cooccurrences_entity2", table_name="entity_cooccurrences")
op.drop_index("idx_entity_cooccurrences_entity1", table_name="entity_cooccurrences")
op.drop_table("entity_cooccurrences")
op.drop_index('idx_entity_cooccurrences_count', table_name='entity_cooccurrences')
op.drop_index('idx_entity_cooccurrences_entity2', table_name='entity_cooccurrences')
op.drop_index('idx_entity_cooccurrences_entity1', table_name='entity_cooccurrences')
op.drop_table('entity_cooccurrences')
# Drop BM25 materialized view and index
op.drop_index("idx_memory_units_bm25_text_vector", table_name="memory_units_bm25")
op.drop_index("idx_memory_units_bm25_bank", table_name="memory_units_bm25")
op.execute("DROP MATERIALIZED VIEW IF EXISTS memory_units_bm25")
op.drop_index('idx_memory_units_bm25_text_vector', table_name='memory_units_bm25')
op.drop_index('idx_memory_units_bm25_bank', table_name='memory_units_bm25')
op.execute('DROP MATERIALIZED VIEW IF EXISTS memory_units_bm25')
op.drop_index("idx_memory_units_embedding", table_name="memory_units")
op.drop_index("idx_memory_units_observation_date", table_name="memory_units")
op.drop_index("idx_memory_units_opinion_date", table_name="memory_units")
op.drop_index("idx_memory_units_opinion_confidence", table_name="memory_units")
op.drop_index("idx_memory_units_bank_type_date", table_name="memory_units")
op.drop_index("idx_memory_units_bank_fact_type", table_name="memory_units")
op.drop_index("idx_memory_units_fact_type", table_name="memory_units")
op.drop_index("idx_memory_units_access_count", table_name="memory_units")
op.drop_index("idx_memory_units_bank_date", table_name="memory_units")
op.drop_index("idx_memory_units_event_date", table_name="memory_units")
op.drop_index("idx_memory_units_document_id", table_name="memory_units")
op.drop_index("idx_memory_units_bank_id", table_name="memory_units")
op.execute("DROP INDEX IF EXISTS idx_memory_units_text_search")
op.drop_table("memory_units")
op.drop_index('idx_memory_units_embedding', table_name='memory_units')
op.drop_index('idx_memory_units_observation_date', table_name='memory_units')
op.drop_index('idx_memory_units_opinion_date', table_name='memory_units')
op.drop_index('idx_memory_units_opinion_confidence', table_name='memory_units')
op.drop_index('idx_memory_units_bank_type_date', table_name='memory_units')
op.drop_index('idx_memory_units_bank_fact_type', table_name='memory_units')
op.drop_index('idx_memory_units_fact_type', table_name='memory_units')
op.drop_index('idx_memory_units_access_count', table_name='memory_units')
op.drop_index('idx_memory_units_bank_date', table_name='memory_units')
op.drop_index('idx_memory_units_event_date', table_name='memory_units')
op.drop_index('idx_memory_units_document_id', table_name='memory_units')
op.drop_index('idx_memory_units_bank_id', table_name='memory_units')
op.execute('DROP INDEX IF EXISTS idx_memory_units_text_search')
op.drop_table('memory_units')
op.execute("DROP INDEX IF EXISTS idx_entities_bank_lower_name")
op.drop_index("idx_entities_bank_name", table_name="entities")
op.drop_index("idx_entities_canonical_name", table_name="entities")
op.drop_index("idx_entities_bank_id", table_name="entities")
op.drop_table("entities")
op.execute('DROP INDEX IF EXISTS idx_entities_bank_lower_name')
op.drop_index('idx_entities_bank_name', table_name='entities')
op.drop_index('idx_entities_canonical_name', table_name='entities')
op.drop_index('idx_entities_bank_id', table_name='entities')
op.drop_table('entities')
op.drop_index("idx_async_operations_bank_status", table_name="async_operations")
op.drop_index("idx_async_operations_status", table_name="async_operations")
op.drop_index("idx_async_operations_bank_id", table_name="async_operations")
op.drop_table("async_operations")
op.drop_index('idx_async_operations_bank_status', table_name='async_operations')
op.drop_index('idx_async_operations_status', table_name='async_operations')
op.drop_index('idx_async_operations_bank_id', table_name='async_operations')
op.drop_table('async_operations')
op.drop_index("idx_documents_content_hash", table_name="documents")
op.drop_index("idx_documents_bank_id", table_name="documents")
op.drop_table("documents")
op.drop_index('idx_documents_content_hash', table_name='documents')
op.drop_index('idx_documents_bank_id', table_name='documents')
op.drop_table('documents')
op.drop_table("banks")
op.drop_table('banks')
# Drop extensions (optional - comment out if you want to keep them)
# op.execute('DROP EXTENSION IF EXISTS vector')
@@ -5,18 +5,18 @@ Revises: 5a366d414dce
Create Date: 2025-11-28 00:00:00.000000
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "b7c4d8e9f1a2"
down_revision: str | Sequence[str] | None = "5a366d414dce"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
revision: str = 'b7c4d8e9f1a2'
down_revision: Union[str, Sequence[str], None] = '5a366d414dce'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
@@ -24,47 +24,47 @@ def upgrade() -> None:
# Create chunks table with single text PK (bank_id_document_id_chunk_index)
op.create_table(
"chunks",
sa.Column("chunk_id", sa.Text(), nullable=False),
sa.Column("document_id", sa.Text(), nullable=False),
sa.Column("bank_id", sa.Text(), nullable=False),
sa.Column("chunk_index", sa.Integer(), nullable=False),
sa.Column("chunk_text", sa.Text(), nullable=False),
sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(
["document_id", "bank_id"],
["documents.id", "documents.bank_id"],
name="chunks_document_fkey",
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("chunk_id", name=op.f("pk_chunks")),
'chunks',
sa.Column('chunk_id', sa.Text(), nullable=False),
sa.Column('document_id', sa.Text(), nullable=False),
sa.Column('bank_id', sa.Text(), nullable=False),
sa.Column('chunk_index', sa.Integer(), nullable=False),
sa.Column('chunk_text', sa.Text(), nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['document_id', 'bank_id'], ['documents.id', 'documents.bank_id'], name='chunks_document_fkey', ondelete='CASCADE'),
sa.PrimaryKeyConstraint('chunk_id', name=op.f('pk_chunks'))
)
# Add indexes for efficient queries
op.create_index("idx_chunks_document_id", "chunks", ["document_id"])
op.create_index("idx_chunks_bank_id", "chunks", ["bank_id"])
op.create_index('idx_chunks_document_id', 'chunks', ['document_id'])
op.create_index('idx_chunks_bank_id', 'chunks', ['bank_id'])
# Add chunk_id column to memory_units (nullable, as existing records won't have chunks)
op.add_column("memory_units", sa.Column("chunk_id", sa.Text(), nullable=True))
op.add_column('memory_units', sa.Column('chunk_id', sa.Text(), nullable=True))
# Add foreign key constraint to chunks table
op.create_foreign_key(
"memory_units_chunk_fkey", "memory_units", "chunks", ["chunk_id"], ["chunk_id"], ondelete="SET NULL"
'memory_units_chunk_fkey',
'memory_units',
'chunks',
['chunk_id'],
['chunk_id'],
ondelete='SET NULL'
)
# Add index on chunk_id for efficient lookups
op.create_index("idx_memory_units_chunk_id", "memory_units", ["chunk_id"])
op.create_index('idx_memory_units_chunk_id', 'memory_units', ['chunk_id'])
def downgrade() -> None:
"""Remove chunks table and chunk_id from memory_units."""
# Drop index and foreign key from memory_units
op.drop_index("idx_memory_units_chunk_id", table_name="memory_units")
op.drop_constraint("memory_units_chunk_fkey", "memory_units", type_="foreignkey")
op.drop_column("memory_units", "chunk_id")
op.drop_index('idx_memory_units_chunk_id', table_name='memory_units')
op.drop_constraint('memory_units_chunk_fkey', 'memory_units', type_='foreignkey')
op.drop_column('memory_units', 'chunk_id')
# Drop chunks table indexes and table
op.drop_index("idx_chunks_bank_id", table_name="chunks")
op.drop_index("idx_chunks_document_id", table_name="chunks")
op.drop_table("chunks")
op.drop_index('idx_chunks_bank_id', table_name='chunks')
op.drop_index('idx_chunks_document_id', table_name='chunks')
op.drop_table('chunks')
@@ -5,35 +5,35 @@ Revises: b7c4d8e9f1a2
Create Date: 2025-12-02 00:00:00.000000
"""
from typing import Sequence, Union
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "c8e5f2a3b4d1"
down_revision: str | Sequence[str] | None = "b7c4d8e9f1a2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
revision: str = 'c8e5f2a3b4d1'
down_revision: Union[str, Sequence[str], None] = 'b7c4d8e9f1a2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add retain_params JSONB column to documents table."""
# Add retain_params column to store parameters passed during retain
op.add_column("documents", sa.Column("retain_params", postgresql.JSONB(), nullable=True))
op.add_column('documents', sa.Column('retain_params', postgresql.JSONB(), nullable=True))
# Add index for efficient queries on retain_params
op.create_index("idx_documents_retain_params", "documents", ["retain_params"], postgresql_using="gin")
op.create_index('idx_documents_retain_params', 'documents', ['retain_params'], postgresql_using='gin')
def downgrade() -> None:
"""Remove retain_params column from documents table."""
# Drop index
op.drop_index("idx_documents_retain_params", table_name="documents")
op.drop_index('idx_documents_retain_params', table_name='documents')
# Drop column
op.drop_column("documents", "retain_params")
op.drop_column('documents', 'retain_params')
@@ -5,49 +5,44 @@ Revises: c8e5f2a3b4d1
Create Date: 2024-12-04 15:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from alembic import context, op
# revision identifiers, used by Alembic.
revision = "d9f6a3b4c5e2"
down_revision = "c8e5f2a3b4d1"
revision = 'd9f6a3b4c5e2'
down_revision = 'c8e5f2a3b4d1'
branch_labels = None
depends_on = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (e.g., 'tenant_x.' or '' for public)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade():
schema = _get_schema_prefix()
# Drop old check constraint FIRST (before updating data)
op.drop_constraint("memory_units_fact_type_check", "memory_units", type_="check")
op.drop_constraint('memory_units_fact_type_check', 'memory_units', type_='check')
# Update existing 'bank' values to 'experience'
op.execute(f"UPDATE {schema}memory_units SET fact_type = 'experience' WHERE fact_type = 'bank'")
op.execute("UPDATE memory_units SET fact_type = 'experience' WHERE fact_type = 'bank'")
# Also update any 'interactions' values (in case of partial migration)
op.execute(f"UPDATE {schema}memory_units SET fact_type = 'experience' WHERE fact_type = 'interactions'")
op.execute("UPDATE memory_units SET fact_type = 'experience' WHERE fact_type = 'interactions'")
# Create new check constraint with 'experience' instead of 'bank'
op.create_check_constraint(
"memory_units_fact_type_check", "memory_units", "fact_type IN ('world', 'experience', 'opinion', 'observation')"
'memory_units_fact_type_check',
'memory_units',
"fact_type IN ('world', 'experience', 'opinion', 'observation')"
)
def downgrade():
schema = _get_schema_prefix()
# Drop new check constraint FIRST
op.drop_constraint("memory_units_fact_type_check", "memory_units", type_="check")
op.drop_constraint('memory_units_fact_type_check', 'memory_units', type_='check')
# Update 'experience' back to 'bank'
op.execute(f"UPDATE {schema}memory_units SET fact_type = 'bank' WHERE fact_type = 'experience'")
op.execute("UPDATE memory_units SET fact_type = 'bank' WHERE fact_type = 'experience'")
# Recreate old check constraint
op.create_check_constraint(
"memory_units_fact_type_check", "memory_units", "fact_type IN ('world', 'bank', 'opinion', 'observation')"
'memory_units_fact_type_check',
'memory_units',
"fact_type IN ('world', 'bank', 'opinion', 'observation')"
)
@@ -8,49 +8,22 @@ Migrate disposition traits from Big Five (openness, conscientiousness, extravers
agreeableness, neuroticism, bias_strength with 0-1 float values) to the new 3-trait
system (skepticism, literalism, empathy with 1-5 integer values).
"""
from typing import Sequence, Union
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from alembic import context, op
# revision identifiers, used by Alembic.
revision: str = "e0a1b2c3d4e5"
down_revision: str | Sequence[str] | None = "rename_personality"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (e.g., 'tenant_x.' or '' for public)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def _get_target_schema() -> str:
"""Get the target schema name (tenant schema or 'public')."""
schema = context.config.get_main_option("target_schema")
return schema if schema else "public"
revision: str = 'e0a1b2c3d4e5'
down_revision: Union[str, Sequence[str], None] = 'rename_personality'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Convert Big Five disposition to 3-trait disposition."""
conn = op.get_bind()
schema = _get_schema_prefix()
target_schema = _get_target_schema()
# Check if disposition column exists (should have been created by previous migration)
result = conn.execute(
sa.text("""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'disposition'
"""),
{"schema": target_schema},
)
if not result.fetchone():
# Column doesn't exist yet (shouldn't happen but be safe)
return
# Update all existing banks to use the new disposition format
# Convert from old format to new format with reasonable mappings:
@@ -58,54 +31,32 @@ def upgrade() -> None:
# - literalism: derived from conscientiousness (detail-oriented people are more literal)
# - empathy: derived from agreeableness + inverse of neuroticism
# Default all to 3 (neutral) for simplicity
conn.execute(
sa.text(f"""
UPDATE {schema}banks
SET disposition = '{{"skepticism": 3, "literalism": 3, "empathy": 3}}'::jsonb
conn.execute(sa.text("""
UPDATE banks
SET disposition = '{"skepticism": 3, "literalism": 3, "empathy": 3}'::jsonb
WHERE disposition IS NOT NULL
""")
)
"""))
# Update the default for new banks
conn.execute(
sa.text(f"""
ALTER TABLE {schema}banks
ALTER COLUMN disposition SET DEFAULT '{{"skepticism": 3, "literalism": 3, "empathy": 3}}'::jsonb
""")
)
conn.execute(sa.text("""
ALTER TABLE banks
ALTER COLUMN disposition SET DEFAULT '{"skepticism": 3, "literalism": 3, "empathy": 3}'::jsonb
"""))
def downgrade() -> None:
"""Convert back to Big Five disposition."""
conn = op.get_bind()
schema = _get_schema_prefix()
target_schema = _get_target_schema()
# Check if disposition column exists
result = conn.execute(
sa.text("""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'disposition'
"""),
{"schema": target_schema},
)
if not result.fetchone():
return
# Revert to Big Five format with default values
conn.execute(
sa.text(f"""
UPDATE {schema}banks
SET disposition = '{{"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.5, "agreeableness": 0.5, "neuroticism": 0.5, "bias_strength": 0.5}}'::jsonb
conn.execute(sa.text("""
UPDATE banks
SET disposition = '{"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.5, "agreeableness": 0.5, "neuroticism": 0.5, "bias_strength": 0.5}'::jsonb
WHERE disposition IS NOT NULL
""")
)
"""))
# Update the default for new banks
conn.execute(
sa.text(f"""
ALTER TABLE {schema}banks
ALTER COLUMN disposition SET DEFAULT '{{"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.5, "agreeableness": 0.5, "neuroticism": 0.5, "bias_strength": 0.5}}'::jsonb
""")
)
conn.execute(sa.text("""
ALTER TABLE banks
ALTER COLUMN disposition SET DEFAULT '{"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.5, "agreeableness": 0.5, "neuroticism": 0.5, "bias_strength": 0.5}'::jsonb
"""))
@@ -5,81 +5,61 @@ Revises: d9f6a3b4c5e2
Create Date: 2024-12-04
"""
from typing import Sequence, Union
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from alembic import context, op
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "rename_personality"
down_revision: str | Sequence[str] | None = "d9f6a3b4c5e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_target_schema() -> str:
"""Get the target schema name (tenant schema or 'public')."""
schema = context.config.get_main_option("target_schema")
return schema if schema else "public"
revision: str = 'rename_personality'
down_revision: Union[str, Sequence[str], None] = 'd9f6a3b4c5e2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Rename personality column to disposition in banks table (if it exists)."""
conn = op.get_bind()
target_schema = _get_target_schema()
# Check if 'personality' column exists (old database)
result = conn.execute(
sa.text("""
result = conn.execute(sa.text("""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'personality'
"""),
{"schema": target_schema},
)
WHERE table_name = 'banks' AND column_name = 'personality'
"""))
has_personality = result.fetchone() is not None
# Check if 'disposition' column exists (new database)
result = conn.execute(
sa.text("""
result = conn.execute(sa.text("""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'disposition'
"""),
{"schema": target_schema},
)
WHERE table_name = 'banks' AND column_name = 'disposition'
"""))
has_disposition = result.fetchone() is not None
if has_personality and not has_disposition:
# Old database: rename personality -> disposition
op.alter_column("banks", "personality", new_column_name="disposition")
op.alter_column('banks', 'personality', new_column_name='disposition')
elif not has_personality and not has_disposition:
# Neither exists (shouldn't happen, but be safe): add disposition column
op.add_column(
"banks",
sa.Column(
"disposition",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
)
op.add_column('banks', sa.Column(
'disposition',
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False
))
# else: disposition already exists, nothing to do
def downgrade() -> None:
"""Revert disposition column back to personality."""
conn = op.get_bind()
target_schema = _get_target_schema()
result = conn.execute(
sa.text("""
result = conn.execute(sa.text("""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'disposition'
"""),
{"schema": target_schema},
)
WHERE table_name = 'banks' AND column_name = 'disposition'
"""))
if result.fetchone():
op.alter_column("banks", "disposition", new_column_name="personality")
op.alter_column('banks', 'disposition', new_column_name='personality')
+10 -10
View File
@@ -3,10 +3,8 @@ Unified API module for Hindsight.
Provides both HTTP REST API and MCP (Model Context Protocol) server.
"""
import logging
from typing import Optional
from fastapi import FastAPI
from hindsight_api import MemoryEngine
@@ -19,7 +17,7 @@ def create_app(
http_api_enabled: bool = True,
mcp_api_enabled: bool = False,
mcp_mount_path: str = "/mcp",
initialize_memory: bool = True,
initialize_memory: bool = True
) -> FastAPI:
"""
Create and configure the unified Hindsight API application.
@@ -49,8 +47,10 @@ def create_app(
# Import and create HTTP API if enabled
if http_api_enabled:
from .http import create_app as create_http_app
app = create_http_app(memory=memory, initialize_memory=initialize_memory)
app = create_http_app(
memory=memory,
initialize_memory=initialize_memory
)
logger.info("HTTP REST API enabled")
else:
# Create minimal FastAPI app
@@ -77,15 +77,15 @@ def create_app(
# Re-export commonly used items for backwards compatibility
from .http import (
CreateBankRequest,
DispositionTraits,
MemoryItem,
RecallRequest,
RecallResponse,
RecallResult,
RecallResponse,
MemoryItem,
RetainRequest,
ReflectRequest,
ReflectResponse,
RetainRequest,
CreateBankRequest,
DispositionTraits,
)
__all__ = [
File diff suppressed because it is too large Load Diff
+30 -39
View File
@@ -4,34 +4,27 @@ import json
import logging
import os
from contextvars import ContextVar
from typing import Optional
from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.models import RequestContext
# Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower()
_log_level_map = {
"critical": logging.CRITICAL,
"error": logging.ERROR,
"warning": logging.WARNING,
"info": logging.INFO,
"debug": logging.DEBUG,
"trace": logging.DEBUG,
}
_log_level_map = {"critical": logging.CRITICAL, "error": logging.ERROR, "warning": logging.WARNING,
"info": logging.INFO, "debug": logging.DEBUG, "trace": logging.DEBUG}
logging.basicConfig(
level=_log_level_map.get(_log_level_str, logging.INFO),
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s"
)
logger = logging.getLogger(__name__)
# Context variable to hold the current bank_id from the URL path
_current_bank_id: ContextVar[str | None] = ContextVar("current_bank_id", default=None)
_current_bank_id: ContextVar[Optional[str]] = ContextVar("current_bank_id", default=None)
def get_current_bank_id() -> str | None:
def get_current_bank_id() -> Optional[str]:
"""Get the current bank_id from context (set from URL path)."""
return _current_bank_id.get()
@@ -68,10 +61,9 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
"""
try:
bank_id = get_current_bank_id()
if bank_id is None:
return "Error: No bank_id configured"
await memory.retain_batch_async(
bank_id=bank_id, contents=[{"content": content, "context": context}], request_context=RequestContext()
await memory.put_batch_async(
bank_id=bank_id,
contents=[{"content": content, "context": context}]
)
return "Memory stored successfully"
except Exception as e:
@@ -95,16 +87,12 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
"""
try:
bank_id = get_current_bank_id()
if bank_id is None:
return "Error: No bank_id configured"
from hindsight_api.engine.memory_engine import Budget
search_result = await memory.recall_async(
bank_id=bank_id,
query=query,
fact_type=list(VALID_RECALL_FACT_TYPES),
budget=Budget.LOW,
request_context=RequestContext(),
budget=Budget.LOW
)
results = [
@@ -113,7 +101,7 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
"text": fact.text,
"type": fact.fact_type,
"context": fact.context,
"occurred_start": fact.occurred_start,
"event_date": fact.event_date,
}
for fact in search_result.results[:max_results]
]
@@ -133,7 +121,11 @@ class MCPMiddleware:
self.app = app
self.memory = memory
self.mcp_server = create_mcp_server(memory)
self.mcp_app = self.mcp_server.http_app()
# Use sse_app - http_app requires lifespan management that's complex with middleware
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
self.mcp_app = self.mcp_server.sse_app()
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
@@ -145,7 +137,7 @@ class MCPMiddleware:
# Strip any mount prefix (e.g., /mcp) that FastAPI might not have stripped
root_path = scope.get("root_path", "")
if root_path and path.startswith(root_path):
path = path[len(root_path) :] or "/"
path = path[len(root_path):] or "/"
# Also handle case where mount path wasn't stripped (e.g., /mcp/...)
if path.startswith("/mcp/"):
@@ -181,7 +173,10 @@ class MCPMiddleware:
body = message.get("body", b"")
if body and b"/messages" in body:
# Rewrite /messages to /{bank_id}/messages in SSE endpoint event
body = body.replace(b"data: /messages", f"data: /{bank_id}/messages".encode())
body = body.replace(
b"data: /messages",
f"data: /{bank_id}/messages".encode()
)
message = {**message, "body": body}
await send(message)
@@ -192,19 +187,15 @@ class MCPMiddleware:
async def _send_error(self, send, status: int, message: str):
"""Send an error response."""
body = json.dumps({"error": message}).encode()
await send(
{
"type": "http.response.start",
"status": status,
"headers": [(b"content-type", b"application/json")],
}
)
await send(
{
"type": "http.response.body",
"body": body,
}
)
await send({
"type": "http.response.start",
"status": status,
"headers": [(b"content-type", b"application/json")],
})
await send({
"type": "http.response.body",
"body": body,
})
def create_mcp_app(memory: MemoryEngine):
+6 -13
View File
@@ -6,7 +6,7 @@ Shows the logo and tagline with gradient colors.
# Gradient colors: #0074d9 -> #009296
GRADIENT_START = (0, 116, 217) # #0074d9
GRADIENT_END = (0, 146, 150) # #009296
GRADIENT_END = (0, 146, 150) # #009296
# Pre-generated logo (generated by test-logo.py)
LOGO = """\
@@ -31,8 +31,8 @@ def gradient_text(text: str, start: tuple = GRADIENT_START, end: tuple = GRADIEN
result = []
length = len(text)
for i, char in enumerate(text):
if char == " ":
result.append(" ")
if char == ' ':
result.append(' ')
else:
t = i / max(length - 1, 1)
r, g, b = _interpolate_color(start, end, t)
@@ -74,16 +74,9 @@ def dim(text: str) -> str:
return f"\033[38;2;128;128;128m{text}\033[0m"
def print_startup_info(
host: str,
port: int,
database_url: str,
llm_provider: str,
llm_model: str,
embeddings_provider: str,
reranker_provider: str,
mcp_enabled: bool = False,
):
def print_startup_info(host: str, port: int, database_url: str, llm_provider: str,
llm_model: str, embeddings_provider: str, reranker_provider: str,
mcp_enabled: bool = False):
"""Print styled startup information."""
print(color_start("Starting Hindsight API..."))
print(f" {dim('URL:')} {color(f'http://{host}:{port}', 0.2)}")
+11 -50
View File
@@ -3,10 +3,10 @@ Centralized configuration for Hindsight API.
All environment variables and their defaults are defined here.
"""
import logging
import os
from dataclasses import dataclass
from typing import Optional
import logging
logger = logging.getLogger(__name__)
@@ -29,13 +29,6 @@ ENV_HOST = "HINDSIGHT_API_HOST"
ENV_PORT = "HINDSIGHT_API_PORT"
ENV_LOG_LEVEL = "HINDSIGHT_API_LOG_LEVEL"
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
# Optimization flags
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
# Default values
DEFAULT_DATABASE_URL = "pg0"
@@ -52,28 +45,6 @@ DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 8888
DEFAULT_LOG_LEVEL = "info"
DEFAULT_MCP_ENABLED = True
DEFAULT_GRAPH_RETRIEVER = "bfs" # Options: "bfs", "mpfp"
DEFAULT_MCP_LOCAL_BANK_ID = "mcp"
# Default MCP tool descriptions (can be customized via env vars)
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
Use this tool PROACTIVELY whenever the user shares:
- Personal facts, preferences, or interests
- Important events or milestones
- User history, experiences, or background
- Decisions, opinions, or stated preferences
- Goals, plans, or future intentions
- Relationships or people mentioned
- Work context, projects, or responsibilities"""
DEFAULT_MCP_RECALL_DESCRIPTION = """Search memories to provide personalized, context-aware responses.
Use this tool PROACTIVELY to:
- Check user's preferences before making suggestions
- Recall user's history to provide continuity
- Remember user's goals and context
- Personalize responses based on past interactions"""
# Required embedding dimension for database schema
EMBEDDING_DIMENSION = 384
@@ -88,19 +59,19 @@ class HindsightConfig:
# LLM
llm_provider: str
llm_api_key: str | None
llm_api_key: Optional[str]
llm_model: str
llm_base_url: str | None
llm_base_url: Optional[str]
# Embeddings
embeddings_provider: str
embeddings_local_model: str
embeddings_tei_url: str | None
embeddings_tei_url: Optional[str]
# Reranker
reranker_provider: str
reranker_local_model: str
reranker_tei_url: str | None
reranker_tei_url: Optional[str]
# Server
host: str
@@ -108,42 +79,34 @@ class HindsightConfig:
log_level: str
mcp_enabled: bool
# Recall
graph_retriever: str
# Optimization flags
skip_llm_verification: bool
lazy_reranker: bool
@classmethod
def from_env(cls) -> "HindsightConfig":
"""Create configuration from environment variables."""
return cls(
# Database
database_url=os.getenv(ENV_DATABASE_URL, DEFAULT_DATABASE_URL),
# LLM
llm_provider=os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER),
llm_api_key=os.getenv(ENV_LLM_API_KEY),
llm_model=os.getenv(ENV_LLM_MODEL, DEFAULT_LLM_MODEL),
llm_base_url=os.getenv(ENV_LLM_BASE_URL) or None,
# Embeddings
embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER),
embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL),
embeddings_tei_url=os.getenv(ENV_EMBEDDINGS_TEI_URL),
# Reranker
reranker_provider=os.getenv(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER),
reranker_local_model=os.getenv(ENV_RERANKER_LOCAL_MODEL, DEFAULT_RERANKER_LOCAL_MODEL),
reranker_tei_url=os.getenv(ENV_RERANKER_TEI_URL),
# Server
host=os.getenv(ENV_HOST, DEFAULT_HOST),
port=int(os.getenv(ENV_PORT, DEFAULT_PORT)),
log_level=os.getenv(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL),
mcp_enabled=os.getenv(ENV_MCP_ENABLED, str(DEFAULT_MCP_ENABLED)).lower() == "true",
# Recall
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
# Optimization flags
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
)
def get_llm_base_url(self) -> str:
@@ -175,8 +138,7 @@ class HindsightConfig:
"""Configure Python logging based on the log level."""
logging.basicConfig(
level=self.get_python_log_level(),
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
force=True, # Override any existing configuration
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s"
)
def log_config(self) -> None:
@@ -185,7 +147,6 @@ class HindsightConfig:
logger.info(f"LLM: provider={self.llm_provider}, model={self.llm_model}")
logger.info(f"Embeddings: provider={self.embeddings_provider}")
logger.info(f"Reranker: provider={self.reranker_provider}")
logger.info(f"Graph retriever: {self.graph_retriever}")
def get_config() -> HindsightConfig:
-204
View File
@@ -1,204 +0,0 @@
"""
Daemon mode support for Hindsight API.
Provides idle timeout and lockfile management for running as a background daemon.
"""
import asyncio
import fcntl
import logging
import os
import sys
import time
from pathlib import Path
logger = logging.getLogger(__name__)
# Default daemon configuration
DEFAULT_DAEMON_PORT = 8889
DEFAULT_IDLE_TIMEOUT = 0 # 0 = no auto-exit (hindsight-embed passes its own timeout)
LOCKFILE_PATH = Path.home() / ".hindsight" / "daemon.lock"
DAEMON_LOG_PATH = Path.home() / ".hindsight" / "daemon.log"
class IdleTimeoutMiddleware:
"""ASGI middleware that tracks activity and exits after idle timeout."""
def __init__(self, app, idle_timeout: int = DEFAULT_IDLE_TIMEOUT):
self.app = app
self.idle_timeout = idle_timeout
self.last_activity = time.time()
self._checker_task = None
async def __call__(self, scope, receive, send):
# Update activity timestamp on each request
self.last_activity = time.time()
await self.app(scope, receive, send)
def start_idle_checker(self):
"""Start the background task that checks for idle timeout."""
self._checker_task = asyncio.create_task(self._check_idle())
async def _check_idle(self):
"""Background task that exits the process after idle timeout."""
# If idle_timeout is 0, don't auto-exit
if self.idle_timeout <= 0:
return
while True:
await asyncio.sleep(30) # Check every 30 seconds
idle_time = time.time() - self.last_activity
if idle_time > self.idle_timeout:
logger.info(f"Idle timeout reached ({self.idle_timeout}s), shutting down daemon")
# Give a moment for any in-flight requests
await asyncio.sleep(1)
os._exit(0)
class DaemonLock:
"""
File-based lock to prevent multiple daemon instances.
Uses fcntl.flock for atomic locking on Unix systems.
"""
def __init__(self, lockfile: Path = LOCKFILE_PATH):
self.lockfile = lockfile
self._fd = None
def acquire(self) -> bool:
"""
Try to acquire the daemon lock.
Returns True if lock acquired, False if another daemon is running.
"""
self.lockfile.parent.mkdir(parents=True, exist_ok=True)
try:
self._fd = open(self.lockfile, "w")
fcntl.flock(self._fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
# Write PID for debugging
self._fd.write(str(os.getpid()))
self._fd.flush()
return True
except (IOError, OSError):
# Lock is held by another process
if self._fd:
self._fd.close()
self._fd = None
return False
def release(self):
"""Release the daemon lock."""
if self._fd:
try:
fcntl.flock(self._fd.fileno(), fcntl.LOCK_UN)
self._fd.close()
except Exception:
pass
finally:
self._fd = None
# Remove lockfile
try:
self.lockfile.unlink()
except Exception:
pass
def is_locked(self) -> bool:
"""Check if the lock is held by another process."""
if not self.lockfile.exists():
return False
try:
fd = open(self.lockfile, "r")
fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
# We got the lock, so no one else has it
fcntl.flock(fd.fileno(), fcntl.LOCK_UN)
fd.close()
return False
except (IOError, OSError):
return True
def get_pid(self) -> int | None:
"""Get the PID of the daemon holding the lock."""
if not self.lockfile.exists():
return None
try:
with open(self.lockfile, "r") as f:
return int(f.read().strip())
except (ValueError, IOError):
return None
def daemonize():
"""
Fork the current process into a background daemon.
Uses double-fork technique to properly detach from terminal.
"""
# First fork
pid = os.fork()
if pid > 0:
# Parent exits
sys.exit(0)
# Create new session
os.setsid()
# Second fork to prevent zombie processes
pid = os.fork()
if pid > 0:
sys.exit(0)
# Redirect standard file descriptors to log file
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
sys.stdout.flush()
sys.stderr.flush()
# Redirect stdin to /dev/null
with open("/dev/null", "r") as devnull:
os.dup2(devnull.fileno(), sys.stdin.fileno())
# Redirect stdout/stderr to log file
log_fd = open(DAEMON_LOG_PATH, "a")
os.dup2(log_fd.fileno(), sys.stdout.fileno())
os.dup2(log_fd.fileno(), sys.stderr.fileno())
def check_daemon_running(port: int = DEFAULT_DAEMON_PORT) -> bool:
"""Check if a daemon is running and responsive on the given port."""
import socket
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(1)
result = sock.connect_ex(("127.0.0.1", port))
sock.close()
return result == 0
except Exception:
return False
def stop_daemon(port: int = DEFAULT_DAEMON_PORT) -> bool:
"""Stop a running daemon by sending SIGTERM to the process."""
lock = DaemonLock()
pid = lock.get_pid()
if pid is None:
return False
try:
import signal
os.kill(pid, signal.SIGTERM)
# Wait for process to exit
for _ in range(50): # Wait up to 5 seconds
time.sleep(0.1)
try:
os.kill(pid, 0) # Check if process exists
except OSError:
return True # Process exited
return False
except OSError:
return False
+11 -22
View File
@@ -7,30 +7,24 @@ This package contains all the implementation details of the memory engine:
- Supporting modules: embeddings, cross_encoder, entity_resolver, etc.
"""
from .cross_encoder import CrossEncoderModel, LocalSTCrossEncoder, RemoteTEICrossEncoder
from .memory_engine import MemoryEngine
from .db_utils import acquire_with_retry
from .embeddings import Embeddings, LocalSTEmbeddings, RemoteTEIEmbeddings
from .llm_wrapper import LLMConfig
from .memory_engine import (
MemoryEngine,
UnqualifiedTableError,
fq_table,
get_current_schema,
validate_sql_schema,
)
from .response_models import MemoryFact, RecallResult, ReflectResult
from .cross_encoder import CrossEncoderModel, LocalSTCrossEncoder, RemoteTEICrossEncoder
from .search.trace import (
EntryPoint,
LinkInfo,
NodeVisit,
PruningDecision,
QueryInfo,
SearchPhaseMetrics,
SearchSummary,
SearchTrace,
QueryInfo,
EntryPoint,
NodeVisit,
WeightComponents,
LinkInfo,
PruningDecision,
SearchSummary,
SearchPhaseMetrics,
)
from .search.tracer import SearchTracer
from .llm_wrapper import LLMConfig
from .response_models import RecallResult, ReflectResult, MemoryFact
__all__ = [
"MemoryEngine",
@@ -55,9 +49,4 @@ __all__ = [
"RecallResult",
"ReflectResult",
"MemoryFact",
# Schema safety utilities
"fq_table",
"get_current_schema",
"validate_sql_schema",
"UnqualifiedTableError",
]
@@ -5,19 +5,19 @@ Provides an interface for reranking with different backends.
Configuration via environment variables - see hindsight_api.config for all env var names.
"""
from abc import ABC, abstractmethod
from typing import List, Tuple, Optional
import logging
import os
from abc import ABC, abstractmethod
import httpx
from ..config import (
DEFAULT_RERANKER_LOCAL_MODEL,
DEFAULT_RERANKER_PROVIDER,
ENV_RERANKER_LOCAL_MODEL,
ENV_RERANKER_PROVIDER,
ENV_RERANKER_LOCAL_MODEL,
ENV_RERANKER_TEI_URL,
DEFAULT_RERANKER_PROVIDER,
DEFAULT_RERANKER_LOCAL_MODEL,
)
logger = logging.getLogger(__name__)
@@ -47,7 +47,7 @@ class CrossEncoderModel(ABC):
pass
@abstractmethod
def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
def predict(self, pairs: List[Tuple[str, str]]) -> List[float]:
"""
Score query-document pairs for relevance.
@@ -72,7 +72,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
- Trained for passage re-ranking
"""
def __init__(self, model_name: str | None = None):
def __init__(self, model_name: Optional[str] = None):
"""
Initialize local SentenceTransformers cross-encoder.
@@ -104,7 +104,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
self._model = CrossEncoder(self.model_name)
logger.info("Reranker: local provider initialized")
def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
def predict(self, pairs: List[Tuple[str, str]]) -> List[float]:
"""
Score query-document pairs for relevance.
@@ -117,7 +117,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
if self._model is None:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
scores = self._model.predict(pairs, show_progress_bar=False)
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
return scores.tolist() if hasattr(scores, 'tolist') else list(scores)
class RemoteTEICrossEncoder(CrossEncoderModel):
@@ -153,8 +153,8 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
self.batch_size = batch_size
self.max_retries = max_retries
self.retry_delay = retry_delay
self._client: httpx.Client | None = None
self._model_id: str | None = None
self._client: Optional[httpx.Client] = None
self._model_id: Optional[str] = None
@property
def provider_name(self) -> str:
@@ -163,7 +163,6 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response:
"""Make an HTTP request with automatic retries on transient errors."""
import time
last_error = None
delay = self.retry_delay
@@ -178,18 +177,14 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as e:
last_error = e
if attempt < self.max_retries:
logger.warning(
f"TEI request failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s..."
)
logger.warning(f"TEI request failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s...")
time.sleep(delay)
delay *= 2 # Exponential backoff
except httpx.HTTPStatusError as e:
# Retry on 5xx server errors
if e.response.status_code >= 500 and attempt < self.max_retries:
last_error = e
logger.warning(
f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s..."
)
logger.warning(f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s...")
time.sleep(delay)
delay *= 2
else:
@@ -214,7 +209,7 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
except httpx.HTTPError as e:
raise RuntimeError(f"Failed to connect to TEI server at {self.base_url}: {e}")
def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
def predict(self, pairs: List[Tuple[str, str]]) -> List[float]:
"""
Score query-document pairs using the remote TEI reranker.
@@ -234,7 +229,7 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
# Process in batches
for i in range(0, len(pairs), self.batch_size):
batch = pairs[i : i + self.batch_size]
batch = pairs[i:i + self.batch_size]
# TEI rerank endpoint expects query and texts separately
# All pairs in a batch should have the same query for optimal performance
@@ -292,11 +287,15 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
if provider == "tei":
url = os.environ.get(ENV_RERANKER_TEI_URL)
if not url:
raise ValueError(f"{ENV_RERANKER_TEI_URL} is required when {ENV_RERANKER_PROVIDER} is 'tei'")
raise ValueError(
f"{ENV_RERANKER_TEI_URL} is required when {ENV_RERANKER_PROVIDER} is 'tei'"
)
return RemoteTEICrossEncoder(base_url=url)
elif provider == "local":
model = os.environ.get(ENV_RERANKER_LOCAL_MODEL)
model_name = model or DEFAULT_RERANKER_LOCAL_MODEL
return LocalSTCrossEncoder(model_name=model_name)
else:
raise ValueError(f"Unknown reranker provider: {provider}. Supported: 'local', 'tei'")
raise ValueError(
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei'"
)
@@ -1,11 +1,9 @@
"""
Database utility functions for connection management with retry logic.
"""
import asyncio
import logging
from contextlib import asynccontextmanager
import asyncpg
logger = logging.getLogger(__name__)
@@ -56,14 +54,16 @@ async def retry_with_backoff(
except retryable_exceptions as e:
last_exception = e
if attempt < max_retries:
delay = min(base_delay * (2**attempt), max_delay)
delay = min(base_delay * (2 ** attempt), max_delay)
logger.warning(
f"Database operation failed (attempt {attempt + 1}/{max_retries + 1}): {e}. "
f"Retrying in {delay:.1f}s..."
)
await asyncio.sleep(delay)
else:
logger.error(f"Database operation failed after {max_retries + 1} attempts: {e}")
logger.error(
f"Database operation failed after {max_retries + 1} attempts: {e}"
)
raise last_exception
@@ -83,7 +83,6 @@ async def acquire_with_retry(pool: asyncpg.Pool, max_retries: int = DEFAULT_MAX_
Yields:
An asyncpg connection
"""
async def acquire():
return await pool.acquire()
@@ -8,20 +8,20 @@ the database schema (pgvector column defined as vector(384)).
Configuration via environment variables - see hindsight_api.config for all env var names.
"""
from abc import ABC, abstractmethod
from typing import List, Optional
import logging
import os
from abc import ABC, abstractmethod
import httpx
from ..config import (
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
DEFAULT_EMBEDDINGS_PROVIDER,
EMBEDDING_DIMENSION,
ENV_EMBEDDINGS_LOCAL_MODEL,
ENV_EMBEDDINGS_PROVIDER,
ENV_EMBEDDINGS_LOCAL_MODEL,
ENV_EMBEDDINGS_TEI_URL,
DEFAULT_EMBEDDINGS_PROVIDER,
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
EMBEDDING_DIMENSION,
)
logger = logging.getLogger(__name__)
@@ -52,7 +52,7 @@ class Embeddings(ABC):
pass
@abstractmethod
def encode(self, texts: list[str]) -> list[list[float]]:
def encode(self, texts: List[str]) -> List[List[float]]:
"""
Generate 384-dimensional embeddings for a list of texts.
@@ -75,7 +75,7 @@ class LocalSTEmbeddings(Embeddings):
embeddings matching the database schema.
"""
def __init__(self, model_name: str | None = None):
def __init__(self, model_name: Optional[str] = None):
"""
Initialize local SentenceTransformers embeddings.
@@ -123,7 +123,7 @@ class LocalSTEmbeddings(Embeddings):
logger.info(f"Embeddings: local provider initialized (dim: {model_dim})")
def encode(self, texts: list[str]) -> list[list[float]]:
def encode(self, texts: List[str]) -> List[List[float]]:
"""
Generate 384-dimensional embeddings for a list of texts.
@@ -172,8 +172,8 @@ class RemoteTEIEmbeddings(Embeddings):
self.batch_size = batch_size
self.max_retries = max_retries
self.retry_delay = retry_delay
self._client: httpx.Client | None = None
self._model_id: str | None = None
self._client: Optional[httpx.Client] = None
self._model_id: Optional[str] = None
@property
def provider_name(self) -> str:
@@ -182,7 +182,6 @@ class RemoteTEIEmbeddings(Embeddings):
def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response:
"""Make an HTTP request with automatic retries on transient errors."""
import time
last_error = None
delay = self.retry_delay
@@ -197,18 +196,14 @@ class RemoteTEIEmbeddings(Embeddings):
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as e:
last_error = e
if attempt < self.max_retries:
logger.warning(
f"TEI request failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s..."
)
logger.warning(f"TEI request failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s...")
time.sleep(delay)
delay *= 2 # Exponential backoff
except httpx.HTTPStatusError as e:
# Retry on 5xx server errors
if e.response.status_code >= 500 and attempt < self.max_retries:
last_error = e
logger.warning(
f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s..."
)
logger.warning(f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s...")
time.sleep(delay)
delay *= 2
else:
@@ -233,7 +228,7 @@ class RemoteTEIEmbeddings(Embeddings):
except httpx.HTTPError as e:
raise RuntimeError(f"Failed to connect to TEI server at {self.base_url}: {e}")
def encode(self, texts: list[str]) -> list[list[float]]:
def encode(self, texts: List[str]) -> List[List[float]]:
"""
Generate embeddings using the remote TEI server.
@@ -253,7 +248,7 @@ class RemoteTEIEmbeddings(Embeddings):
# Process in batches
for i in range(0, len(texts), self.batch_size):
batch = texts[i : i + self.batch_size]
batch = texts[i:i + self.batch_size]
try:
response = self._request_with_retry(
@@ -283,11 +278,15 @@ def create_embeddings_from_env() -> Embeddings:
if provider == "tei":
url = os.environ.get(ENV_EMBEDDINGS_TEI_URL)
if not url:
raise ValueError(f"{ENV_EMBEDDINGS_TEI_URL} is required when {ENV_EMBEDDINGS_PROVIDER} is 'tei'")
raise ValueError(
f"{ENV_EMBEDDINGS_TEI_URL} is required when {ENV_EMBEDDINGS_PROVIDER} is 'tei'"
)
return RemoteTEIEmbeddings(base_url=url)
elif provider == "local":
model = os.environ.get(ENV_EMBEDDINGS_LOCAL_MODEL)
model_name = model or DEFAULT_EMBEDDINGS_LOCAL_MODEL
return LocalSTEmbeddings(model_name=model_name)
else:
raise ValueError(f"Unknown embeddings provider: {provider}. Supported: 'local', 'tei'")
raise ValueError(
f"Unknown embeddings provider: {provider}. Supported: 'local', 'tei'"
)
@@ -4,14 +4,12 @@ Entity extraction and resolution for memory system.
Uses spaCy for entity extraction and implements resolution logic
to disambiguate entities across memory units.
"""
from datetime import UTC, datetime
from difflib import SequenceMatcher
import asyncpg
from typing import List, Dict, Optional, Set, Any
from difflib import SequenceMatcher
from datetime import datetime, timezone
from .db_utils import acquire_with_retry
from .memory_engine import fq_table
# Load spaCy model (singleton)
_nlp = None
@@ -34,11 +32,11 @@ class EntityResolver:
async def resolve_entities_batch(
self,
bank_id: str,
entities_data: list[dict],
entities_data: List[Dict],
context: str,
unit_event_date,
conn=None,
) -> list[str]:
) -> List[str]:
"""
Resolve multiple entities in batch (MUCH faster than sequential).
@@ -64,38 +62,36 @@ class EntityResolver:
else:
return await self._resolve_entities_batch_impl(conn, bank_id, entities_data, context, unit_event_date)
async def _resolve_entities_batch_impl(
self, conn, bank_id: str, entities_data: list[dict], context: str, unit_event_date
) -> list[str]:
async def _resolve_entities_batch_impl(self, conn, bank_id: str, entities_data: List[Dict], context: str, unit_event_date) -> List[str]:
# Query ALL candidates for this bank
all_entities = await conn.fetch(
f"""
"""
SELECT canonical_name, id, metadata, last_seen, mention_count
FROM {fq_table("entities")}
FROM entities
WHERE bank_id = $1
""",
bank_id,
bank_id
)
# Build entity ID to name mapping for co-occurrence lookups
entity_id_to_name = {row["id"]: row["canonical_name"].lower() for row in all_entities}
entity_id_to_name = {row['id']: row['canonical_name'].lower() for row in all_entities}
# Query ALL co-occurrences for this bank's entities in one query
# This builds a map of entity_id -> set of co-occurring entity names
all_cooccurrences = await conn.fetch(
f"""
"""
SELECT ec.entity_id_1, ec.entity_id_2, ec.cooccurrence_count
FROM {fq_table("entity_cooccurrences")} ec
WHERE ec.entity_id_1 IN (SELECT id FROM {fq_table("entities")} WHERE bank_id = $1)
OR ec.entity_id_2 IN (SELECT id FROM {fq_table("entities")} WHERE bank_id = $1)
FROM entity_cooccurrences ec
WHERE ec.entity_id_1 IN (SELECT id FROM entities WHERE bank_id = $1)
OR ec.entity_id_2 IN (SELECT id FROM entities WHERE bank_id = $1)
""",
bank_id,
bank_id
)
# Build co-occurrence map: entity_id -> set of co-occurring entity names (lowercase)
cooccurrence_map: dict[str, set[str]] = {}
cooccurrence_map: Dict[str, Set[str]] = {}
for row in all_cooccurrences:
eid1, eid2 = row["entity_id_1"], row["entity_id_2"]
eid1, eid2 = row['entity_id_1'], row['entity_id_2']
# Add both directions
if eid1 not in cooccurrence_map:
cooccurrence_map[eid1] = set()
@@ -109,24 +105,22 @@ class EntityResolver:
# Build candidate map for each entity text
all_candidates = {} # Maps entity_text -> list of candidates
entity_texts = list(set(e["text"] for e in entities_data))
entity_texts = list(set(e['text'] for e in entities_data))
for entity_text in entity_texts:
matching = []
entity_text_lower = entity_text.lower()
for row in all_entities:
canonical_name = row["canonical_name"]
ent_id = row["id"]
metadata = row["metadata"]
last_seen = row["last_seen"]
mention_count = row["mention_count"]
canonical_name = row['canonical_name']
ent_id = row['id']
metadata = row['metadata']
last_seen = row['last_seen']
mention_count = row['mention_count']
canonical_lower = canonical_name.lower()
# Match if exact or substring match
if (
entity_text_lower == canonical_lower
or entity_text_lower in canonical_lower
or canonical_lower in entity_text_lower
):
if (entity_text_lower == canonical_lower or
entity_text_lower in canonical_lower or
canonical_lower in entity_text_lower):
matching.append((ent_id, canonical_name, metadata, last_seen, mention_count))
all_candidates[entity_text] = matching
@@ -136,10 +130,10 @@ class EntityResolver:
entities_to_create = [] # (idx, entity_data, event_date)
for idx, entity_data in enumerate(entities_data):
entity_text = entity_data["text"]
nearby_entities = entity_data.get("nearby_entities", [])
entity_text = entity_data['text']
nearby_entities = entity_data.get('nearby_entities', [])
# Use per-entity date if available, otherwise fall back to batch-level date
entity_event_date = entity_data.get("event_date", unit_event_date)
entity_event_date = entity_data.get('event_date', unit_event_date)
candidates = all_candidates.get(entity_text, [])
@@ -152,13 +146,17 @@ class EntityResolver:
best_candidate = None
best_score = 0.0
nearby_entity_set = {e["text"].lower() for e in nearby_entities if e["text"] != entity_text}
nearby_entity_set = {e['text'].lower() for e in nearby_entities if e['text'] != entity_text}
for candidate_id, canonical_name, metadata, last_seen, mention_count in candidates:
score = 0.0
# 1. Name similarity (0-0.5)
name_similarity = SequenceMatcher(None, entity_text.lower(), canonical_name.lower()).ratio()
name_similarity = SequenceMatcher(
None,
entity_text.lower(),
canonical_name.lower()
).ratio()
score += name_similarity * 0.5
# 2. Co-occurring entities (0-0.3)
@@ -171,10 +169,8 @@ class EntityResolver:
# 3. Temporal proximity (0-0.2)
if last_seen and entity_event_date:
# Normalize timezone awareness for comparison
event_date_utc = (
entity_event_date if entity_event_date.tzinfo else entity_event_date.replace(tzinfo=UTC)
)
last_seen_utc = last_seen if last_seen.tzinfo else last_seen.replace(tzinfo=UTC)
event_date_utc = entity_event_date if entity_event_date.tzinfo else entity_event_date.replace(tzinfo=timezone.utc)
last_seen_utc = last_seen if last_seen.tzinfo else last_seen.replace(tzinfo=timezone.utc)
days_diff = abs((event_date_utc - last_seen_utc).total_seconds() / 86400)
if days_diff < 7:
temporal_score = max(0, 1.0 - (days_diff / 7))
@@ -196,13 +192,13 @@ class EntityResolver:
# Batch update existing entities
if entities_to_update:
await conn.executemany(
f"""
UPDATE {fq_table("entities")} SET
"""
UPDATE entities SET
mention_count = mention_count + 1,
last_seen = $2
WHERE id = $1::uuid
""",
entities_to_update,
entities_to_update
)
# Batch create new entities using COPY + INSERT for maximum speed
@@ -212,7 +208,7 @@ class EntityResolver:
# For duplicates, we only insert once and reuse the ID
unique_entities = {} # lowercase_name -> (entity_data, event_date, [indices])
for idx, entity_data, event_date in entities_to_create:
name_lower = entity_data["text"].lower()
name_lower = entity_data['text'].lower()
if name_lower not in unique_entities:
unique_entities[name_lower] = (entity_data, event_date, [idx])
else:
@@ -226,31 +222,31 @@ class EntityResolver:
indices_map = [] # Maps result index -> list of original indices
for name_lower, (entity_data, event_date, indices) in unique_entities.items():
entity_names.append(entity_data["text"])
entity_names.append(entity_data['text'])
entity_dates.append(event_date)
indices_map.append(indices)
# Batch INSERT ... ON CONFLICT with RETURNING
# This is much faster than individual inserts
rows = await conn.fetch(
f"""
INSERT INTO {fq_table("entities")} (bank_id, canonical_name, first_seen, last_seen, mention_count)
"""
INSERT INTO entities (bank_id, canonical_name, first_seen, last_seen, mention_count)
SELECT $1, name, event_date, event_date, 1
FROM unnest($2::text[], $3::timestamptz[]) AS t(name, event_date)
ON CONFLICT (bank_id, LOWER(canonical_name))
DO UPDATE SET
mention_count = {fq_table("entities")}.mention_count + 1,
mention_count = entities.mention_count + 1,
last_seen = EXCLUDED.last_seen
RETURNING id
""",
bank_id,
entity_names,
entity_dates,
entity_dates
)
# Map returned IDs back to original indices
for result_idx, row in enumerate(rows):
entity_id = row["id"]
entity_id = row['id']
for original_idx in indices_map[result_idx]:
entity_ids[original_idx] = entity_id
@@ -261,7 +257,7 @@ class EntityResolver:
bank_id: str,
entity_text: str,
context: str,
nearby_entities: list[dict],
nearby_entities: List[Dict],
unit_event_date,
) -> str:
"""
@@ -280,9 +276,9 @@ class EntityResolver:
async with acquire_with_retry(self.pool) as conn:
# Find candidate entities with similar name
candidates = await conn.fetch(
f"""
"""
SELECT id, canonical_name, metadata, last_seen
FROM {fq_table("entities")}
FROM entities
WHERE bank_id = $1
AND (
canonical_name ILIKE $2
@@ -291,14 +287,14 @@ class EntityResolver:
)
ORDER BY mention_count DESC
""",
bank_id,
entity_text,
f"%{entity_text}%",
bank_id, entity_text, f"%{entity_text}%"
)
if not candidates:
# New entity - create it
return await self._create_entity(conn, bank_id, entity_text, unit_event_date)
return await self._create_entity(
conn, bank_id, entity_text, unit_event_date
)
# Score candidates based on:
# 1. Name similarity
@@ -310,27 +306,31 @@ class EntityResolver:
best_score = 0.0
best_name_similarity = 0.0
nearby_entity_set = {e["text"].lower() for e in nearby_entities if e["text"] != entity_text}
nearby_entity_set = {e['text'].lower() for e in nearby_entities if e['text'] != entity_text}
for row in candidates:
candidate_id = row["id"]
canonical_name = row["canonical_name"]
metadata = row["metadata"]
last_seen = row["last_seen"]
candidate_id = row['id']
canonical_name = row['canonical_name']
metadata = row['metadata']
last_seen = row['last_seen']
score = 0.0
# 1. Name similarity (0-1)
name_similarity = SequenceMatcher(None, entity_text.lower(), canonical_name.lower()).ratio()
name_similarity = SequenceMatcher(
None,
entity_text.lower(),
canonical_name.lower()
).ratio()
score += name_similarity * 0.5
# 2. Co-occurring entities (0-0.5)
# Get entities that co-occurred with this candidate before
# Use the materialized co-occurrence cache for fast lookup
co_entity_rows = await conn.fetch(
f"""
"""
SELECT e.canonical_name, ec.cooccurrence_count
FROM {fq_table("entity_cooccurrences")} ec
JOIN {fq_table("entities")} e ON (
FROM entity_cooccurrences ec
JOIN entities e ON (
CASE
WHEN ec.entity_id_1 = $1 THEN ec.entity_id_2
WHEN ec.entity_id_2 = $1 THEN ec.entity_id_1
@@ -338,9 +338,9 @@ class EntityResolver:
)
WHERE ec.entity_id_1 = $1 OR ec.entity_id_2 = $1
""",
candidate_id,
candidate_id
)
co_entities = {r["canonical_name"].lower() for r in co_entity_rows}
co_entities = {r['canonical_name'].lower() for r in co_entity_rows}
# Check overlap with nearby entities
overlap = len(nearby_entity_set & co_entities)
@@ -366,19 +366,20 @@ class EntityResolver:
if best_score > threshold:
# Update entity
await conn.execute(
f"""
UPDATE {fq_table("entities")}
"""
UPDATE entities
SET mention_count = mention_count + 1,
last_seen = $1
WHERE id = $2
""",
unit_event_date,
best_candidate,
unit_event_date, best_candidate
)
return best_candidate
else:
# Not confident - create new entity
return await self._create_entity(conn, bank_id, entity_text, unit_event_date)
return await self._create_entity(
conn, bank_id, entity_text, unit_event_date
)
async def _create_entity(
self,
@@ -403,19 +404,16 @@ class EntityResolver:
Entity ID
"""
entity_id = await conn.fetchval(
f"""
INSERT INTO {fq_table("entities")} (bank_id, canonical_name, first_seen, last_seen, mention_count)
"""
INSERT INTO entities (bank_id, canonical_name, first_seen, last_seen, mention_count)
VALUES ($1, $2, $3, $4, 1)
ON CONFLICT (bank_id, LOWER(canonical_name))
DO UPDATE SET
mention_count = {fq_table("entities")}.mention_count + 1,
mention_count = entities.mention_count + 1,
last_seen = EXCLUDED.last_seen
RETURNING id
""",
bank_id,
entity_text,
event_date,
event_date,
bank_id, entity_text, event_date, event_date
)
return entity_id
@@ -431,27 +429,25 @@ class EntityResolver:
async with acquire_with_retry(self.pool) as conn:
# Insert unit-entity link
await conn.execute(
f"""
INSERT INTO {fq_table("unit_entities")} (unit_id, entity_id)
"""
INSERT INTO unit_entities (unit_id, entity_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
""",
unit_id,
entity_id,
unit_id, entity_id
)
# Update co-occurrence cache: find other entities in this unit
rows = await conn.fetch(
f"""
"""
SELECT entity_id
FROM {fq_table("unit_entities")}
FROM unit_entities
WHERE unit_id = $1 AND entity_id != $2
""",
unit_id,
entity_id,
unit_id, entity_id
)
other_entities = [row["entity_id"] for row in rows]
other_entities = [row['entity_id'] for row in rows]
# Update co-occurrences for each pair
for other_entity_id in other_entities:
@@ -473,19 +469,18 @@ class EntityResolver:
entity_id_1, entity_id_2 = entity_id_2, entity_id_1
await conn.execute(
f"""
INSERT INTO {fq_table("entity_cooccurrences")} (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
"""
INSERT INTO entity_cooccurrences (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
VALUES ($1, $2, 1, NOW())
ON CONFLICT (entity_id_1, entity_id_2)
DO UPDATE SET
cooccurrence_count = {fq_table("entity_cooccurrences")}.cooccurrence_count + 1,
cooccurrence_count = entity_cooccurrences.cooccurrence_count + 1,
last_cooccurred = NOW()
""",
entity_id_1,
entity_id_2,
entity_id_1, entity_id_2
)
async def link_units_to_entities_batch(self, unit_entity_pairs: list[tuple[str, str]], conn=None):
async def link_units_to_entities_batch(self, unit_entity_pairs: List[tuple[str, str]], conn=None):
"""
Link multiple memory units to entities in batch (MUCH faster than sequential).
@@ -504,15 +499,15 @@ class EntityResolver:
else:
return await self._link_units_to_entities_batch_impl(conn, unit_entity_pairs)
async def _link_units_to_entities_batch_impl(self, conn, unit_entity_pairs: list[tuple[str, str]]):
async def _link_units_to_entities_batch_impl(self, conn, unit_entity_pairs: List[tuple[str, str]]):
# Batch insert all unit-entity links
await conn.executemany(
f"""
INSERT INTO {fq_table("unit_entities")} (unit_id, entity_id)
"""
INSERT INTO unit_entities (unit_id, entity_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING
""",
unit_entity_pairs,
unit_entity_pairs
)
# Build map of unit -> entities for co-occurrence calculation
@@ -529,7 +524,7 @@ class EntityResolver:
entity_list = list(entity_ids) # Convert set to list for iteration
# For each pair of entities in this unit, create co-occurrence
for i, entity_id_1 in enumerate(entity_list):
for entity_id_2 in entity_list[i + 1 :]:
for entity_id_2 in entity_list[i+1:]:
# Skip if same entity (shouldn't happen with set, but be safe)
if entity_id_1 == entity_id_2:
continue
@@ -540,20 +535,20 @@ class EntityResolver:
# Batch update co-occurrences
if cooccurrence_pairs:
now = datetime.now(UTC)
now = datetime.now(timezone.utc)
await conn.executemany(
f"""
INSERT INTO {fq_table("entity_cooccurrences")} (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
"""
INSERT INTO entity_cooccurrences (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
VALUES ($1, $2, $3, $4)
ON CONFLICT (entity_id_1, entity_id_2)
DO UPDATE SET
cooccurrence_count = {fq_table("entity_cooccurrences")}.cooccurrence_count + 1,
cooccurrence_count = entity_cooccurrences.cooccurrence_count + 1,
last_cooccurred = EXCLUDED.last_cooccurred
""",
[(e1, e2, 1, now) for e1, e2 in cooccurrence_pairs],
[(e1, e2, 1, now) for e1, e2 in cooccurrence_pairs]
)
async def get_units_by_entity(self, entity_id: str, limit: int = 100) -> list[str]:
async def get_units_by_entity(self, entity_id: str, limit: int = 100) -> List[str]:
"""
Get all units that mention an entity.
@@ -566,23 +561,22 @@ class EntityResolver:
"""
async with acquire_with_retry(self.pool) as conn:
rows = await conn.fetch(
f"""
"""
SELECT unit_id
FROM {fq_table("unit_entities")}
FROM unit_entities
WHERE entity_id = $1
ORDER BY unit_id
LIMIT $2
""",
entity_id,
limit,
entity_id, limit
)
return [row["unit_id"] for row in rows]
return [row['unit_id'] for row in rows]
async def get_entity_by_text(
self,
bank_id: str,
entity_text: str,
) -> str | None:
) -> Optional[str]:
"""
Find an entity by text (for query resolution).
@@ -595,15 +589,14 @@ class EntityResolver:
"""
async with acquire_with_retry(self.pool) as conn:
row = await conn.fetchrow(
f"""
SELECT id FROM {fq_table("entities")}
"""
SELECT id FROM entities
WHERE bank_id = $1
AND canonical_name ILIKE $2
ORDER BY mention_count DESC
LIMIT 1
""",
bank_id,
entity_text,
bank_id, entity_text
)
return row["id"] if row else None
return row['id'] if row else None
@@ -1,592 +0,0 @@
"""Abstract interface for MemoryEngine public methods.
This module defines the public API that HTTP endpoints and extensions should use
to interact with the memory system. All methods require a RequestContext for
authentication when a TenantExtension is configured.
"""
from abc import ABC, abstractmethod
from datetime import datetime
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import RecallResult, ReflectResult
from hindsight_api.models import RequestContext
class MemoryEngineInterface(ABC):
"""
Abstract interface for the Memory Engine.
This defines the public API that should be used by HTTP endpoints and extensions.
All methods require a RequestContext for authentication.
"""
# =========================================================================
# Health & Status
# =========================================================================
@abstractmethod
async def health_check(self) -> dict:
"""
Check the health of the memory system.
Returns:
Dict with 'status' key ('healthy' or 'unhealthy') and additional info.
"""
...
# =========================================================================
# Core Memory Operations
# =========================================================================
@abstractmethod
async def retain_batch_async(
self,
bank_id: str,
contents: list[dict[str, Any]],
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Retain a batch of memory items.
Args:
bank_id: The memory bank ID.
contents: List of content dicts with 'content', optional 'event_date',
'context', 'metadata', 'document_id'.
request_context: Request context for authentication.
Returns:
Dict with processing results.
"""
...
@abstractmethod
async def recall_async(
self,
bank_id: str,
query: str,
*,
budget: "Budget | None" = None,
max_tokens: int = 4096,
enable_trace: bool = False,
fact_type: list[str] | None = None,
question_date: datetime | None = None,
include_entities: bool = False,
max_entity_tokens: int = 500,
include_chunks: bool = False,
max_chunk_tokens: int = 8192,
request_context: "RequestContext",
) -> "RecallResult":
"""
Recall memories relevant to a query.
Args:
bank_id: The memory bank ID.
query: The search query.
budget: Search budget (LOW, MID, HIGH).
max_tokens: Maximum tokens in response.
enable_trace: Include trace information.
fact_type: Filter by fact types.
question_date: Context date for temporal relevance.
include_entities: Include entity observations.
max_entity_tokens: Max tokens for entity observations.
include_chunks: Include raw chunks.
max_chunk_tokens: Max tokens for chunks.
request_context: Request context for authentication.
Returns:
RecallResult with matching memories.
"""
...
@abstractmethod
async def reflect_async(
self,
bank_id: str,
query: str,
*,
budget: "Budget | None" = None,
context: str | None = None,
request_context: "RequestContext",
) -> "ReflectResult":
"""
Reflect on a query and generate a thoughtful response.
Args:
bank_id: The memory bank ID.
query: The question to reflect on.
budget: Search budget for retrieving context.
context: Additional context for the reflection.
request_context: Request context for authentication.
Returns:
ReflectResult with generated response and supporting facts.
"""
...
# =========================================================================
# Bank Management
# =========================================================================
@abstractmethod
async def list_banks(
self,
*,
request_context: "RequestContext",
) -> list[dict[str, Any]]:
"""
List all memory banks.
Args:
request_context: Request context for authentication.
Returns:
List of bank info dicts.
"""
...
@abstractmethod
async def get_bank_profile(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Get bank profile including disposition and background.
Args:
bank_id: The memory bank ID.
request_context: Request context for authentication.
Returns:
Bank profile dict.
"""
...
@abstractmethod
async def update_bank_disposition(
self,
bank_id: str,
disposition: dict[str, int],
*,
request_context: "RequestContext",
) -> None:
"""
Update bank disposition traits.
Args:
bank_id: The memory bank ID.
disposition: Dict with trait values.
request_context: Request context for authentication.
"""
...
@abstractmethod
async def merge_bank_background(
self,
bank_id: str,
new_info: str,
*,
update_disposition: bool = True,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Merge new background information into bank profile.
Args:
bank_id: The memory bank ID.
new_info: New background information to merge.
update_disposition: Whether to infer disposition from background.
request_context: Request context for authentication.
Returns:
Updated background info.
"""
...
@abstractmethod
async def delete_bank(
self,
bank_id: str,
*,
fact_type: str | None = None,
request_context: "RequestContext",
) -> dict[str, int]:
"""
Delete a bank or its memories.
Args:
bank_id: The memory bank ID.
fact_type: If specified, only delete memories of this type.
request_context: Request context for authentication.
Returns:
Dict with deletion counts.
"""
...
# =========================================================================
# Memory Units
# =========================================================================
@abstractmethod
async def list_memory_units(
self,
bank_id: str,
*,
fact_type: str | None = None,
search_query: str | None = None,
limit: int = 100,
offset: int = 0,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
List memory units with pagination.
Args:
bank_id: The memory bank ID.
fact_type: Filter by fact type.
search_query: Full-text search query.
limit: Maximum results.
offset: Pagination offset.
request_context: Request context for authentication.
Returns:
Dict with 'items', 'total', 'limit', 'offset'.
"""
...
@abstractmethod
async def delete_memory_unit(
self,
unit_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Delete a specific memory unit.
Args:
unit_id: The memory unit ID.
request_context: Request context for authentication.
Returns:
Deletion result.
"""
...
@abstractmethod
async def get_graph_data(
self,
bank_id: str,
*,
fact_type: str | None = None,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Get graph data for visualization.
Args:
bank_id: The memory bank ID.
fact_type: Filter by fact type.
request_context: Request context for authentication.
Returns:
Dict with nodes, edges, table_rows, total_units.
"""
...
# =========================================================================
# Documents
# =========================================================================
@abstractmethod
async def list_documents(
self,
bank_id: str,
*,
search_query: str | None = None,
limit: int = 100,
offset: int = 0,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
List documents with pagination.
Args:
bank_id: The memory bank ID.
search_query: Search query.
limit: Maximum results.
offset: Pagination offset.
request_context: Request context for authentication.
Returns:
Dict with 'items', 'total', 'limit', 'offset'.
"""
...
@abstractmethod
async def get_document(
self,
document_id: str,
bank_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any] | None:
"""
Get a specific document.
Args:
document_id: The document ID.
bank_id: The memory bank ID.
request_context: Request context for authentication.
Returns:
Document dict or None if not found.
"""
...
@abstractmethod
async def delete_document(
self,
document_id: str,
bank_id: str,
*,
request_context: "RequestContext",
) -> dict[str, int]:
"""
Delete a document and its memory units.
Args:
document_id: The document ID.
bank_id: The memory bank ID.
request_context: Request context for authentication.
Returns:
Dict with deletion counts.
"""
...
@abstractmethod
async def get_chunk(
self,
chunk_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any] | None:
"""
Get a specific chunk.
Args:
chunk_id: The chunk ID.
request_context: Request context for authentication.
Returns:
Chunk dict or None if not found.
"""
...
# =========================================================================
# Entities
# =========================================================================
@abstractmethod
async def list_entities(
self,
bank_id: str,
*,
limit: int = 100,
request_context: "RequestContext",
) -> list[dict[str, Any]]:
"""
List entities for a bank.
Args:
bank_id: The memory bank ID.
limit: Maximum results.
request_context: Request context for authentication.
Returns:
List of entity dicts.
"""
...
@abstractmethod
async def get_entity_observations(
self,
bank_id: str,
entity_id: str,
*,
limit: int = 10,
request_context: "RequestContext",
) -> list[Any]:
"""
Get observations for an entity.
Args:
bank_id: The memory bank ID.
entity_id: The entity ID.
limit: Maximum observations.
request_context: Request context for authentication.
Returns:
List of EntityObservation objects.
"""
...
@abstractmethod
async def regenerate_entity_observations(
self,
bank_id: str,
entity_id: str,
entity_name: str,
*,
request_context: "RequestContext",
) -> None:
"""
Regenerate observations for an entity.
Args:
bank_id: The memory bank ID.
entity_id: The entity ID.
entity_name: The entity's canonical name.
request_context: Request context for authentication.
"""
...
# =========================================================================
# Statistics & Operations
# =========================================================================
@abstractmethod
async def get_bank_stats(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Get statistics about memory nodes and links for a bank.
Args:
bank_id: The memory bank ID.
request_context: Request context for authentication.
Returns:
Dict with node_counts, link_counts, link_counts_by_fact_type,
link_breakdown, and operations stats.
"""
...
@abstractmethod
async def get_entity(
self,
bank_id: str,
entity_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any] | None:
"""
Get entity details including metadata and observations.
Args:
bank_id: The memory bank ID.
entity_id: The entity ID.
request_context: Request context for authentication.
Returns:
Entity dict with id, canonical_name, mention_count, first_seen,
last_seen, metadata, and observations. None if not found.
"""
...
@abstractmethod
async def list_operations(
self,
bank_id: str,
*,
request_context: "RequestContext",
) -> list[dict[str, Any]]:
"""
List async operations for a bank.
Args:
bank_id: The memory bank ID.
request_context: Request context for authentication.
Returns:
List of operation dicts with id, task_type, status, etc.
"""
...
@abstractmethod
async def cancel_operation(
self,
bank_id: str,
operation_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Cancel a pending async operation.
Args:
bank_id: The memory bank ID.
operation_id: The operation ID to cancel.
request_context: Request context for authentication.
Returns:
Dict with success status and message.
Raises:
ValueError: If operation not found.
"""
...
@abstractmethod
async def update_bank(
self,
bank_id: str,
*,
name: str | None = None,
background: str | None = None,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Update bank name and/or background.
Args:
bank_id: The memory bank ID.
name: New bank name (optional).
background: New background text (optional, replaces existing).
request_context: Request context for authentication.
Returns:
Updated bank profile dict.
"""
...
@abstractmethod
async def submit_async_retain(
self,
bank_id: str,
contents: list[dict[str, Any]],
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Submit a batch retain operation to run asynchronously.
Args:
bank_id: The memory bank ID.
contents: List of content dicts to retain.
request_context: Request context for authentication.
Returns:
Dict with operation_id and items_count.
"""
...
+97 -252
View File
@@ -1,19 +1,15 @@
"""
LLM wrapper for unified configuration across providers.
"""
import asyncio
import json
import logging
import os
import time
from typing import Any
import httpx
import asyncio
from typing import Optional, Any, Dict, List
from openai import AsyncOpenAI, RateLimitError, APIError, APIStatusError, APIConnectionError, LengthFinishReasonError
from google import genai
from google.genai import errors as genai_errors
from google.genai import types as genai_types
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
from google.genai import errors as genai_errors
import logging
# Seed applied to every Groq request for deterministic behavior.
DEFAULT_LLM_SEED = 4242
@@ -35,7 +31,6 @@ class OutputTooLongError(Exception):
to allow callers to handle output length issues without depending on
provider-specific implementations.
"""
pass
@@ -73,7 +68,9 @@ class LLMProvider:
# Validate provider
valid_providers = ["openai", "groq", "ollama", "gemini"]
if self.provider not in valid_providers:
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
raise ValueError(
f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}"
)
# Set default base URLs
if not self.base_url:
@@ -98,7 +95,7 @@ class LLMProvider:
client_kwargs = {"api_key": self.api_key, "max_retries": 0}
if self.base_url:
client_kwargs["base_url"] = self.base_url
self._client = AsyncOpenAI(**client_kwargs) # type: ignore[invalid-argument-type] - dict kwargs
self._client = AsyncOpenAI(**client_kwargs)
self._gemini_client = None
async def verify_connection(self) -> None:
@@ -109,12 +106,10 @@ class LLMProvider:
RuntimeError: If the connection test fails.
"""
try:
logger.info(
f"Verifying LLM: provider={self.provider}, model={self.model}, base_url={self.base_url or 'default'}..."
)
logger.info(f"Verifying LLM: provider={self.provider}, model={self.model}, base_url={self.base_url or 'default'}...")
await self.call(
messages=[{"role": "user", "content": "Say 'ok'"}],
max_completion_tokens=100,
max_completion_tokens=10,
max_retries=2,
initial_backoff=0.5,
max_backoff=2.0,
@@ -122,14 +117,16 @@ class LLMProvider:
# If we get here without exception, the connection is working
logger.info(f"LLM verified: {self.provider}/{self.model}")
except Exception as e:
raise RuntimeError(f"LLM connection verification failed for {self.provider}/{self.model}: {e}") from e
raise RuntimeError(
f"LLM connection verification failed for {self.provider}/{self.model}: {e}"
) from e
async def call(
self,
messages: list[dict[str, str]],
response_format: Any | None = None,
max_completion_tokens: int | None = None,
temperature: float | None = None,
messages: List[Dict[str, str]],
response_format: Optional[Any] = None,
max_completion_tokens: Optional[int] = None,
temperature: Optional[float] = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
@@ -159,25 +156,13 @@ class LLMProvider:
"""
async with _global_llm_semaphore:
start_time = time.time()
import json
# Handle Gemini provider separately
if self.provider == "gemini":
return await self._call_gemini(
messages, response_format, max_retries, initial_backoff, max_backoff, skip_validation, start_time
)
# Handle Ollama with native API for structured output (better schema enforcement)
if self.provider == "ollama" and response_format is not None:
return await self._call_ollama_native(
messages,
response_format,
max_completion_tokens,
temperature,
max_retries,
initial_backoff,
max_backoff,
skip_validation,
start_time,
messages, response_format, max_retries, initial_backoff,
max_backoff, skip_validation, start_time
)
call_params = {
@@ -187,16 +172,12 @@ class LLMProvider:
# Check if model supports reasoning parameter (o1, o3, gpt-5 families)
model_lower = self.model.lower()
is_reasoning_model = any(x in model_lower for x in ["gpt-5", "o1", "o3", "deepseek"])
is_reasoning_model = any(x in model_lower for x in ["gpt-5", "o1", "o3"])
# For GPT-4 and GPT-4.1 models, cap max_completion_tokens to 32000
# For GPT-4o models, cap to 16384
is_gpt4_model = any(x in model_lower for x in ["gpt-4.1", "gpt-4-"])
is_gpt4o_model = "gpt-4o" in model_lower
if max_completion_tokens is not None:
if is_gpt4o_model and max_completion_tokens > 16384:
max_completion_tokens = 16384
elif is_gpt4_model and max_completion_tokens > 32000:
if is_gpt4_model and max_completion_tokens > 32000:
max_completion_tokens = 32000
# For reasoning models, max_completion_tokens includes reasoning + output tokens
# Enforce minimum of 16000 to ensure enough space for both
@@ -209,7 +190,7 @@ class LLMProvider:
call_params["temperature"] = temperature
# Set reasoning_effort for reasoning models (OpenAI gpt-5, o1, o3)
if is_reasoning_model:
if is_reasoning_model and self.provider == "openai":
call_params["reasoning_effort"] = self.reasoning_effort
# Provider-specific parameters
@@ -218,6 +199,7 @@ class LLMProvider:
extra_body = {"service_tier": "auto"}
# Only add reasoning parameters for reasoning models
if is_reasoning_model:
extra_body["reasoning_effort"] = self.reasoning_effort
extra_body["include_reasoning"] = False
call_params["extra_body"] = extra_body
@@ -227,46 +209,20 @@ class LLMProvider:
try:
if response_format is not None:
# Add schema to system message for JSON mode
if hasattr(response_format, "model_json_schema"):
if hasattr(response_format, 'model_json_schema'):
schema = response_format.model_json_schema()
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
if call_params["messages"] and call_params["messages"][0].get("role") == "system":
call_params["messages"][0]["content"] += schema_msg
elif call_params["messages"]:
call_params["messages"][0]["content"] = (
schema_msg + "\n\n" + call_params["messages"][0]["content"]
)
if call_params['messages'] and call_params['messages'][0].get('role') == 'system':
call_params['messages'][0]['content'] += schema_msg
elif call_params['messages']:
call_params['messages'][0]['content'] = schema_msg + "\n\n" + call_params['messages'][0]['content']
call_params["response_format"] = {"type": "json_object"}
call_params['response_format'] = {"type": "json_object"}
response = await self._client.chat.completions.create(**call_params)
content = response.choices[0].message.content
# Log raw LLM response for debugging JSON parse issues
try:
json_data = json.loads(content)
except json.JSONDecodeError as json_err:
# Truncate content for logging (first 500 and last 200 chars)
content_preview = content[:500] if content else "<empty>"
if content and len(content) > 700:
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
logger.warning(
f"JSON parse error from LLM response (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
f" Model: {self.provider}/{self.model}\n"
f" Content length: {len(content) if content else 0} chars\n"
f" Content preview: {content_preview!r}\n"
f" Finish reason: {response.choices[0].finish_reason if response.choices else 'unknown'}"
)
# Retry on JSON parse errors - LLM may return valid JSON on next attempt
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = json_err
continue
else:
logger.error(f"JSON parse error after {max_retries + 1} attempts, giving up")
raise
json_data = json.loads(content)
if skip_validation:
result = json_data
@@ -282,8 +238,8 @@ class LLMProvider:
if duration > 10.0:
ratio = max(1, usage.completion_tokens) / usage.prompt_tokens
cached_tokens = 0
if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details:
cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0
if hasattr(usage, 'prompt_tokens_details') and usage.prompt_tokens_details:
cached_tokens = getattr(usage.prompt_tokens_details, 'cached_tokens', 0) or 0
cache_info = f", cached_tokens={cached_tokens}" if cached_tokens > 0 else ""
logger.info(
f"slow llm call: model={self.provider}/{self.model}, "
@@ -296,19 +252,15 @@ class LLMProvider:
except LengthFinishReasonError as e:
logger.warning(f"LLM output exceeded token limits: {str(e)}")
raise OutputTooLongError(
"LLM output exceeded token limits. Input may need to be split into smaller chunks."
f"LLM output exceeded token limits. Input may need to be split into smaller chunks."
) from e
except APIConnectionError as e:
last_exception = e
if attempt < max_retries:
status_code = getattr(e, "status_code", None) or getattr(
getattr(e, "response", None), "status_code", None
)
logger.warning(
f"Connection error, retrying... (attempt {attempt + 1}/{max_retries + 1}) - status_code={status_code}, message={e}"
)
backoff = min(initial_backoff * (2**attempt), max_backoff)
status_code = getattr(e, 'status_code', None) or getattr(getattr(e, 'response', None), 'status_code', None)
logger.warning(f"Connection error, retrying... (attempt {attempt + 1}/{max_retries + 1}) - status_code={status_code}, message={e}")
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
@@ -316,14 +268,14 @@ class LLMProvider:
raise
except APIStatusError as e:
# Fast fail only on 401 (unauthorized) and 403 (forbidden) - these won't recover with retries
if e.status_code in (401, 403):
logger.error(f"Auth error (HTTP {e.status_code}), not retrying: {str(e)}")
# Fast fail on 4xx client errors (except 429 rate limit and 498 which is treated as server error)
if 400 <= e.status_code < 500 and e.status_code not in (429, 498):
logger.error(f"Client error (HTTP {e.status_code}), not retrying: {str(e)}")
raise
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
sleep_time = backoff + jitter
await asyncio.sleep(sleep_time)
@@ -337,135 +289,12 @@ class LLMProvider:
if last_exception:
raise last_exception
raise RuntimeError("LLM call failed after all retries with no exception captured")
async def _call_ollama_native(
self,
messages: list[dict[str, str]],
response_format: Any,
max_completion_tokens: int | None,
temperature: float | None,
max_retries: int,
initial_backoff: float,
max_backoff: float,
skip_validation: bool,
start_time: float,
) -> Any:
"""
Call Ollama using native API with JSON schema enforcement.
Ollama's native API supports passing a full JSON schema in the 'format' parameter,
which provides better structured output control than the OpenAI-compatible API.
"""
# Get the JSON schema from the Pydantic model
schema = response_format.model_json_schema() if hasattr(response_format, "model_json_schema") else None
# Build the base URL for Ollama's native API
# Default OpenAI-compatible URL is http://localhost:11434/v1
# Native API is at http://localhost:11434/api/chat
base_url = self.base_url or "http://localhost:11434/v1"
if base_url.endswith("/v1"):
native_url = base_url[:-3] + "/api/chat"
else:
native_url = base_url.rstrip("/") + "/api/chat"
# Build request payload
payload = {
"model": self.model,
"messages": messages,
"stream": False,
}
# Add schema as format parameter for structured output
if schema:
payload["format"] = schema
# Add optional parameters with optimized defaults for Ollama
# Benchmarking shows num_ctx=16384 + num_batch=512 is optimal
options = {
"num_ctx": 16384, # 16k context window for larger prompts
"num_batch": 512, # Optimal batch size for prompt processing
}
if max_completion_tokens:
options["num_predict"] = max_completion_tokens
if temperature is not None:
options["temperature"] = temperature
payload["options"] = options
last_exception = None
async with httpx.AsyncClient(timeout=300.0) as client:
for attempt in range(max_retries + 1):
try:
response = await client.post(native_url, json=payload)
response.raise_for_status()
result = response.json()
content = result.get("message", {}).get("content", "")
# Parse JSON response
try:
json_data = json.loads(content)
except json.JSONDecodeError as json_err:
content_preview = content[:500] if content else "<empty>"
if content and len(content) > 700:
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
logger.warning(
f"Ollama JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
f" Model: ollama/{self.model}\n"
f" Content length: {len(content) if content else 0} chars\n"
f" Content preview: {content_preview!r}"
)
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
last_exception = json_err
continue
else:
raise
# Validate against Pydantic model or return raw JSON
if skip_validation:
return json_data
else:
return response_format.model_validate(json_data)
except httpx.HTTPStatusError as e:
last_exception = e
if attempt < max_retries:
logger.warning(
f"Ollama HTTP error (attempt {attempt + 1}/{max_retries + 1}): {e.response.status_code}"
)
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Ollama HTTP error after {max_retries + 1} attempts: {e}")
raise
except httpx.RequestError as e:
last_exception = e
if attempt < max_retries:
logger.warning(f"Ollama connection error (attempt {attempt + 1}/{max_retries + 1}): {e}")
backoff = min(initial_backoff * (2**attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Ollama connection error after {max_retries + 1} attempts: {e}")
raise
except Exception as e:
logger.error(f"Unexpected error during Ollama call: {type(e).__name__}: {e}")
raise
if last_exception:
raise last_exception
raise RuntimeError("Ollama call failed after all retries")
raise RuntimeError(f"LLM call failed after all retries with no exception captured")
async def _call_gemini(
self,
messages: list[dict[str, str]],
response_format: Any | None,
messages: List[Dict[str, str]],
response_format: Optional[Any],
max_retries: int,
initial_backoff: float,
max_backoff: float,
@@ -473,26 +302,34 @@ class LLMProvider:
start_time: float,
) -> Any:
"""Handle Gemini-specific API calls."""
import json
# Convert OpenAI-style messages to Gemini format
system_instruction = None
gemini_contents = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
role = msg.get('role', 'user')
content = msg.get('content', '')
if role == "system":
if role == 'system':
if system_instruction:
system_instruction += "\n\n" + content
else:
system_instruction = content
elif role == "assistant":
gemini_contents.append(genai_types.Content(role="model", parts=[genai_types.Part(text=content)]))
elif role == 'assistant':
gemini_contents.append(genai_types.Content(
role="model",
parts=[genai_types.Part(text=content)]
))
else:
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
gemini_contents.append(genai_types.Content(
role="user",
parts=[genai_types.Part(text=content)]
))
# Add JSON schema instruction if response_format is provided
if response_format is not None and hasattr(response_format, "model_json_schema"):
if response_format is not None and hasattr(response_format, 'model_json_schema'):
schema = response_format.model_json_schema()
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
if system_instruction:
@@ -503,10 +340,10 @@ class LLMProvider:
# Build generation config
config_kwargs = {}
if system_instruction:
config_kwargs["system_instruction"] = system_instruction
config_kwargs['system_instruction'] = system_instruction
if response_format is not None:
config_kwargs["response_mime_type"] = "application/json"
config_kwargs["response_schema"] = response_format
config_kwargs['response_mime_type'] = 'application/json'
config_kwargs['response_schema'] = response_format
generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
@@ -525,14 +362,14 @@ class LLMProvider:
# Handle empty response
if content is None:
block_reason = None
if hasattr(response, "candidates") and response.candidates:
if hasattr(response, 'candidates') and response.candidates:
candidate = response.candidates[0]
if hasattr(candidate, "finish_reason"):
if hasattr(candidate, 'finish_reason'):
block_reason = candidate.finish_reason
if attempt < max_retries:
logger.warning(f"Gemini returned empty response (reason: {block_reason}), retrying...")
backoff = min(initial_backoff * (2**attempt), max_backoff)
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
@@ -549,7 +386,7 @@ class LLMProvider:
# Log slow calls
duration = time.time() - start_time
if duration > 10.0 and hasattr(response, "usage_metadata") and response.usage_metadata:
if duration > 10.0 and hasattr(response, 'usage_metadata') and response.usage_metadata:
usage = response.usage_metadata
logger.info(
f"slow llm call: model={self.provider}/{self.model}, "
@@ -562,8 +399,8 @@ class LLMProvider:
except json.JSONDecodeError as e:
last_exception = e
if attempt < max_retries:
logger.warning("Gemini returned invalid JSON, retrying...")
backoff = min(initial_backoff * (2**attempt), max_backoff)
logger.warning(f"Gemini returned invalid JSON, retrying...")
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
@@ -571,16 +408,16 @@ class LLMProvider:
raise
except genai_errors.APIError as e:
# Fast fail only on 401 (unauthorized) and 403 (forbidden) - these won't recover with retries
if e.code in (401, 403):
logger.error(f"Gemini auth error (HTTP {e.code}), not retrying: {str(e)}")
# Fast fail on 4xx client errors (except 429 rate limit)
if e.code and 400 <= e.code < 500 and e.code != 429:
logger.error(f"Gemini client error (HTTP {e.code}), not retrying: {str(e)}")
raise
# Retry on retryable errors (rate limits, server errors, and other client errors like 400)
if e.code in (400, 429, 500, 502, 503, 504) or (e.code and e.code >= 500):
# Retry on 429 and 5xx
if e.code in (429, 500, 502, 503, 504):
last_exception = e
if attempt < max_retries:
backoff = min(initial_backoff * (2**attempt), max_backoff)
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
await asyncio.sleep(backoff + jitter)
else:
@@ -596,47 +433,55 @@ class LLMProvider:
if last_exception:
raise last_exception
raise RuntimeError("Gemini call failed after all retries")
raise RuntimeError(f"Gemini call failed after all retries")
@classmethod
def for_memory(cls) -> "LLMProvider":
"""Create provider for memory operations from environment variables."""
provider = os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq")
api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY")
if not api_key:
raise ValueError("HINDSIGHT_API_LLM_API_KEY environment variable is required")
base_url = os.getenv("HINDSIGHT_API_LLM_BASE_URL", "")
model = os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b")
return cls(provider=provider, api_key=api_key, base_url=base_url, model=model, reasoning_effort="low")
return cls(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort="low"
)
@classmethod
def for_answer_generation(cls) -> "LLMProvider":
"""Create provider for answer generation. Falls back to memory config if not set."""
provider = os.getenv("HINDSIGHT_API_ANSWER_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
api_key = os.getenv("HINDSIGHT_API_ANSWER_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY"))
if not api_key:
raise ValueError(
"HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_ANSWER_LLM_API_KEY environment variable is required"
)
base_url = os.getenv("HINDSIGHT_API_ANSWER_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
model = os.getenv("HINDSIGHT_API_ANSWER_LLM_MODEL", os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"))
return cls(provider=provider, api_key=api_key, base_url=base_url, model=model, reasoning_effort="high")
return cls(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort="high"
)
@classmethod
def for_judge(cls) -> "LLMProvider":
"""Create provider for judge/evaluator operations. Falls back to memory config if not set."""
provider = os.getenv("HINDSIGHT_API_JUDGE_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
api_key = os.getenv("HINDSIGHT_API_JUDGE_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY"))
if not api_key:
raise ValueError(
"HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_JUDGE_LLM_API_KEY environment variable is required"
)
base_url = os.getenv("HINDSIGHT_API_JUDGE_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
model = os.getenv("HINDSIGHT_API_JUDGE_LLM_MODEL", os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"))
return cls(provider=provider, api_key=api_key, base_url=base_url, model=model, reasoning_effort="high")
return cls(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort="high"
)
# Backwards compatibility alias
File diff suppressed because it is too large Load Diff
@@ -4,12 +4,11 @@ Query analysis abstraction for the memory system.
Provides an interface for analyzing natural language queries to extract
structured information like temporal constraints.
"""
from abc import ABC, abstractmethod
from typing import Optional
from datetime import datetime, timedelta
import logging
import re
from abc import ABC, abstractmethod
from datetime import datetime, timedelta
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
@@ -21,7 +20,6 @@ class TemporalConstraint(BaseModel):
Represents a time range with start and end dates.
"""
start_date: datetime = Field(description="Start of the time range (inclusive)")
end_date: datetime = Field(description="End of the time range (inclusive)")
@@ -35,9 +33,9 @@ class QueryAnalysis(BaseModel):
Contains extracted structured information like temporal constraints.
"""
temporal_constraint: TemporalConstraint | None = Field(
default=None, description="Extracted temporal constraint, if any"
temporal_constraint: Optional[TemporalConstraint] = Field(
default=None,
description="Extracted temporal constraint, if any"
)
@@ -60,7 +58,9 @@ class QueryAnalyzer(ABC):
pass
@abstractmethod
def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAnalysis:
def analyze(
self, query: str, reference_date: Optional[datetime] = None
) -> QueryAnalysis:
"""
Analyze a natural language query.
@@ -95,10 +95,11 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
"""Load dateparser (lazy import)."""
if self._search_dates is None:
from dateparser.search import search_dates
self._search_dates = search_dates
def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAnalysis:
def analyze(
self, query: str, reference_date: Optional[datetime] = None
) -> QueryAnalysis:
"""
Analyze query using dateparser.
@@ -125,9 +126,9 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
# Use dateparser's search_dates to find temporal expressions
settings = {
"RELATIVE_BASE": reference_date,
"PREFER_DATES_FROM": "past",
"RETURN_AS_TIMEZONE_AWARE": False,
'RELATIVE_BASE': reference_date,
'PREFER_DATES_FROM': 'past',
'RETURN_AS_TIMEZONE_AWARE': False,
}
results = self._search_dates(query, settings=settings)
@@ -136,8 +137,11 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
return QueryAnalysis(temporal_constraint=None)
# Filter out false positives (common words parsed as dates)
false_positives = {"do", "may", "march", "will", "can", "sat", "sun", "mon", "tue", "wed", "thu", "fri"}
valid_results = [(text, date) for text, date in results if text.lower() not in false_positives or len(text) > 3]
false_positives = {'do', 'may', 'march', 'will', 'can', 'sat', 'sun', 'mon', 'tue', 'wed', 'thu', 'fri'}
valid_results = [
(text, date) for text, date in results
if text.lower() not in false_positives or len(text) > 3
]
if not valid_results:
return QueryAnalysis(temporal_constraint=None)
@@ -149,94 +153,84 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
start_date = parsed_date.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = parsed_date.replace(hour=23, minute=59, second=59, microsecond=999999)
return QueryAnalysis(temporal_constraint=TemporalConstraint(start_date=start_date, end_date=end_date))
return QueryAnalysis(
temporal_constraint=TemporalConstraint(
start_date=start_date,
end_date=end_date
)
)
def _extract_period(self, query: str, reference_date: datetime) -> TemporalConstraint | None:
def _extract_period(
self, query: str, reference_date: datetime
) -> Optional[TemporalConstraint]:
"""
Extract period-based temporal expressions (week, month, year, weekend).
These need special handling as they represent date ranges, not single dates.
Supports multiple languages.
"""
def constraint(start: datetime, end: datetime) -> TemporalConstraint:
return TemporalConstraint(
start_date=start.replace(hour=0, minute=0, second=0, microsecond=0),
end_date=end.replace(hour=23, minute=59, second=59, microsecond=999999),
end_date=end.replace(hour=23, minute=59, second=59, microsecond=999999)
)
# Yesterday patterns (English, Spanish, Italian, French, German)
if re.search(r"\b(yesterday|ayer|ieri|hier|gestern)\b", query, re.IGNORECASE):
if re.search(r'\b(yesterday|ayer|ieri|hier|gestern)\b', query, re.IGNORECASE):
d = reference_date - timedelta(days=1)
return constraint(d, d)
# Today patterns
if re.search(r"\b(today|hoy|oggi|aujourd\'?hui|heute)\b", query, re.IGNORECASE):
if re.search(r'\b(today|hoy|oggi|aujourd\'?hui|heute)\b', query, re.IGNORECASE):
return constraint(reference_date, reference_date)
# "a couple of days ago" / "a few days ago" patterns
# These are imprecise so we create a range
if re.search(r"\b(a\s+)?couple\s+(of\s+)?days?\s+ago\b", query, re.IGNORECASE):
if re.search(r'\b(a\s+)?couple\s+(of\s+)?days?\s+ago\b', query, re.IGNORECASE):
# "a couple of days" = approximately 2 days, give range of 1-3 days
return constraint(reference_date - timedelta(days=3), reference_date - timedelta(days=1))
if re.search(r"\b(a\s+)?few\s+days?\s+ago\b", query, re.IGNORECASE):
if re.search(r'\b(a\s+)?few\s+days?\s+ago\b', query, re.IGNORECASE):
# "a few days" = approximately 3-4 days, give range of 2-5 days
return constraint(reference_date - timedelta(days=5), reference_date - timedelta(days=2))
# "a couple of weeks ago" / "a few weeks ago" patterns
if re.search(r"\b(a\s+)?couple\s+(of\s+)?weeks?\s+ago\b", query, re.IGNORECASE):
if re.search(r'\b(a\s+)?couple\s+(of\s+)?weeks?\s+ago\b', query, re.IGNORECASE):
# "a couple of weeks" = approximately 2 weeks, give range of 1-3 weeks
return constraint(reference_date - timedelta(weeks=3), reference_date - timedelta(weeks=1))
if re.search(r"\b(a\s+)?few\s+weeks?\s+ago\b", query, re.IGNORECASE):
if re.search(r'\b(a\s+)?few\s+weeks?\s+ago\b', query, re.IGNORECASE):
# "a few weeks" = approximately 3-4 weeks, give range of 2-5 weeks
return constraint(reference_date - timedelta(weeks=5), reference_date - timedelta(weeks=2))
# "a couple of months ago" / "a few months ago" patterns
if re.search(r"\b(a\s+)?couple\s+(of\s+)?months?\s+ago\b", query, re.IGNORECASE):
if re.search(r'\b(a\s+)?couple\s+(of\s+)?months?\s+ago\b', query, re.IGNORECASE):
# "a couple of months" = approximately 2 months, give range of 1-3 months
return constraint(reference_date - timedelta(days=90), reference_date - timedelta(days=30))
if re.search(r"\b(a\s+)?few\s+months?\s+ago\b", query, re.IGNORECASE):
if re.search(r'\b(a\s+)?few\s+months?\s+ago\b', query, re.IGNORECASE):
# "a few months" = approximately 3-4 months, give range of 2-5 months
return constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60))
# Last week patterns (English, Spanish, Italian, French, German)
if re.search(
r"\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche)\b",
query,
re.IGNORECASE,
):
if re.search(r'\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche)\b', query, re.IGNORECASE):
start = reference_date - timedelta(days=reference_date.weekday() + 7)
return constraint(start, start + timedelta(days=6))
# Last month patterns
if re.search(
r"\b(last\s+month|el\s+mes\s+pasado|il\s+mese\s+scorso|le\s+mois\s+dernier|letzten?\s+monat)\b",
query,
re.IGNORECASE,
):
if re.search(r'\b(last\s+month|el\s+mes\s+pasado|il\s+mese\s+scorso|le\s+mois\s+dernier|letzten?\s+monat)\b', query, re.IGNORECASE):
first = reference_date.replace(day=1)
end = first - timedelta(days=1)
start = end.replace(day=1)
return constraint(start, end)
# Last year patterns
if re.search(
r"\b(last\s+year|el\s+a[ñn]o\s+pasado|l\'anno\s+scorso|l\'ann[ée]e\s+derni[eè]re|letztes?\s+jahr)\b",
query,
re.IGNORECASE,
):
if re.search(r'\b(last\s+year|el\s+a[ñn]o\s+pasado|l\'anno\s+scorso|l\'ann[ée]e\s+derni[eè]re|letztes?\s+jahr)\b', query, re.IGNORECASE):
year = reference_date.year - 1
return constraint(datetime(year, 1, 1), datetime(year, 12, 31))
# Last weekend patterns
if re.search(
r"\b(last\s+weekend|el\s+fin\s+de\s+semana\s+pasado|lo\s+scorso\s+fine\s+settimana|le\s+week-?end\s+dernier|letztes?\s+wochenende)\b",
query,
re.IGNORECASE,
):
if re.search(r'\b(last\s+weekend|el\s+fin\s+de\s+semana\s+pasado|lo\s+scorso\s+fine\s+settimana|le\s+week-?end\s+dernier|letztes?\s+wochenende)\b', query, re.IGNORECASE):
days_since_sat = (reference_date.weekday() + 2) % 7
if days_since_sat == 0:
days_since_sat = 7
@@ -245,22 +239,22 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
# Month + Year patterns (e.g., "June 2024", "junio 2024", "giugno 2024")
month_patterns = {
"january|enero|gennaio|janvier|januar": 1,
"february|febrero|febbraio|f[ée]vrier|februar": 2,
"march|marzo|mars|m[äa]rz": 3,
"april|abril|aprile|avril": 4,
"may|mayo|maggio|mai": 5,
"june|junio|giugno|juin|juni": 6,
"july|julio|luglio|juillet|juli": 7,
"august|agosto|ao[uû]t": 8,
"september|septiembre|settembre|septembre": 9,
"october|octubre|ottobre|octobre|oktober": 10,
"november|noviembre|novembre": 11,
"december|diciembre|dicembre|d[ée]cembre|dezember": 12,
'january|enero|gennaio|janvier|januar': 1,
'february|febrero|febbraio|f[ée]vrier|februar': 2,
'march|marzo|mars|m[äa]rz': 3,
'april|abril|aprile|avril': 4,
'may|mayo|maggio|mai': 5,
'june|junio|giugno|juin|juni': 6,
'july|julio|luglio|juillet|juli': 7,
'august|agosto|ao[uû]t': 8,
'september|septiembre|settembre|septembre': 9,
'october|octubre|ottobre|octobre|oktober': 10,
'november|noviembre|novembre': 11,
'december|diciembre|dicembre|d[ée]cembre|dezember': 12,
}
for pattern, month_num in month_patterns.items():
match = re.search(rf"\b({pattern})\s+(\d{{4}})\b", query, re.IGNORECASE)
match = re.search(rf'\b({pattern})\s+(\d{{4}})\b', query, re.IGNORECASE)
if match:
year = int(match.group(2))
start = datetime(year, month_num, 1)
@@ -285,7 +279,11 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
- Model size: ~80M params (~300MB download)
"""
def __init__(self, model_name: str = "google/flan-t5-small", device: str = "cpu"):
def __init__(
self,
model_name: str = "google/flan-t5-small",
device: str = "cpu"
):
"""
Initialize T5 query analyzer.
@@ -306,10 +304,11 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
return
try:
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
except ImportError:
raise ImportError(
"transformers is required for TransformerQueryAnalyzer. Install it with: pip install transformers"
"transformers is required for TransformerQueryAnalyzer. "
"Install it with: pip install transformers"
)
logger.info(f"Loading query analyzer model: {self.model_name}...")
@@ -323,7 +322,9 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
"""Lazy load the T5 model for temporal extraction (calls load())."""
self.load()
def _extract_with_rules(self, query: str, reference_date: datetime) -> TemporalConstraint | None:
def _extract_with_rules(
self, query: str, reference_date: datetime
) -> Optional[TemporalConstraint]:
"""
Extract temporal expressions using rule-based patterns.
@@ -331,7 +332,6 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
patterns that need model-based extraction.
"""
import re
query_lower = query.lower()
def get_last_weekday(weekday: int) -> datetime:
@@ -343,60 +343,50 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
def constraint(start: datetime, end: datetime) -> TemporalConstraint:
return TemporalConstraint(
start_date=start.replace(hour=0, minute=0, second=0, microsecond=0),
end_date=end.replace(hour=23, minute=59, second=59, microsecond=999999),
end_date=end.replace(hour=23, minute=59, second=59, microsecond=999999)
)
# Yesterday
if re.search(r"\byesterday\b", query_lower):
if re.search(r'\byesterday\b', query_lower):
d = reference_date - timedelta(days=1)
return constraint(d, d)
# Last week
if re.search(r"\blast\s+week\b", query_lower):
if re.search(r'\blast\s+week\b', query_lower):
start = reference_date - timedelta(days=reference_date.weekday() + 7)
return constraint(start, start + timedelta(days=6))
# Last month
if re.search(r"\blast\s+month\b", query_lower):
if re.search(r'\blast\s+month\b', query_lower):
first = reference_date.replace(day=1)
end = first - timedelta(days=1)
start = end.replace(day=1)
return constraint(start, end)
# Last year
if re.search(r"\blast\s+year\b", query_lower):
if re.search(r'\blast\s+year\b', query_lower):
y = reference_date.year - 1
return constraint(datetime(y, 1, 1), datetime(y, 12, 31))
# Last weekend
if re.search(r"\blast\s+weekend\b", query_lower):
if re.search(r'\blast\s+weekend\b', query_lower):
sat = get_last_weekday(5)
return constraint(sat, sat + timedelta(days=1))
# Last <weekday>
weekdays = {"monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6}
weekdays = {'monday': 0, 'tuesday': 1, 'wednesday': 2, 'thursday': 3,
'friday': 4, 'saturday': 5, 'sunday': 6}
for name, num in weekdays.items():
if re.search(rf"\blast\s+{name}\b", query_lower):
if re.search(rf'\blast\s+{name}\b', query_lower):
d = get_last_weekday(num)
return constraint(d, d)
# Month + Year: "June 2024", "in March 2023"
months = {
"january": 1,
"february": 2,
"march": 3,
"april": 4,
"may": 5,
"june": 6,
"july": 7,
"august": 8,
"september": 9,
"october": 10,
"november": 11,
"december": 12,
}
months = {'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5,
'june': 6, 'july': 7, 'august': 8, 'september': 9, 'october': 10,
'november': 11, 'december': 12}
for name, num in months.items():
match = re.search(rf"\b{name}\s+(\d{{4}})\b", query_lower)
match = re.search(rf'\b{name}\s+(\d{{4}})\b', query_lower)
if match:
year = int(match.group(1))
if num == 12:
@@ -407,7 +397,9 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
return None
def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAnalysis:
def analyze(
self, query: str, reference_date: Optional[datetime] = None
) -> QueryAnalysis:
"""
Analyze query for temporal expressions.
@@ -443,11 +435,11 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
last_saturday = get_last_weekday(5)
# Build prompt for T5
prompt = f"""Today is {reference_date.strftime("%Y-%m-%d")}. Extract date range or "none".
prompt = f"""Today is {reference_date.strftime('%Y-%m-%d')}. Extract date range or "none".
June 2024 = 2024-06-01 to 2024-06-30
yesterday = {yesterday.strftime("%Y-%m-%d")} to {yesterday.strftime("%Y-%m-%d")}
last Saturday = {last_saturday.strftime("%Y-%m-%d")} to {last_saturday.strftime("%Y-%m-%d")}
yesterday = {yesterday.strftime('%Y-%m-%d')} to {yesterday.strftime('%Y-%m-%d')}
last Saturday = {last_saturday.strftime('%Y-%m-%d')} to {last_saturday.strftime('%Y-%m-%d')}
what is the weather = none
{query} ="""
@@ -456,7 +448,13 @@ what is the weather = none
inputs = {k: v.to(self.device) for k, v in inputs.items()}
with self._no_grad():
outputs = self._model.generate(**inputs, max_new_tokens=30, num_beams=3, do_sample=False, temperature=1.0)
outputs = self._model.generate(
**inputs,
max_new_tokens=30,
num_beams=3,
do_sample=False,
temperature=1.0
)
result = self._tokenizer.decode(outputs[0], skip_special_tokens=True).strip()
@@ -468,14 +466,14 @@ what is the weather = none
"""Get torch.no_grad context manager."""
try:
import torch
return torch.no_grad()
except ImportError:
from contextlib import nullcontext
return nullcontext()
def _parse_generated_output(self, result: str, reference_date: datetime) -> TemporalConstraint | None:
def _parse_generated_output(
self, result: str, reference_date: datetime
) -> Optional[TemporalConstraint]:
"""
Parse T5 generated output into TemporalConstraint.
@@ -494,8 +492,7 @@ what is the weather = none
try:
# Parse "YYYY-MM-DD to YYYY-MM-DD"
import re
pattern = r"(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})"
pattern = r'(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})'
match = re.search(pattern, result, re.IGNORECASE)
if match:
@@ -516,7 +513,7 @@ what is the weather = none
return TemporalConstraint(start_date=start_date, end_date=end_date)
except (ValueError, AttributeError):
except (ValueError, AttributeError) as e:
return None
return None
@@ -6,9 +6,9 @@ API response models should be kept separate and convert from these core models t
API stability even if internal models change.
"""
from typing import Any
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
# Valid fact types for recall operations (excludes 'observation' which is internal)
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "opinion"])
@@ -23,12 +23,17 @@ class DispositionTraits(BaseModel):
- literalism: 1=flexible interpretation, 5=literal interpretation (how strictly to interpret information)
- empathy: 1=detached, 5=empathetic (how much to consider emotional context)
"""
skepticism: int = Field(ge=1, le=5, description="How skeptical vs trusting (1=trusting, 5=skeptical)")
literalism: int = Field(ge=1, le=5, description="How literally to interpret information (1=flexible, 5=literal)")
empathy: int = Field(ge=1, le=5, description="How much to consider emotional context (1=detached, 5=empathetic)")
model_config = ConfigDict(json_schema_extra={"example": {"skepticism": 3, "literalism": 3, "empathy": 3}})
model_config = ConfigDict(json_schema_extra={
"example": {
"skepticism": 3,
"literalism": 3,
"empathy": 3
}
})
class MemoryFact(BaseModel):
@@ -38,44 +43,38 @@ class MemoryFact(BaseModel):
This represents a unit of information stored in the memory system,
including both the content and metadata.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"text": "Alice works at Google on the AI team",
"fact_type": "world",
"entities": ["Alice", "Google"],
"context": "work info",
"occurred_start": "2024-01-15T10:30:00Z",
"occurred_end": "2024-01-15T10:30:00Z",
"mentioned_at": "2024-01-15T10:30:00Z",
"document_id": "session_abc123",
"metadata": {"source": "slack"},
"chunk_id": "bank123_session_abc123_0",
"activation": 0.95,
}
model_config = ConfigDict(json_schema_extra={
"example": {
"id": "123e4567-e89b-12d3-a456-426614174000",
"text": "Alice works at Google on the AI team",
"fact_type": "world",
"entities": ["Alice", "Google"],
"context": "work info",
"occurred_start": "2024-01-15T10:30:00Z",
"occurred_end": "2024-01-15T10:30:00Z",
"mentioned_at": "2024-01-15T10:30:00Z",
"document_id": "session_abc123",
"metadata": {"source": "slack"},
"chunk_id": "bank123_session_abc123_0",
"activation": 0.95
}
)
})
id: str = Field(description="Unique identifier for the memory fact")
text: str = Field(description="The actual text content of the memory")
fact_type: str = Field(description="Type of fact: 'world', 'experience', 'opinion', or 'observation'")
entities: list[str] | None = Field(None, description="Entity names mentioned in this fact")
context: str | None = Field(None, description="Additional context for the memory")
occurred_start: str | None = Field(None, description="ISO format date when the event started occurring")
occurred_end: str | None = Field(None, description="ISO format date when the event ended occurring")
mentioned_at: str | None = Field(None, description="ISO format date when the fact was mentioned/learned")
document_id: str | None = Field(None, description="ID of the document this memory belongs to")
metadata: dict[str, str] | None = Field(None, description="User-defined metadata")
chunk_id: str | None = Field(
None, description="ID of the chunk this fact was extracted from (format: bank_id_document_id_chunk_index)"
)
entities: Optional[List[str]] = Field(None, description="Entity names mentioned in this fact")
context: Optional[str] = Field(None, description="Additional context for the memory")
occurred_start: Optional[str] = Field(None, description="ISO format date when the event started occurring")
occurred_end: Optional[str] = Field(None, description="ISO format date when the event ended occurring")
mentioned_at: Optional[str] = Field(None, description="ISO format date when the fact was mentioned/learned")
document_id: Optional[str] = Field(None, description="ID of the document this memory belongs to")
metadata: Optional[Dict[str, str]] = Field(None, description="User-defined metadata")
chunk_id: Optional[str] = Field(None, description="ID of the chunk this fact was extracted from (format: bank_id_document_id_chunk_index)")
class ChunkInfo(BaseModel):
"""Information about a chunk."""
chunk_text: str = Field(description="The raw chunk text")
chunk_index: int = Field(description="Index of the chunk within the document")
truncated: bool = Field(default=False, description="Whether the chunk was truncated due to token limits")
@@ -88,33 +87,35 @@ class RecallResult(BaseModel):
Contains a list of matching memory facts and optional trace information
for debugging and transparency.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"results": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"text": "Alice works at Google on the AI team",
"fact_type": "world",
"context": "work info",
"occurred_start": "2024-01-15T10:30:00Z",
"occurred_end": "2024-01-15T10:30:00Z",
"activation": 0.95,
}
],
"trace": {"query": "What did Alice say about machine learning?", "num_results": 1},
model_config = ConfigDict(json_schema_extra={
"example": {
"results": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"text": "Alice works at Google on the AI team",
"fact_type": "world",
"context": "work info",
"occurred_start": "2024-01-15T10:30:00Z",
"occurred_end": "2024-01-15T10:30:00Z",
"activation": 0.95
}
],
"trace": {
"query": "What did Alice say about machine learning?",
"num_results": 1
}
}
)
})
results: list[MemoryFact] = Field(description="List of memory facts matching the query")
trace: dict[str, Any] | None = Field(None, description="Trace information for debugging")
entities: dict[str, "EntityState"] | None = Field(
None, description="Entity states for entities mentioned in results (keyed by canonical name)"
results: List[MemoryFact] = Field(description="List of memory facts matching the query")
trace: Optional[Dict[str, Any]] = Field(None, description="Trace information for debugging")
entities: Optional[Dict[str, "EntityState"]] = Field(
None,
description="Entity states for entities mentioned in results (keyed by canonical name)"
)
chunks: dict[str, ChunkInfo] | None = Field(
None, description="Chunks for facts, keyed by '{document_id}_{chunk_index}'"
chunks: Optional[Dict[str, ChunkInfo]] = Field(
None,
description="Chunks for facts, keyed by '{document_id}_{chunk_index}'"
)
@@ -125,35 +126,37 @@ class ReflectResult(BaseModel):
Contains the formulated answer, the facts it was based on (organized by type),
and any new opinions that were formed during the reflection process.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"text": "Based on my knowledge, machine learning is being actively used in healthcare...",
"based_on": {
"world": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"text": "Machine learning is used in medical diagnosis",
"fact_type": "world",
"context": "healthcare",
"occurred_start": "2024-01-15T10:30:00Z",
"occurred_end": "2024-01-15T10:30:00Z",
}
],
"experience": [],
"opinion": [],
},
"new_opinions": ["Machine learning has great potential in healthcare"],
}
model_config = ConfigDict(json_schema_extra={
"example": {
"text": "Based on my knowledge, machine learning is being actively used in healthcare...",
"based_on": {
"world": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"text": "Machine learning is used in medical diagnosis",
"fact_type": "world",
"context": "healthcare",
"occurred_start": "2024-01-15T10:30:00Z",
"occurred_end": "2024-01-15T10:30:00Z"
}
],
"experience": [],
"opinion": []
},
"new_opinions": [
"Machine learning has great potential in healthcare"
]
}
)
})
text: str = Field(description="The formulated answer text")
based_on: dict[str, list[MemoryFact]] = Field(
based_on: Dict[str, List[MemoryFact]] = Field(
description="Facts used to formulate the answer, organized by type (world, experience, opinion)"
)
new_opinions: list[str] = Field(default_factory=list, description="List of newly formed opinions during reflection")
new_opinions: List[str] = Field(
default_factory=list,
description="List of newly formed opinions during reflection"
)
class Opinion(BaseModel):
@@ -163,12 +166,12 @@ class Opinion(BaseModel):
Opinions represent the bank's formed perspectives on topics,
with a confidence level indicating strength of belief.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {"text": "Machine learning has great potential in healthcare", "confidence": 0.85}
model_config = ConfigDict(json_schema_extra={
"example": {
"text": "Machine learning has great potential in healthcare",
"confidence": 0.85
}
)
})
text: str = Field(description="The opinion text")
confidence: float = Field(description="Confidence score between 0.0 and 1.0")
@@ -181,15 +184,15 @@ class EntityObservation(BaseModel):
Observations are objective facts synthesized from multiple memory facts
about an entity, without personality influence.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {"text": "John is detail-oriented and works at Google", "mentioned_at": "2024-01-15T10:30:00Z"}
model_config = ConfigDict(json_schema_extra={
"example": {
"text": "John is detail-oriented and works at Google",
"mentioned_at": "2024-01-15T10:30:00Z"
}
)
})
text: str = Field(description="The observation text")
mentioned_at: str | None = Field(None, description="ISO format date when this observation was created")
mentioned_at: Optional[str] = Field(None, description="ISO format date when this observation was created")
class EntityState(BaseModel):
@@ -198,22 +201,20 @@ class EntityState(BaseModel):
Contains observations synthesized from facts about the entity.
"""
model_config = ConfigDict(
json_schema_extra={
"example": {
"entity_id": "123e4567-e89b-12d3-a456-426614174000",
"canonical_name": "John",
"observations": [
{"text": "John is detail-oriented", "mentioned_at": "2024-01-15T10:30:00Z"},
{"text": "John works at Google on the AI team", "mentioned_at": "2024-01-14T09:00:00Z"},
],
}
model_config = ConfigDict(json_schema_extra={
"example": {
"entity_id": "123e4567-e89b-12d3-a456-426614174000",
"canonical_name": "John",
"observations": [
{"text": "John is detail-oriented", "mentioned_at": "2024-01-15T10:30:00Z"},
{"text": "John works at Google on the AI team", "mentioned_at": "2024-01-14T09:00:00Z"}
]
}
)
})
entity_id: str = Field(description="Unique identifier for the entity")
canonical_name: str = Field(description="Canonical name of the entity")
observations: list[EntityObservation] = Field(
default_factory=list, description="List of observations about this entity"
observations: List[EntityObservation] = Field(
default_factory=list,
description="List of observations about this entity"
)
@@ -12,16 +12,23 @@ This package contains modular components for the retain operation:
- fact_storage: Handle fact insertion into database
"""
from . import (
chunk_storage,
deduplication,
embedding_processing,
entity_processing,
fact_extraction,
fact_storage,
link_creation,
from .types import (
RetainContent,
ExtractedFact,
ProcessedFact,
ChunkMetadata,
EntityRef,
CausalRelation,
RetainBatch
)
from .types import CausalRelation, ChunkMetadata, EntityRef, ExtractedFact, ProcessedFact, RetainBatch, RetainContent
from . import fact_extraction
from . import embedding_processing
from . import deduplication
from . import entity_processing
from . import link_creation
from . import chunk_storage
from . import fact_storage
__all__ = [
# Types
@@ -5,12 +5,9 @@ bank profile utilities for disposition and background management.
import json
import logging
import re
from typing import TypedDict
from typing import Dict, Optional, TypedDict
from pydantic import BaseModel, Field
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from ..response_models import DispositionTraits
logger = logging.getLogger(__name__)
@@ -24,7 +21,6 @@ DEFAULT_DISPOSITION = {
class BankProfile(TypedDict):
"""Type for bank profile data."""
name: str
disposition: DispositionTraits
background: str
@@ -32,7 +28,6 @@ class BankProfile(TypedDict):
class BackgroundMergeResponse(BaseModel):
"""LLM response for background merge with disposition inference."""
background: str = Field(description="Merged background in first person perspective")
disposition: DispositionTraits = Field(description="Inferred disposition traits (skepticism, literalism, empathy)")
@@ -52,11 +47,11 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
async with acquire_with_retry(pool) as conn:
# Try to get existing bank
row = await conn.fetchrow(
f"""
"""
SELECT name, disposition, background
FROM {fq_table("banks")} WHERE bank_id = $1
FROM banks WHERE bank_id = $1
""",
bank_id,
bank_id
)
if row:
@@ -66,26 +61,36 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
disposition_data = json.loads(disposition_data)
return BankProfile(
name=row["name"], disposition=DispositionTraits(**disposition_data), background=row["background"]
name=row["name"],
disposition=DispositionTraits(**disposition_data),
background=row["background"]
)
# Bank doesn't exist, create with defaults
await conn.execute(
f"""
INSERT INTO {fq_table("banks")} (bank_id, name, disposition, background)
"""
INSERT INTO banks (bank_id, name, disposition, background)
VALUES ($1, $2, $3::jsonb, $4)
ON CONFLICT (bank_id) DO NOTHING
""",
bank_id,
bank_id, # Default name is the bank_id
json.dumps(DEFAULT_DISPOSITION),
"",
""
)
return BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), background="")
return BankProfile(
name=bank_id,
disposition=DispositionTraits(**DEFAULT_DISPOSITION),
background=""
)
async def update_bank_disposition(pool, bank_id: str, disposition: dict[str, int]) -> None:
async def update_bank_disposition(
pool,
bank_id: str,
disposition: Dict[str, int]
) -> None:
"""
Update bank disposition traits.
@@ -99,18 +104,24 @@ async def update_bank_disposition(pool, bank_id: str, disposition: dict[str, int
async with acquire_with_retry(pool) as conn:
await conn.execute(
f"""
UPDATE {fq_table("banks")}
"""
UPDATE banks
SET disposition = $2::jsonb,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
json.dumps(disposition),
json.dumps(disposition)
)
async def merge_bank_background(pool, llm_config, bank_id: str, new_info: str, update_disposition: bool = True) -> dict:
async def merge_bank_background(
pool,
llm_config,
bank_id: str,
new_info: str,
update_disposition: bool = True
) -> dict:
"""
Merge new background information with existing background using LLM.
Normalizes to first person ("I") and resolves conflicts.
@@ -131,7 +142,12 @@ async def merge_bank_background(pool, llm_config, bank_id: str, new_info: str, u
current_background = profile["background"]
# Use LLM to merge backgrounds and optionally infer disposition
result = await _llm_merge_background(llm_config, current_background, new_info, infer_disposition=update_disposition)
result = await _llm_merge_background(
llm_config,
current_background,
new_info,
infer_disposition=update_disposition
)
merged_background = result["background"]
inferred_disposition = result.get("disposition")
@@ -141,8 +157,8 @@ async def merge_bank_background(pool, llm_config, bank_id: str, new_info: str, u
if inferred_disposition:
# Update both background and disposition
await conn.execute(
f"""
UPDATE {fq_table("banks")}
"""
UPDATE banks
SET background = $2,
disposition = $3::jsonb,
updated_at = NOW()
@@ -150,19 +166,19 @@ async def merge_bank_background(pool, llm_config, bank_id: str, new_info: str, u
""",
bank_id,
merged_background,
json.dumps(inferred_disposition),
json.dumps(inferred_disposition)
)
else:
# Update only background
await conn.execute(
f"""
UPDATE {fq_table("banks")}
"""
UPDATE banks
SET background = $2,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
merged_background,
merged_background
)
response = {"background": merged_background}
@@ -172,7 +188,12 @@ async def merge_bank_background(pool, llm_config, bank_id: str, new_info: str, u
return response
async def _llm_merge_background(llm_config, current: str, new_info: str, infer_disposition: bool = False) -> dict:
async def _llm_merge_background(
llm_config,
current: str,
new_info: str,
infer_disposition: bool = False
) -> dict:
"""
Use LLM to intelligently merge background information.
Optionally infer Big Five disposition traits from the merged background.
@@ -252,19 +273,25 @@ Merged background:"""
response_format=BackgroundMergeResponse,
scope="bank_background",
temperature=0.3,
max_completion_tokens=8192,
max_completion_tokens=8192
)
logger.info(f"Successfully got structured response: background={parsed.background[:100]}")
# Convert Pydantic model to dict format
return {"background": parsed.background, "disposition": parsed.disposition.model_dump()}
return {
"background": parsed.background,
"disposition": parsed.disposition.model_dump()
}
except Exception as e:
logger.warning(f"Structured output failed, falling back to manual parsing: {e}")
# Fall through to manual parsing below
# Manual parsing fallback or non-disposition merge
content = await llm_config.call(
messages=messages, scope="bank_background", temperature=0.3, max_completion_tokens=8192
messages=messages,
scope="bank_background",
temperature=0.3,
max_completion_tokens=8192
)
logger.info(f"LLM response for background merge (first 500 chars): {content[:500]}")
@@ -283,7 +310,7 @@ Merged background:"""
# Method 2: Extract from markdown code blocks
if result is None:
# Remove markdown code blocks
code_block_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", content, re.DOTALL)
code_block_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', content, re.DOTALL)
if code_block_match:
try:
result = json.loads(code_block_match.group(1))
@@ -294,9 +321,7 @@ Merged background:"""
# Method 3: Find nested JSON structure
if result is None:
# Look for JSON object with nested structure
json_match = re.search(
r'\{[^{}]*"background"[^{}]*"disposition"[^{}]*\{[^{}]*\}[^{}]*\}', content, re.DOTALL
)
json_match = re.search(r'\{[^{}]*"background"[^{}]*"disposition"[^{}]*\{[^{}]*\}[^{}]*\}', content, re.DOTALL)
if json_match:
try:
result = json.loads(json_match.group())
@@ -310,7 +335,7 @@ Merged background:"""
# Fallback: use new_info as background with default disposition
return {
"background": new_info if new_info else current if current else "",
"disposition": DEFAULT_DISPOSITION.copy(),
"disposition": DEFAULT_DISPOSITION.copy()
}
# Validate disposition values
@@ -362,9 +387,9 @@ async def list_banks(pool) -> list:
"""
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
f"""
"""
SELECT bank_id, name, disposition, background, created_at, updated_at
FROM {fq_table("banks")}
FROM banks
ORDER BY updated_at DESC
"""
)
@@ -376,15 +401,13 @@ async def list_banks(pool) -> list:
if isinstance(disposition_data, str):
disposition_data = json.loads(disposition_data)
result.append(
{
"bank_id": row["bank_id"],
"name": row["name"],
"disposition": disposition_data,
"background": row["background"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
}
)
result.append({
"bank_id": row["bank_id"],
"name": row["name"],
"disposition": disposition_data,
"background": row["background"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
})
return result
@@ -3,16 +3,20 @@ Chunk storage for retain pipeline.
Handles storage of document chunks in the database.
"""
import logging
from typing import List, Dict, Optional
from ..memory_engine import fq_table
from .types import ChunkMetadata
logger = logging.getLogger(__name__)
async def store_chunks_batch(conn, bank_id: str, document_id: str, chunks: list[ChunkMetadata]) -> dict[int, str]:
async def store_chunks_batch(
conn,
bank_id: str,
document_id: str,
chunks: List[ChunkMetadata]
) -> Dict[int, str]:
"""
Store document chunks in the database.
@@ -43,21 +47,24 @@ async def store_chunks_batch(conn, bank_id: str, document_id: str, chunks: list[
# Batch insert all chunks
await conn.execute(
f"""
INSERT INTO {fq_table("chunks")} (chunk_id, document_id, bank_id, chunk_text, chunk_index)
"""
INSERT INTO chunks (chunk_id, document_id, bank_id, chunk_text, chunk_index)
SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::text[], $5::integer[])
""",
chunk_ids,
[document_id] * len(chunk_texts),
[bank_id] * len(chunk_texts),
chunk_texts,
chunk_indices,
chunk_indices
)
return chunk_id_map
def map_facts_to_chunks(facts_chunk_indices: list[int], chunk_id_map: dict[int, str]) -> list[str | None]:
def map_facts_to_chunks(
facts_chunk_indices: List[int],
chunk_id_map: Dict[int, str]
) -> List[Optional[str]]:
"""
Map fact chunk indices to chunk IDs.
@@ -3,17 +3,22 @@ Deduplication logic for retain pipeline.
Checks for duplicate facts using semantic similarity and temporal proximity.
"""
import logging
from datetime import datetime
from typing import List
from collections import defaultdict
from datetime import UTC
from .types import ProcessedFact
logger = logging.getLogger(__name__)
async def check_duplicates_batch(conn, bank_id: str, facts: list[ProcessedFact], duplicate_checker_fn) -> list[bool]:
async def check_duplicates_batch(
conn,
bank_id: str,
facts: List[ProcessedFact],
duplicate_checker_fn
) -> List[bool]:
"""
Check which facts are duplicates using batched time-window queries.
@@ -42,12 +47,16 @@ async def check_duplicates_batch(conn, bank_id: str, facts: list[ProcessedFact],
# Defensive: if both are None (shouldn't happen), use now()
if fact_date is None:
from datetime import datetime
fact_date = datetime.now(UTC)
from datetime import datetime, timezone
fact_date = datetime.now(timezone.utc)
# Round to 12-hour bucket to group similar times
bucket_key = fact_date.replace(hour=(fact_date.hour // 12) * 12, minute=0, second=0, microsecond=0)
bucket_key = fact_date.replace(
hour=(fact_date.hour // 12) * 12,
minute=0,
second=0,
microsecond=0
)
time_buckets[bucket_key].append((idx, fact))
# Process each bucket in batch
@@ -59,7 +68,14 @@ async def check_duplicates_batch(conn, bank_id: str, facts: list[ProcessedFact],
embeddings = [item[1].embedding for item in bucket_items]
# Check duplicates for this time bucket
dup_flags = await duplicate_checker_fn(conn, bank_id, texts, embeddings, bucket_date, time_window_hours=24)
dup_flags = await duplicate_checker_fn(
conn,
bank_id,
texts,
embeddings,
bucket_date,
time_window_hours=24
)
# Map results back to original indices
for idx, is_dup in zip(indices, dup_flags):
@@ -68,7 +84,10 @@ async def check_duplicates_batch(conn, bank_id: str, facts: list[ProcessedFact],
return all_is_duplicate
def filter_duplicates(facts: list[ProcessedFact], is_duplicate_flags: list[bool]) -> list[ProcessedFact]:
def filter_duplicates(
facts: List[ProcessedFact],
is_duplicate_flags: List[bool]
) -> List[ProcessedFact]:
"""
Filter out duplicate facts based on duplicate flags.
@@ -3,8 +3,9 @@ Embedding processing for retain pipeline.
Handles augmenting fact texts with temporal information and generating embeddings.
"""
import logging
from typing import List
from datetime import datetime
from . import embedding_utils
from .types import ExtractedFact
@@ -12,7 +13,7 @@ from .types import ExtractedFact
logger = logging.getLogger(__name__)
def augment_texts_with_dates(facts: list[ExtractedFact], format_date_fn) -> list[str]:
def augment_texts_with_dates(facts: List[ExtractedFact], format_date_fn) -> List[str]:
"""
Augment fact texts with readable dates for better temporal matching.
@@ -36,7 +37,10 @@ def augment_texts_with_dates(facts: list[ExtractedFact], format_date_fn) -> list
return augmented_texts
async def generate_embeddings_batch(embeddings_model, texts: list[str]) -> list[list[float]]:
async def generate_embeddings_batch(
embeddings_model,
texts: List[str]
) -> List[List[float]]:
"""
Generate embeddings for a batch of texts.
@@ -50,6 +54,9 @@ async def generate_embeddings_batch(embeddings_model, texts: list[str]) -> list[
if not texts:
return []
embeddings = await embedding_utils.generate_embeddings_batch(embeddings_model, texts)
embeddings = await embedding_utils.generate_embeddings_batch(
embeddings_model,
texts
)
return embeddings
@@ -4,11 +4,12 @@ Embedding generation utilities for memory units.
import asyncio
import logging
from typing import List
logger = logging.getLogger(__name__)
def generate_embedding(embeddings_backend, text: str) -> list[float]:
def generate_embedding(embeddings_backend, text: str) -> List[float]:
"""
Generate embedding for text using the provided embeddings backend.
@@ -26,7 +27,7 @@ def generate_embedding(embeddings_backend, text: str) -> list[float]:
raise Exception(f"Failed to generate embedding: {str(e)}")
async def generate_embeddings_batch(embeddings_backend, texts: list[str]) -> list[list[float]]:
async def generate_embeddings_batch(embeddings_backend, texts: List[str]) -> List[List[float]]:
"""
Generate embeddings for multiple texts using the provided embeddings backend.
@@ -46,7 +47,7 @@ async def generate_embeddings_batch(embeddings_backend, texts: list[str]) -> lis
embeddings = await loop.run_in_executor(
None, # Use default thread pool
embeddings_backend.encode,
texts,
texts
)
return embeddings
except Exception as e:
@@ -3,18 +3,24 @@ Entity processing for retain pipeline.
Handles entity extraction, resolution, and link creation for stored facts.
"""
import logging
from typing import List, Tuple, Dict, Any
from uuid import UUID
from .types import ProcessedFact, EntityRef, EntityLink
from . import link_utils
from .types import EntityLink, ProcessedFact
logger = logging.getLogger(__name__)
async def process_entities_batch(
entity_resolver, conn, bank_id: str, unit_ids: list[str], facts: list[ProcessedFact], log_buffer: list[str] = None
) -> list[EntityLink]:
entity_resolver,
conn,
bank_id: str,
unit_ids: List[str],
facts: List[ProcessedFact],
log_buffer: List[str] = None
) -> List[EntityLink]:
"""
Process entities for all facts and create entity links.
@@ -47,7 +53,8 @@ async def process_entities_batch(
fact_dates = [fact.occurred_start if fact.occurred_start is not None else fact.mentioned_at for fact in facts]
# Convert EntityRef objects to dict format expected by link_utils
entities_per_fact = [
[{"text": entity.name, "type": "CONCEPT"} for entity in (fact.entities or [])] for fact in facts
[{'text': entity.name, 'type': 'CONCEPT'} for entity in (fact.entities or [])]
for fact in facts
]
# Use existing link_utils function for entity processing
@@ -60,13 +67,16 @@ async def process_entities_batch(
"", # context (not used in current implementation)
fact_dates,
entities_per_fact,
log_buffer, # Pass log_buffer for detailed logging
log_buffer # Pass log_buffer for detailed logging
)
return entity_links
async def insert_entity_links_batch(conn, entity_links: list[EntityLink]) -> None:
async def insert_entity_links_batch(
conn,
entity_links: List[EntityLink]
) -> None:
"""
Insert entity links in batch.
@@ -4,17 +4,16 @@ Fact extraction from text using LLM.
Extracts semantic facts, entities, and temporal information from text.
Uses the LLMConfig wrapper for all LLM calls.
"""
import asyncio
import json
import logging
import os
import json
import re
import asyncio
from datetime import datetime, timedelta
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..llm_wrapper import LLMConfig, OutputTooLongError
from typing import List, Dict, Optional, Literal
from openai import AsyncOpenAI
from pydantic import BaseModel, Field, field_validator, ConfigDict
from ..llm_wrapper import OutputTooLongError, LLMConfig
def _sanitize_text(text: str) -> str:
@@ -32,12 +31,11 @@ def _sanitize_text(text: str) -> str:
return text
# Remove surrogate characters (U+D800 to U+DFFF) using regex
# These are invalid in UTF-8 and cause encoding errors
return re.sub(r"[\ud800-\udfff]", "", text)
return re.sub(r'[\ud800-\udfff]', '', text)
class Entity(BaseModel):
"""An entity extracted from text."""
text: str = Field(
description="The specific, named entity as it appears in the fact. Must be a proper noun or specific identifier."
)
@@ -50,46 +48,42 @@ class Fact(BaseModel):
This is what fact_extraction returns and what the rest of the pipeline expects.
Combined fact text format: "what | when | where | who | why"
"""
# Required fields
fact: str = Field(description="Combined fact text: what | when | where | who | why")
fact_type: Literal["world", "experience", "opinion"] = Field(description="Perspective: world/experience/opinion")
# Optional temporal fields
occurred_start: str | None = None
occurred_end: str | None = None
mentioned_at: str | None = None
occurred_start: Optional[str] = None
occurred_end: Optional[str] = None
mentioned_at: Optional[str] = None
# Optional location field
where: str | None = Field(
None, description="WHERE the fact occurred or is about (specific location, place, or area)"
)
where: Optional[str] = Field(None, description="WHERE the fact occurred or is about (specific location, place, or area)")
# Optional structured data
entities: list[Entity] | None = None
causal_relations: list["CausalRelation"] | None = None
entities: Optional[List[Entity]] = None
causal_relations: Optional[List['CausalRelation']] = None
class CausalRelation(BaseModel):
"""Causal relationship between facts."""
target_fact_index: int = Field(
description="Index of the related fact in the facts array (0-based). "
"This creates a directed causal link to another fact in the extraction."
"This creates a directed causal link to another fact in the extraction."
)
relation_type: Literal["causes", "caused_by", "enables", "prevents"] = Field(
description="Type of causal relationship: "
"'causes' = this fact directly causes the target fact, "
"'caused_by' = this fact was caused by the target fact, "
"'enables' = this fact enables/allows the target fact, "
"'prevents' = this fact prevents/blocks the target fact"
"'causes' = this fact directly causes the target fact, "
"'caused_by' = this fact was caused by the target fact, "
"'enables' = this fact enables/allows the target fact, "
"'prevents' = this fact prevents/blocks the target fact"
)
strength: float = Field(
description="Strength of causal relationship (0.0 to 1.0). "
"1.0 = direct/strong causation, 0.5 = moderate, 0.3 = weak/indirect",
"1.0 = direct/strong causation, 0.5 = moderate, 0.3 = weak/indirect",
ge=0.0,
le=1.0,
default=1.0,
default=1.0
)
@@ -98,7 +92,9 @@ class ExtractedFact(BaseModel):
model_config = ConfigDict(
json_schema_mode="validation",
json_schema_extra={"required": ["what", "when", "where", "who", "why", "fact_type"]},
json_schema_extra={
"required": ["what", "when", "where", "who", "why", "fact_type"]
}
)
# ==========================================================================
@@ -107,43 +103,43 @@ class ExtractedFact(BaseModel):
what: str = Field(
description="WHAT happened - COMPLETE, DETAILED description with ALL specifics. "
"NEVER summarize or omit details. Include: exact actions, objects, quantities, specifics. "
"BE VERBOSE - capture every detail that was mentioned. "
"Example: 'Emily got married to Sarah at a rooftop garden ceremony with 50 guests attending and a live jazz band playing' "
"NOT: 'A wedding happened' or 'Emily got married'"
"NEVER summarize or omit details. Include: exact actions, objects, quantities, specifics. "
"BE VERBOSE - capture every detail that was mentioned. "
"Example: 'Emily got married to Sarah at a rooftop garden ceremony with 50 guests attending and a live jazz band playing' "
"NOT: 'A wedding happened' or 'Emily got married'"
)
when: str = Field(
description="WHEN it happened - ALWAYS include temporal information if mentioned. "
"Include: specific dates, times, durations, relative time references. "
"Examples: 'on June 15th, 2024 at 3pm', 'last weekend', 'for the past 3 years', 'every morning at 6am'. "
"Write 'N/A' ONLY if absolutely no temporal context exists. Prefer converting to absolute dates when possible."
"Include: specific dates, times, durations, relative time references. "
"Examples: 'on June 15th, 2024 at 3pm', 'last weekend', 'for the past 3 years', 'every morning at 6am'. "
"Write 'N/A' ONLY if absolutely no temporal context exists. Prefer converting to absolute dates when possible."
)
where: str = Field(
description="WHERE it happened or is about - SPECIFIC locations, places, areas, regions if applicable. "
"Include: cities, neighborhoods, venues, buildings, countries, specific addresses when mentioned. "
"Examples: 'downtown San Francisco at a rooftop garden venue', 'at the user's home in Brooklyn', 'online via Zoom', 'Paris, France'. "
"Write 'N/A' ONLY if absolutely no location context exists or if the fact is completely location-agnostic."
"Include: cities, neighborhoods, venues, buildings, countries, specific addresses when mentioned. "
"Examples: 'downtown San Francisco at a rooftop garden venue', 'at the user's home in Brooklyn', 'online via Zoom', 'Paris, France'. "
"Write 'N/A' ONLY if absolutely no location context exists or if the fact is completely location-agnostic."
)
who: str = Field(
description="WHO is involved - ALL people/entities with FULL context and relationships. "
"Include: names, roles, relationships to user, background details. "
"Resolve coreferences (if 'my roommate' is later named 'Emily', write 'Emily, the user's college roommate'). "
"BE DETAILED about relationships and roles. "
"Example: 'Emily (user's college roommate from Stanford, now works at Google), Sarah (Emily's partner of 5 years, software engineer)' "
"NOT: 'my friend' or 'Emily and Sarah'"
"Include: names, roles, relationships to user, background details. "
"Resolve coreferences (if 'my roommate' is later named 'Emily', write 'Emily, the user's college roommate'). "
"BE DETAILED about relationships and roles. "
"Example: 'Emily (user's college roommate from Stanford, now works at Google), Sarah (Emily's partner of 5 years, software engineer)' "
"NOT: 'my friend' or 'Emily and Sarah'"
)
why: str = Field(
description="WHY it matters - ALL emotional, contextual, and motivational details. "
"Include EVERYTHING: feelings, preferences, motivations, observations, context, background, significance. "
"BE VERBOSE - capture all the nuance and meaning. "
"FOR ASSISTANT FACTS: MUST include what the user asked/requested that led to this interaction! "
"Example (world): 'The user felt thrilled and inspired, has always dreamed of an outdoor ceremony, mentioned wanting a similar garden venue, was particularly moved by the intimate atmosphere and personal vows' "
"Example (assistant): 'User asked how to fix slow API performance with 1000+ concurrent users, expected 70-80% reduction in database load' "
"NOT: 'User liked it' or 'To help user'"
"Include EVERYTHING: feelings, preferences, motivations, observations, context, background, significance. "
"BE VERBOSE - capture all the nuance and meaning. "
"FOR ASSISTANT FACTS: MUST include what the user asked/requested that led to this interaction! "
"Example (world): 'The user felt thrilled and inspired, has always dreamed of an outdoor ceremony, mentioned wanting a similar garden venue, was particularly moved by the intimate atmosphere and personal vows' "
"Example (assistant): 'User asked how to fix slow API performance with 1000+ concurrent users, expected 70-80% reduction in database load' "
"NOT: 'User liked it' or 'To help user'"
)
# ==========================================================================
@@ -152,17 +148,17 @@ class ExtractedFact(BaseModel):
fact_kind: str = Field(
default="conversation",
description="'event' = specific datable occurrence (set occurred dates), 'conversation' = general info (no occurred dates)",
description="'event' = specific datable occurrence (set occurred dates), 'conversation' = general info (no occurred dates)"
)
# Temporal fields - optional
occurred_start: str | None = Field(
occurred_start: Optional[str] = Field(
default=None,
description="WHEN the event happened (ISO timestamp). Only for fact_kind='event'. Leave null for conversations.",
description="WHEN the event happened (ISO timestamp). Only for fact_kind='event'. Leave null for conversations."
)
occurred_end: str | None = Field(
occurred_end: Optional[str] = Field(
default=None,
description="WHEN the event ended (ISO timestamp). Only for events with duration. Leave null for conversations.",
description="WHEN the event ended (ISO timestamp). Only for events with duration. Leave null for conversations."
)
# Classification (CRITICAL - required)
@@ -172,15 +168,16 @@ class ExtractedFact(BaseModel):
)
# Entities - extracted from fact content
entities: list[Entity] | None = Field(
entities: Optional[List[Entity]] = Field(
default=None,
description="Named entities, objects, AND abstract concepts from the fact. Include: people names, organizations, places, significant objects (e.g., 'coffee maker', 'car'), AND abstract concepts/themes (e.g., 'friendship', 'career growth', 'loss', 'celebration'). Extract anything that could help link related facts together.",
description="Named entities, objects, AND abstract concepts from the fact. Include: people names, organizations, places, significant objects (e.g., 'coffee maker', 'car'), AND abstract concepts/themes (e.g., 'friendship', 'career growth', 'loss', 'celebration'). Extract anything that could help link related facts together."
)
causal_relations: list[CausalRelation] | None = Field(
default=None, description="Causal links to other facts. Can be null."
causal_relations: Optional[List[CausalRelation]] = Field(
default=None,
description="Causal links to other facts. Can be null."
)
@field_validator("entities", mode="before")
@field_validator('entities', mode='before')
@classmethod
def ensure_entities_list(cls, v):
"""Ensure entities is always a list (convert None to empty list)."""
@@ -188,7 +185,7 @@ class ExtractedFact(BaseModel):
return []
return v
@field_validator("causal_relations", mode="before")
@field_validator('causal_relations', mode='before')
@classmethod
def ensure_causal_relations_list(cls, v):
"""Ensure causal_relations is always a list (convert None to empty list)."""
@@ -201,11 +198,11 @@ class ExtractedFact(BaseModel):
parts = [self.what]
# Add 'who' if not N/A
if self.who and self.who.upper() != "N/A":
if self.who and self.who.upper() != 'N/A':
parts.append(f"Involving: {self.who}")
# Add 'why' if not N/A
if self.why and self.why.upper() != "N/A":
if self.why and self.why.upper() != 'N/A':
parts.append(self.why)
if len(parts) == 1:
@@ -216,11 +213,12 @@ class ExtractedFact(BaseModel):
class FactExtractionResponse(BaseModel):
"""Response containing all extracted facts."""
facts: list[ExtractedFact] = Field(description="List of extracted factual statements")
facts: List[ExtractedFact] = Field(
description="List of extracted factual statements"
)
def chunk_text(text: str, max_chars: int) -> list[str]:
def chunk_text(text: str, max_chars: int) -> List[str]:
"""
Split text into chunks, preserving conversation structure when possible.
@@ -234,6 +232,7 @@ def chunk_text(text: str, max_chars: int) -> list[str]:
Returns:
List of text chunks, roughly under max_chars
"""
import json
from langchain_text_splitters import RecursiveCharacterTextSplitter
# If text is small enough, return as-is
@@ -257,21 +256,21 @@ def chunk_text(text: str, max_chars: int) -> list[str]:
is_separator_regex=False,
separators=[
"\n\n", # Paragraph breaks
"\n", # Line breaks
". ", # Sentence endings
"! ", # Exclamations
"? ", # Questions
"; ", # Semicolons
", ", # Commas
" ", # Words
"", # Characters (last resort)
"\n", # Line breaks
". ", # Sentence endings
"! ", # Exclamations
"? ", # Questions
"; ", # Semicolons
", ", # Commas
" ", # Words
"", # Characters (last resort)
],
)
return splitter.split_text(text)
def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
def _chunk_conversation(turns: List[dict], max_chars: int) -> List[str]:
"""
Chunk a conversation array at turn boundaries, preserving complete turns.
@@ -282,6 +281,7 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
Returns:
List of JSON-serialized chunks, each containing complete turns
"""
import json
chunks = []
current_chunk = []
@@ -315,10 +315,10 @@ async def _extract_facts_from_chunk(
total_chunks: int,
event_date: datetime,
context: str,
llm_config: "LLMConfig",
llm_config: 'LLMConfig',
agent_name: str = None,
extract_opinions: bool = False,
) -> list[dict[str, str]]:
extract_opinions: bool = False
) -> List[Dict[str, str]]:
"""
Extract facts from a single chunk (internal helper for parallel processing).
@@ -333,9 +333,7 @@ async def _extract_facts_from_chunk(
# Opinion extraction uses a separate prompt (not this one)
fact_types_instruction = "Extract ONLY 'opinion' type facts (formed opinions, beliefs, and perspectives). DO NOT extract 'world' or 'assistant' facts."
else:
fact_types_instruction = (
"Extract ONLY 'world' and 'assistant' type facts. DO NOT extract opinions - those are extracted separately."
)
fact_types_instruction = "Extract ONLY 'world' and 'assistant' type facts. DO NOT extract opinions - those are extracted separately."
prompt = f"""Extract facts from text into structured format with FOUR required dimensions - BE EXTREMELY DETAILED.
@@ -536,8 +534,10 @@ WHAT TO EXTRACT vs SKIP
✅ EXTRACT: User preferences (ALWAYS as separate facts!), feelings, plans, events, relationships, achievements
❌ SKIP: Greetings, filler ("thanks", "cool"), purely structural statements"""
import logging
import logging
from openai import BadRequestError
logger = logging.getLogger(__name__)
@@ -548,11 +548,11 @@ WHAT TO EXTRACT vs SKIP
# 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"
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
event_date_formatted = event_date.strftime("%A, %B %d, %Y") # e.g., "Monday, June 10, 2024"
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.
{memory_bank_context}
@@ -566,7 +566,16 @@ Text:
for attempt in range(max_retries):
try:
extraction_response_json = await llm_config.call(
messages=[{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
messages=[
{
"role": "system",
"content": prompt
},
{
"role": "user",
"content": user_message
}
],
response_format=FactExtractionResponse,
scope="memory_extract_facts",
temperature=0.1,
@@ -592,7 +601,7 @@ Text:
)
return []
raw_facts = extraction_response_json.get("facts", [])
raw_facts = extraction_response_json.get('facts', [])
if not raw_facts:
logger.debug(
f"LLM response missing 'facts' field or returned empty list. "
@@ -613,48 +622,48 @@ Text:
# Helper to get non-empty value
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":
if value and value != '' and value != [] and value != {} and str(value).upper() != 'N/A':
return value
return None
# NEW FORMAT: what, when, who, why (all required)
what = get_value("what")
when = get_value("when")
who = get_value("who")
why = get_value("why")
what = get_value('what')
when = get_value('when')
who = get_value('who')
why = get_value('why')
# Fallback to old format if new fields not present
if not what:
what = get_value("factual_core")
what = get_value('factual_core')
if not what:
logger.warning(f"Skipping fact {i}: missing 'what' field")
continue
# Critical field: fact_type
# LLM uses "assistant" but we convert to "experience" for storage
fact_type = llm_fact.get("fact_type")
fact_type = llm_fact.get('fact_type')
# Convert "assistant" → "experience" for storage
if fact_type == "assistant":
fact_type = "experience"
if fact_type == 'assistant':
fact_type = 'experience'
# Validate fact_type (after conversion)
if fact_type not in ["world", "experience", "opinion"]:
if fact_type not in ['world', 'experience', 'opinion']:
# Try to fix common mistakes - check if they swapped fact_type and fact_kind
fact_kind = llm_fact.get("fact_kind")
if fact_kind == "assistant":
fact_type = "experience"
elif fact_kind 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:
# Default to 'world' if we can't determine
fact_type = "world"
fact_type = 'world'
logger.warning(f"Fact {i}: defaulting to fact_type='world'")
# Get fact_kind for temporal handling (but don't store it)
fact_kind = llm_fact.get("fact_kind", "conversation")
if fact_kind not in ["conversation", "event", "other"]:
fact_kind = "conversation"
fact_kind = llm_fact.get('fact_kind', 'conversation')
if fact_kind not in ['conversation', 'event', 'other']:
fact_kind = 'conversation'
# Build combined fact text from the 4 dimensions: what | when | who | why
fact_data = {}
@@ -673,20 +682,20 @@ Text:
# Add temporal fields
# For events: occurred_start/occurred_end (when the event happened)
if fact_kind == "event":
occurred_start = get_value("occurred_start")
occurred_end = get_value("occurred_end")
if fact_kind == 'event':
occurred_start = get_value('occurred_start')
occurred_end = get_value('occurred_end')
if occurred_start:
fact_data["occurred_start"] = occurred_start
fact_data['occurred_start'] = occurred_start
# For point events: if occurred_end not set, default to occurred_start
if occurred_end:
fact_data["occurred_end"] = occurred_end
fact_data['occurred_end'] = occurred_end
else:
fact_data["occurred_end"] = occurred_start
fact_data['occurred_end'] = occurred_start
# Add entities if present (validate as Entity objects)
# LLM sometimes returns strings instead of {"text": "..."} format
entities = get_value("entities")
entities = get_value('entities')
if entities:
# Validate and normalize each entity
validated_entities = []
@@ -694,34 +703,38 @@ Text:
if isinstance(ent, str):
# Normalize string to Entity object
validated_entities.append(Entity(text=ent))
elif isinstance(ent, dict) and "text" in ent:
elif isinstance(ent, dict) and 'text' in ent:
try:
validated_entities.append(Entity.model_validate(ent))
except Exception as e:
logger.warning(f"Invalid entity {ent}: {e}")
if validated_entities:
fact_data["entities"] = validated_entities
fact_data['entities'] = validated_entities
# Add causal relations if present (validate as CausalRelation objects)
# Filter out invalid relations (missing required fields)
causal_relations = get_value("causal_relations")
causal_relations = get_value('causal_relations')
if causal_relations:
validated_relations = []
for rel in causal_relations:
if isinstance(rel, dict) and "target_fact_index" in rel and "relation_type" in rel:
if isinstance(rel, dict) and 'target_fact_index' in rel and 'relation_type' in rel:
try:
validated_relations.append(CausalRelation.model_validate(rel))
except Exception as e:
logger.warning(f"Invalid causal relation {rel}: {e}")
if validated_relations:
fact_data["causal_relations"] = validated_relations
fact_data['causal_relations'] = validated_relations
# Always set mentioned_at to the event_date (when the conversation/document occurred)
fact_data["mentioned_at"] = event_date.isoformat()
fact_data['mentioned_at'] = event_date.isoformat()
# Build Fact model instance
try:
fact = Fact(fact=combined_text, fact_type=fact_type, **fact_data)
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}")
@@ -740,9 +753,7 @@ Text:
except BadRequestError as e:
last_error = 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}"
)
logger.warning(f" [1.3.{chunk_index + 1}] Attempt {attempt + 1}/{max_retries} failed with JSON validation error: {e}")
if attempt < max_retries - 1:
logger.info(f" [1.3.{chunk_index + 1}] Retrying...")
continue
@@ -761,8 +772,8 @@ async def _extract_facts_with_auto_split(
context: str,
llm_config: LLMConfig,
agent_name: str = None,
extract_opinions: bool = False,
) -> list[dict[str, str]]:
extract_opinions: bool = False
) -> List[Dict[str, str]]:
"""
Extract facts from a chunk with automatic splitting if output exceeds token limits.
@@ -783,7 +794,6 @@ async def _extract_facts_with_auto_split(
List of fact dictionaries extracted from the chunk (possibly from sub-chunks)
"""
import logging
logger = logging.getLogger(__name__)
try:
@@ -796,9 +806,9 @@ async def _extract_facts_with_auto_split(
context=context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
extract_opinions=extract_opinions
)
except OutputTooLongError:
except OutputTooLongError as e:
# Output exceeded token limits - split the chunk in half and retry
logger.warning(
f"Output too long for chunk {chunk_index + 1}/{total_chunks} "
@@ -814,7 +824,7 @@ async def _extract_facts_with_auto_split(
search_start = max(0, mid_point - search_range)
search_end = min(len(chunk), mid_point + search_range)
sentence_endings = [". ", "! ", "? ", "\n\n"]
sentence_endings = ['. ', '! ', '? ', '\n\n']
best_split = mid_point
for ending in sentence_endings:
@@ -828,7 +838,8 @@ async def _extract_facts_with_auto_split(
second_half = chunk[best_split:].strip()
logger.info(
f"Split chunk {chunk_index + 1} into two sub-chunks: {len(first_half)} chars and {len(second_half)} chars"
f"Split chunk {chunk_index + 1} into two sub-chunks: "
f"{len(first_half)} chars and {len(second_half)} chars"
)
# Process both halves recursively (in parallel)
@@ -841,7 +852,7 @@ async def _extract_facts_with_auto_split(
context=context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
extract_opinions=extract_opinions
),
_extract_facts_with_auto_split(
chunk=second_half,
@@ -851,8 +862,8 @@ async def _extract_facts_with_auto_split(
context=context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
),
extract_opinions=extract_opinions
)
]
sub_results = await asyncio.gather(*sub_tasks)
@@ -862,7 +873,9 @@ async def _extract_facts_with_auto_split(
for sub_result in sub_results:
all_facts.extend(sub_result)
logger.info(f"Successfully extracted {len(all_facts)} facts from split chunk {chunk_index + 1}")
logger.info(
f"Successfully extracted {len(all_facts)} facts from split chunk {chunk_index + 1}"
)
return all_facts
@@ -874,7 +887,7 @@ async def extract_facts_from_text(
agent_name: str,
context: str = "",
extract_opinions: bool = False,
) -> tuple[list[Fact], list[tuple[str, int]]]:
) -> tuple[List[Fact], List[tuple[str, int]]]:
"""
Extract semantic facts from conversational or narrative text using LLM.
@@ -907,7 +920,7 @@ async def extract_facts_from_text(
context=context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
extract_opinions=extract_opinions
)
for i, chunk in enumerate(chunks)
]
@@ -925,10 +938,8 @@ async def extract_facts_from_text(
# ============================================================================
# Import types for the orchestration layer (note: ExtractedFact here is different from the Pydantic model above)
from .types import CausalRelation as CausalRelationType
from .types import ChunkMetadata, RetainContent
from .types import ExtractedFact as ExtractedFactType
from .types import RetainContent, ExtractedFact as ExtractedFactType, ChunkMetadata, CausalRelation as CausalRelationType
from typing import Tuple
logger = logging.getLogger(__name__)
@@ -937,8 +948,11 @@ SECONDS_PER_FACT = 10
async def extract_facts_from_contents(
contents: list[RetainContent], llm_config, agent_name: str, extract_opinions: bool = False
) -> tuple[list[ExtractedFactType], list[ChunkMetadata]]:
contents: List[RetainContent],
llm_config,
agent_name: str,
extract_opinions: bool = False
) -> Tuple[List[ExtractedFactType], List[ChunkMetadata]]:
"""
Extract facts from multiple content items in parallel.
@@ -971,7 +985,7 @@ async def extract_facts_from_contents(
context=item.context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
extract_opinions=extract_opinions
)
fact_extraction_tasks.append(task)
@@ -979,8 +993,8 @@ async def extract_facts_from_contents(
all_fact_results = await asyncio.gather(*fact_extraction_tasks)
# Step 3: Flatten and convert to typed objects
extracted_facts: list[ExtractedFactType] = []
chunks_metadata: list[ChunkMetadata] = []
extracted_facts: List[ExtractedFactType] = []
chunks_metadata: List[ChunkMetadata] = []
global_chunk_idx = 0
global_fact_idx = 0
@@ -994,7 +1008,7 @@ async def extract_facts_from_contents(
chunk_text=chunk_text,
fact_count=chunk_fact_count,
content_index=content_index,
chunk_index=global_chunk_idx,
chunk_index=global_chunk_idx
)
chunks_metadata.append(chunk_metadata)
global_chunk_idx += 1
@@ -1015,21 +1029,18 @@ async def extract_facts_from_contents(
fact_type=fact_from_llm.fact_type,
entities=[e.text for e in (fact_from_llm.entities or [])],
# occurred_start/end: from LLM only, leave None if not provided
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,
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
fact_from_llm.causal_relations or [],
global_fact_idx
),
content_index=content_index,
chunk_index=chunk_global_idx,
context=content.context,
# mentioned_at: always the event_date (when the conversation/document occurred)
mentioned_at=content.event_date,
metadata=content.metadata,
metadata=content.metadata
)
extracted_facts.append(extracted_fact)
@@ -1045,14 +1056,13 @@ async def extract_facts_from_contents(
def _parse_datetime(date_str: str):
"""Parse ISO datetime string."""
from dateutil import parser as date_parser
try:
return date_parser.isoparse(date_str)
except Exception:
return None
def _convert_causal_relations(relations_from_llm, fact_start_idx: int) -> list[CausalRelationType]:
def _convert_causal_relations(relations_from_llm, fact_start_idx: int) -> List[CausalRelationType]:
"""
Convert causal relations from LLM format to ExtractedFact format.
@@ -1063,13 +1073,13 @@ def _convert_causal_relations(relations_from_llm, fact_start_idx: int) -> list[C
causal_relation = CausalRelationType(
relation_type=rel.relation_type,
target_fact_index=fact_start_idx + rel.target_fact_index,
strength=rel.strength,
strength=rel.strength
)
causal_relations.append(causal_relation)
return causal_relations
def _add_temporal_offsets(facts: list[ExtractedFactType], contents: list[RetainContent]) -> None:
def _add_temporal_offsets(facts: List[ExtractedFactType], contents: List[RetainContent]) -> None:
"""
Add time offsets to preserve fact ordering within each content.
@@ -3,19 +3,22 @@ Fact storage for retain pipeline.
Handles insertion of facts into the database.
"""
import json
import logging
import json
from typing import List, Optional
from uuid import UUID
from ..memory_engine import fq_table
from .types import ProcessedFact
logger = logging.getLogger(__name__)
async def insert_facts_batch(
conn, bank_id: str, facts: list[ProcessedFact], document_id: str | None = None
) -> list[str]:
conn,
bank_id: str,
facts: List[ProcessedFact],
document_id: Optional[str] = None
) -> List[str]:
"""
Insert facts into the database in batch.
@@ -59,7 +62,7 @@ async def insert_facts_batch(
contexts.append(fact.context)
fact_types.append(fact.fact_type)
# confidence_score is only for opinion facts
confidence_scores.append(1.0 if fact.fact_type == "opinion" else None)
confidence_scores.append(1.0 if fact.fact_type == 'opinion' else None)
access_counts.append(0) # Initial access count
metadata_jsons.append(json.dumps(fact.metadata))
chunk_ids.append(fact.chunk_id)
@@ -68,8 +71,8 @@ async def insert_facts_batch(
# Batch insert all facts
results = await conn.fetch(
f"""
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
"""
INSERT INTO memory_units (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id)
SELECT $1, * FROM unnest(
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
@@ -90,10 +93,10 @@ async def insert_facts_batch(
access_counts,
metadata_jsons,
chunk_ids,
document_ids,
document_ids
)
unit_ids = [str(row["id"]) for row in results]
unit_ids = [str(row['id']) for row in results]
return unit_ids
@@ -108,20 +111,25 @@ async def ensure_bank_exists(conn, bank_id: str) -> None:
bank_id: Bank identifier
"""
await conn.execute(
f"""
INSERT INTO {fq_table("banks")} (bank_id, disposition, background)
"""
INSERT INTO banks (bank_id, disposition, background)
VALUES ($1, $2::jsonb, $3)
ON CONFLICT (bank_id) DO UPDATE
SET updated_at = NOW()
""",
bank_id,
'{"skepticism": 3, "literalism": 3, "empathy": 3}',
"",
""
)
async def handle_document_tracking(
conn, bank_id: str, document_id: str, combined_content: str, is_first_batch: bool, retain_params: dict | None = None
conn,
bank_id: str,
document_id: str,
combined_content: str,
is_first_batch: bool,
retain_params: Optional[dict] = None
) -> None:
"""
Handle document tracking in the database.
@@ -143,13 +151,14 @@ async def handle_document_tracking(
# Only delete on the first batch to avoid deleting data we just inserted
if is_first_batch:
await conn.fetchval(
f"DELETE FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 RETURNING id", document_id, bank_id
"DELETE FROM documents WHERE id = $1 AND bank_id = $2 RETURNING id",
document_id, bank_id
)
# Insert document (or update if exists from concurrent operations)
await conn.execute(
f"""
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, metadata, retain_params)
"""
INSERT INTO documents (id, bank_id, original_text, content_hash, metadata, retain_params)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id, bank_id) DO UPDATE
SET original_text = EXCLUDED.original_text,
@@ -163,5 +172,5 @@ async def handle_document_tracking(
combined_content,
content_hash,
json.dumps({}), # Empty metadata dict
json.dumps(retain_params) if retain_params else None,
json.dumps(retain_params) if retain_params else None
)
@@ -3,16 +3,20 @@ Link creation for retain pipeline.
Handles creation of temporal, semantic, and causal links between facts.
"""
import logging
from typing import List
from .types import ProcessedFact, CausalRelation
from . import link_utils
from .types import ProcessedFact
logger = logging.getLogger(__name__)
async def create_temporal_links_batch(conn, bank_id: str, unit_ids: list[str]) -> int:
async def create_temporal_links_batch(
conn,
bank_id: str,
unit_ids: List[str]
) -> int:
"""
Create temporal links between facts.
@@ -29,10 +33,20 @@ async def create_temporal_links_batch(conn, bank_id: str, unit_ids: list[str]) -
if not unit_ids:
return 0
return await link_utils.create_temporal_links_batch_per_fact(conn, bank_id, unit_ids, log_buffer=[])
return await link_utils.create_temporal_links_batch_per_fact(
conn,
bank_id,
unit_ids,
log_buffer=[]
)
async def create_semantic_links_batch(conn, bank_id: str, unit_ids: list[str], embeddings: list[list[float]]) -> int:
async def create_semantic_links_batch(
conn,
bank_id: str,
unit_ids: List[str],
embeddings: List[List[float]]
) -> int:
"""
Create semantic links between facts.
@@ -53,10 +67,20 @@ async def create_semantic_links_batch(conn, bank_id: str, unit_ids: list[str], e
if len(unit_ids) != len(embeddings):
raise ValueError(f"Mismatch between unit_ids ({len(unit_ids)}) and embeddings ({len(embeddings)})")
return await link_utils.create_semantic_links_batch(conn, bank_id, unit_ids, embeddings, log_buffer=[])
return await link_utils.create_semantic_links_batch(
conn,
bank_id,
unit_ids,
embeddings,
log_buffer=[]
)
async def create_causal_links_batch(conn, unit_ids: list[str], facts: list[ProcessedFact]) -> int:
async def create_causal_links_batch(
conn,
unit_ids: List[str],
facts: List[ProcessedFact]
) -> int:
"""
Create causal links between facts.
@@ -84,9 +108,9 @@ async def create_causal_links_batch(conn, unit_ids: list[str], facts: list[Proce
# Convert CausalRelation objects to dicts
relations_dicts = [
{
"relation_type": rel.relation_type,
"target_fact_index": rel.target_fact_index,
"strength": rel.strength,
'relation_type': rel.relation_type,
'target_fact_index': rel.target_fact_index,
'strength': rel.strength
}
for rel in fact.causal_relations
]
@@ -94,6 +118,10 @@ async def create_causal_links_batch(conn, unit_ids: list[str], facts: list[Proce
else:
causal_relations_per_fact.append([])
link_count = await link_utils.create_causal_links_batch(conn, unit_ids, causal_relations_per_fact)
link_count = await link_utils.create_causal_links_batch(
conn,
unit_ids,
causal_relations_per_fact
)
return link_count
@@ -2,12 +2,12 @@
Link creation utilities for temporal, semantic, and entity links.
"""
import logging
import time
from datetime import UTC, datetime, timedelta
import logging
from typing import List
from datetime import timedelta, datetime, timezone
from uuid import UUID
from ..memory_engine import fq_table
from .types import EntityLink
logger = logging.getLogger(__name__)
@@ -19,7 +19,7 @@ def _normalize_datetime(dt):
return None
if dt.tzinfo is None:
# Naive datetime - assume UTC
return dt.replace(tzinfo=UTC)
return dt.replace(tzinfo=timezone.utc)
return dt
@@ -54,26 +54,24 @@ def compute_temporal_links(
try:
time_lower = unit_event_date_norm - timedelta(hours=time_window_hours)
except OverflowError:
time_lower = datetime.min.replace(tzinfo=UTC)
time_lower = datetime.min.replace(tzinfo=timezone.utc)
try:
time_upper = unit_event_date_norm + timedelta(hours=time_window_hours)
except OverflowError:
time_upper = datetime.max.replace(tzinfo=UTC)
time_upper = datetime.max.replace(tzinfo=timezone.utc)
# Filter candidates within this unit's time window
matching_neighbors = [
(row["id"], row["event_date"])
(row['id'], row['event_date'])
for row in candidates
if time_lower <= _normalize_datetime(row["event_date"]) <= time_upper
if time_lower <= _normalize_datetime(row['event_date']) <= time_upper
][:10] # Limit to top 10
for recent_id, recent_event_date in matching_neighbors:
# Calculate temporal proximity weight
time_diff_hours = abs(
(unit_event_date_norm - _normalize_datetime(recent_event_date)).total_seconds() / 3600
)
time_diff_hours = abs((unit_event_date_norm - _normalize_datetime(recent_event_date)).total_seconds() / 3600)
weight = max(0.3, 1.0 - (time_diff_hours / time_window_hours))
links.append((unit_id, str(recent_id), "temporal", weight, None))
links.append((unit_id, str(recent_id), 'temporal', weight, None))
return links
@@ -101,17 +99,17 @@ def compute_temporal_query_bounds(
try:
min_date = min(all_dates) - timedelta(hours=time_window_hours)
except OverflowError:
min_date = datetime.min.replace(tzinfo=UTC)
min_date = datetime.min.replace(tzinfo=timezone.utc)
try:
max_date = max(all_dates) + timedelta(hours=time_window_hours)
except OverflowError:
max_date = datetime.max.replace(tzinfo=UTC)
max_date = datetime.max.replace(tzinfo=timezone.utc)
return min_date, max_date
def _log(log_buffer, message, level="info"):
def _log(log_buffer, message, level='info'):
"""Helper to log to buffer if available, otherwise use logger.
Args:
@@ -119,7 +117,7 @@ def _log(log_buffer, message, level="info"):
message: The log message
level: 'info', 'debug', 'warning', or 'error'. Debug messages are not added to buffer.
"""
if level == "debug":
if level == 'debug':
# Debug messages only go to logger, not to buffer
logger.debug(message)
return
@@ -127,23 +125,23 @@ def _log(log_buffer, message, level="info"):
if log_buffer is not None:
log_buffer.append(message)
else:
if level == "info":
if level == 'info':
logger.info(message)
else:
logger.log(logging.WARNING if level == "warning" else logging.ERROR, message)
logger.log(logging.WARNING if level == 'warning' else logging.ERROR, message)
async def extract_entities_batch_optimized(
entity_resolver,
conn,
bank_id: str,
unit_ids: list[str],
sentences: list[str],
unit_ids: List[str],
sentences: List[str],
context: str,
fact_dates: list,
llm_entities: list[list[dict]],
log_buffer: list[str] = None,
) -> list[tuple]:
fact_dates: List,
llm_entities: List[List[dict]],
log_buffer: List[str] = None,
) -> List[tuple]:
"""
Process LLM-extracted entities for ALL facts in batch.
@@ -173,19 +171,15 @@ async def extract_entities_batch_optimized(
formatted_entities = []
for ent in entity_list:
# Handle both Entity objects and dicts
if hasattr(ent, "text"):
if hasattr(ent, 'text'):
# Entity objects only have 'text', default type to 'CONCEPT'
formatted_entities.append({"text": ent.text, "type": "CONCEPT"})
formatted_entities.append({'text': ent.text, 'type': 'CONCEPT'})
elif isinstance(ent, dict):
formatted_entities.append({"text": ent.get("text", ""), "type": ent.get("type", "CONCEPT")})
formatted_entities.append({'text': ent.get('text', ''), 'type': ent.get('type', 'CONCEPT')})
all_entities.append(formatted_entities)
total_entities = sum(len(ents) for ents in all_entities)
_log(
log_buffer,
f" [6.1] Process LLM entities: {total_entities} entities from {len(sentences)} facts in {time.time() - substep_start:.3f}s",
level="debug",
)
_log(log_buffer, f" [6.1] Process LLM entities: {total_entities} entities from {len(sentences)} facts in {time.time() - substep_start:.3f}s", level='debug')
# Step 2: Resolve entities in BATCH (much faster!)
substep_start = time.time()
@@ -201,19 +195,13 @@ async def extract_entities_batch_optimized(
continue
for local_idx, entity in enumerate(entities):
all_entities_flat.append(
{
"text": entity["text"],
"type": entity["type"],
"nearby_entities": entities,
}
)
all_entities_flat.append({
'text': entity['text'],
'type': entity['type'],
'nearby_entities': entities,
})
entity_to_unit.append((unit_id, local_idx, fact_date))
_log(
log_buffer,
f" [6.2.1] Prepare entities: {len(all_entities_flat)} entities in {time.time() - substep_6_2_1_start:.3f}s",
level="debug",
)
_log(log_buffer, f" [6.2.1] Prepare entities: {len(all_entities_flat)} entities in {time.time() - substep_6_2_1_start:.3f}s", level='debug')
# Resolve ALL entities in one batch call
if all_entities_flat:
@@ -222,7 +210,7 @@ async def extract_entities_batch_optimized(
# Add per-entity dates to entity data for batch resolution
for idx, (unit_id, local_idx, fact_date) in enumerate(entity_to_unit):
all_entities_flat[idx]["event_date"] = fact_date
all_entities_flat[idx]['event_date'] = fact_date
# Resolve ALL entities in ONE batch call (much faster than sequential buckets)
# INSERT ... ON CONFLICT handles any race conditions at the DB level
@@ -231,14 +219,10 @@ async def extract_entities_batch_optimized(
entities_data=all_entities_flat,
context=context,
unit_event_date=None, # Not used when per-entity dates provided
conn=conn, # Use main transaction connection
conn=conn # Use main transaction connection
)
_log(
log_buffer,
f" [6.2.2] Resolve entities: {len(all_entities_flat)} entities in single batch in {time.time() - substep_6_2_2_start:.3f}s",
level="debug",
)
_log(log_buffer, f" [6.2.2] Resolve entities: {len(all_entities_flat)} entities in single batch in {time.time() - substep_6_2_2_start:.3f}s", level='debug')
# [6.2.3] Create unit-entity links in BATCH
substep_6_2_3_start = time.time()
@@ -255,24 +239,12 @@ async def extract_entities_batch_optimized(
# Batch insert all unit-entity links (MUCH faster!)
await entity_resolver.link_units_to_entities_batch(unit_entity_pairs, conn=conn)
_log(
log_buffer,
f" [6.2.3] Create unit-entity links (batched): {len(unit_entity_pairs)} links in {time.time() - substep_6_2_3_start:.3f}s",
level="debug",
)
_log(log_buffer, f" [6.2.3] Create unit-entity links (batched): {len(unit_entity_pairs)} links in {time.time() - substep_6_2_3_start:.3f}s", level='debug')
_log(
log_buffer,
f" [6.2] Entity resolution (batched): {len(all_entities_flat)} entities resolved in {time.time() - step_6_2_start:.3f}s",
level="debug",
)
_log(log_buffer, f" [6.2] Entity resolution (batched): {len(all_entities_flat)} entities resolved in {time.time() - step_6_2_start:.3f}s", level='debug')
else:
unit_to_entity_ids = {}
_log(
log_buffer,
f" [6.2] Entity resolution (batched): 0 entities in {time.time() - step_6_2_start:.3f}s",
level="debug",
)
_log(log_buffer, f" [6.2] Entity resolution (batched): 0 entities in {time.time() - step_6_2_start:.3f}s", level='debug')
# Step 3: Create entity links between units that share entities
substep_start = time.time()
@@ -281,44 +253,39 @@ async def extract_entities_batch_optimized(
for entity_ids in unit_to_entity_ids.values():
all_entity_ids.update(entity_ids)
_log(log_buffer, f" [6.3] Creating entity links for {len(all_entity_ids)} unique entities...", level="debug")
_log(log_buffer, f" [6.3] Creating entity links for {len(all_entity_ids)} unique entities...", level='debug')
# Find all units that reference these entities (ONE batched query)
entity_to_units = {}
if all_entity_ids:
query_start = time.time()
import uuid
entity_id_list = [uuid.UUID(eid) if isinstance(eid, str) else eid for eid in all_entity_ids]
rows = await conn.fetch(
f"""
"""
SELECT entity_id, unit_id
FROM {fq_table("unit_entities")}
FROM unit_entities
WHERE entity_id = ANY($1::uuid[])
""",
entity_id_list,
)
_log(
log_buffer,
f" [6.3.1] Query unit_entities: {len(rows)} rows in {time.time() - query_start:.3f}s",
level="debug",
entity_id_list
)
_log(log_buffer, f" [6.3.1] Query unit_entities: {len(rows)} rows in {time.time() - query_start:.3f}s", level='debug')
# Group by entity_id
group_start = time.time()
for row in rows:
entity_id = row["entity_id"]
entity_id = row['entity_id']
if entity_id not in entity_to_units:
entity_to_units[entity_id] = []
entity_to_units[entity_id].append(row["unit_id"])
_log(log_buffer, f" [6.3.2] Group by entity_id: {time.time() - group_start:.3f}s", level="debug")
entity_to_units[entity_id].append(row['unit_id'])
_log(log_buffer, f" [6.3.2] Group by entity_id: {time.time() - group_start:.3f}s", level='debug')
# Create bidirectional links between units that share entities
# OPTIMIZATION: Limit links per entity to avoid N² explosion
# Only link each new unit to the most recent MAX_LINKS_PER_ENTITY units
MAX_LINKS_PER_ENTITY = 50 # Limit to prevent explosion when entity appears in many facts
link_gen_start = time.time()
links: list[EntityLink] = []
links: List[EntityLink] = []
new_unit_set = set(unit_ids) # Units from this batch
def to_uuid(val) -> UUID:
@@ -332,52 +299,27 @@ async def extract_entities_batch_optimized(
# Link new units to each other (within batch) - also limited
# For very common entities, limit within-batch links too
new_units_to_link = (
new_units[-MAX_LINKS_PER_ENTITY:] if len(new_units) > MAX_LINKS_PER_ENTITY else new_units
)
new_units_to_link = new_units[-MAX_LINKS_PER_ENTITY:] if len(new_units) > MAX_LINKS_PER_ENTITY else new_units
for i, unit_id_1 in enumerate(new_units_to_link):
for unit_id_2 in new_units_to_link[i + 1 :]:
links.append(
EntityLink(
from_unit_id=to_uuid(unit_id_1), to_unit_id=to_uuid(unit_id_2), entity_id=entity_uuid
)
)
links.append(
EntityLink(
from_unit_id=to_uuid(unit_id_2), to_unit_id=to_uuid(unit_id_1), entity_id=entity_uuid
)
)
for unit_id_2 in new_units_to_link[i+1:]:
links.append(EntityLink(from_unit_id=to_uuid(unit_id_1), to_unit_id=to_uuid(unit_id_2), entity_id=entity_uuid))
links.append(EntityLink(from_unit_id=to_uuid(unit_id_2), to_unit_id=to_uuid(unit_id_1), entity_id=entity_uuid))
# Link new units to LIMITED existing units (most recent)
existing_to_link = existing_units[-MAX_LINKS_PER_ENTITY:] # Take most recent
for new_unit in new_units:
for existing_unit in existing_to_link:
links.append(
EntityLink(
from_unit_id=to_uuid(new_unit), to_unit_id=to_uuid(existing_unit), entity_id=entity_uuid
)
)
links.append(
EntityLink(
from_unit_id=to_uuid(existing_unit), to_unit_id=to_uuid(new_unit), entity_id=entity_uuid
)
)
links.append(EntityLink(from_unit_id=to_uuid(new_unit), to_unit_id=to_uuid(existing_unit), entity_id=entity_uuid))
links.append(EntityLink(from_unit_id=to_uuid(existing_unit), to_unit_id=to_uuid(new_unit), entity_id=entity_uuid))
_log(
log_buffer, f" [6.3.3] Generate {len(links)} links: {time.time() - link_gen_start:.3f}s", level="debug"
)
_log(
log_buffer,
f" [6.3] Entity link creation: {len(links)} links for {len(all_entity_ids)} unique entities in {time.time() - substep_start:.3f}s",
level="debug",
)
_log(log_buffer, f" [6.3.3] Generate {len(links)} links: {time.time() - link_gen_start:.3f}s", level='debug')
_log(log_buffer, f" [6.3] Entity link creation: {len(links)} links for {len(all_entity_ids)} unique entities in {time.time() - substep_start:.3f}s", level='debug')
return links
except Exception as e:
logger.error(f"Failed to extract entities in batch: {str(e)}")
import traceback
traceback.print_exc()
raise
@@ -385,9 +327,9 @@ async def extract_entities_batch_optimized(
async def create_temporal_links_batch_per_fact(
conn,
bank_id: str,
unit_ids: list[str],
unit_ids: List[str],
time_window_hours: int = 24,
log_buffer: list[str] = None,
log_buffer: List[str] = None,
) -> int:
"""
Create temporal links for multiple units, each with their own event_date.
@@ -414,18 +356,15 @@ async def create_temporal_links_batch_per_fact(
# Get the event_date for each new unit
fetch_dates_start = time_mod.time()
rows = await conn.fetch(
f"""
"""
SELECT id, event_date
FROM {fq_table("memory_units")}
FROM memory_units
WHERE id::text = ANY($1)
""",
unit_ids,
)
new_units = {str(row["id"]): row["event_date"] for row in rows}
_log(
log_buffer,
f" [7.1] Fetch event_dates for {len(unit_ids)} units: {time_mod.time() - fetch_dates_start:.3f}s",
unit_ids
)
new_units = {str(row['id']): row['event_date'] for row in rows}
_log(log_buffer, f" [7.1] Fetch event_dates for {len(unit_ids)} units: {time_mod.time() - fetch_dates_start:.3f}s")
# Fetch ALL potential temporal neighbors in ONE query (much faster!)
# Get time range across all units with overflow protection
@@ -433,9 +372,9 @@ async def create_temporal_links_batch_per_fact(
fetch_neighbors_start = time_mod.time()
all_candidates = await conn.fetch(
f"""
"""
SELECT id, event_date
FROM {fq_table("memory_units")}
FROM memory_units
WHERE bank_id = $1
AND event_date BETWEEN $2 AND $3
AND id::text != ALL($4)
@@ -444,12 +383,9 @@ async def create_temporal_links_batch_per_fact(
bank_id,
min_date,
max_date,
unit_ids,
)
_log(
log_buffer,
f" [7.2] Fetch {len(all_candidates)} candidate neighbors (1 query): {time_mod.time() - fetch_neighbors_start:.3f}s",
unit_ids
)
_log(log_buffer, f" [7.2] Fetch {len(all_candidates)} candidate neighbors (1 query): {time_mod.time() - fetch_neighbors_start:.3f}s")
# Filter and create links in memory (much faster than N queries)
link_gen_start = time_mod.time()
@@ -472,20 +408,20 @@ async def create_temporal_links_batch_per_fact(
if time_diff_hours <= time_window_hours:
weight = max(0.3, 1.0 - (time_diff_hours / time_window_hours))
# Create bidirectional links
links.append((unit_id, other_id, "temporal", weight, None))
links.append((other_id, unit_id, "temporal", weight, None))
links.append((unit_id, other_id, 'temporal', weight, None))
links.append((other_id, unit_id, 'temporal', weight, None))
_log(log_buffer, f" [7.3] Generate {len(links)} temporal links: {time_mod.time() - link_gen_start:.3f}s")
if links:
insert_start = time_mod.time()
await conn.executemany(
f"""
INSERT INTO {fq_table("memory_links")} (from_unit_id, to_unit_id, link_type, weight, entity_id)
"""
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
""",
links,
links
)
_log(log_buffer, f" [7.4] Insert {len(links)} temporal links: {time_mod.time() - insert_start:.3f}s")
@@ -494,7 +430,6 @@ async def create_temporal_links_batch_per_fact(
except Exception as e:
logger.error(f"Failed to create temporal links: {str(e)}")
import traceback
traceback.print_exc()
raise
@@ -502,11 +437,11 @@ async def create_temporal_links_batch_per_fact(
async def create_semantic_links_batch(
conn,
bank_id: str,
unit_ids: list[str],
embeddings: list[list[float]],
unit_ids: List[str],
embeddings: List[List[float]],
top_k: int = 5,
threshold: float = 0.7,
log_buffer: list[str] = None,
log_buffer: List[str] = None,
) -> int:
"""
Create semantic links for multiple units efficiently.
@@ -530,26 +465,22 @@ async def create_semantic_links_batch(
try:
import time as time_mod
import numpy as np
# Fetch ALL existing units with embeddings in ONE query
fetch_start = time_mod.time()
all_existing = await conn.fetch(
f"""
"""
SELECT id, embedding
FROM {fq_table("memory_units")}
FROM memory_units
WHERE bank_id = $1
AND embedding IS NOT NULL
AND id::text != ALL($2)
""",
bank_id,
unit_ids,
)
_log(
log_buffer,
f" [8.1] Fetch {len(all_existing)} existing embeddings (1 query): {time_mod.time() - fetch_start:.3f}s",
unit_ids
)
_log(log_buffer, f" [8.1] Fetch {len(all_existing)} existing embeddings (1 query): {time_mod.time() - fetch_start:.3f}s")
# Convert to numpy for vectorized similarity computation
compute_start = time_mod.time()
@@ -557,16 +488,15 @@ async def create_semantic_links_batch(
if all_existing:
# Convert existing embeddings to numpy array
existing_ids = [str(row["id"]) for row in all_existing]
existing_ids = [str(row['id']) for row in all_existing]
# Stack embeddings as 2D array: (num_embeddings, embedding_dim)
embedding_arrays = []
for row in all_existing:
raw_emb = row["embedding"]
raw_emb = row['embedding']
# Handle different pgvector formats
if isinstance(raw_emb, str):
# Parse string format: "[1.0, 2.0, ...]"
import json
emb = np.array(json.loads(raw_emb), dtype=np.float32)
elif isinstance(raw_emb, (list, tuple)):
emb = np.array(raw_emb, dtype=np.float32)
@@ -607,7 +537,7 @@ async def create_semantic_links_batch(
similar_id = existing_ids[idx]
# Clamp to [0, 1] to handle floating point precision issues
similarity = float(min(1.0, max(0.0, similarities[idx])))
all_links.append((unit_id, similar_id, "semantic", similarity, None))
all_links.append((unit_id, similar_id, 'semantic', similarity, None))
# Also compute similarities WITHIN the new batch (new units to each other)
# Apply the same top_k limit per unit as we do for existing units
@@ -635,38 +565,32 @@ async def create_semantic_links_batch(
other_id = unit_ids[other_idx]
# Clamp to [0, 1] to handle floating point precision issues
similarity = float(min(1.0, max(0.0, similarities[local_idx])))
all_links.append((unit_id, other_id, "semantic", similarity, None))
all_links.append((unit_id, other_id, 'semantic', similarity, None))
_log(
log_buffer,
f" [8.2] Compute similarities & generate {len(all_links)} semantic links: {time_mod.time() - compute_start:.3f}s",
)
_log(log_buffer, f" [8.2] Compute similarities & generate {len(all_links)} semantic links: {time_mod.time() - compute_start:.3f}s")
if all_links:
insert_start = time_mod.time()
await conn.executemany(
f"""
INSERT INTO {fq_table("memory_links")} (from_unit_id, to_unit_id, link_type, weight, entity_id)
"""
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
""",
all_links,
)
_log(
log_buffer, f" [8.3] Insert {len(all_links)} semantic links: {time_mod.time() - insert_start:.3f}s"
all_links
)
_log(log_buffer, f" [8.3] Insert {len(all_links)} semantic links: {time_mod.time() - insert_start:.3f}s")
return len(all_links)
except Exception as e:
logger.error(f"Failed to create semantic links: {str(e)}")
import traceback
traceback.print_exc()
raise
async def insert_entity_links_batch(conn, links: list[EntityLink], chunk_size: int = 50000):
async def insert_entity_links_batch(conn, links: List[EntityLink], chunk_size: int = 50000):
"""
Insert all entity links using COPY to temp table + INSERT for maximum speed.
@@ -682,6 +606,7 @@ async def insert_entity_links_batch(conn, links: list[EntityLink], chunk_size: i
if not links:
return
import uuid as uuid_mod
import time as time_mod
total_start = time_mod.time()
@@ -708,22 +633,28 @@ async def insert_entity_links_batch(conn, links: list[EntityLink], chunk_size: i
convert_start = time_mod.time()
records = []
for link in links:
records.append((link.from_unit_id, link.to_unit_id, link.link_type, link.weight, link.entity_id))
records.append((
link.from_unit_id,
link.to_unit_id,
link.link_type,
link.weight,
link.entity_id
))
logger.debug(f" [9.3] Convert {len(records)} records: {time_mod.time() - convert_start:.3f}s")
# Bulk load using COPY (fastest method)
copy_start = time_mod.time()
await conn.copy_records_to_table(
"_temp_entity_links",
'_temp_entity_links',
records=records,
columns=["from_unit_id", "to_unit_id", "link_type", "weight", "entity_id"],
columns=['from_unit_id', 'to_unit_id', 'link_type', 'weight', 'entity_id']
)
logger.debug(f" [9.4] COPY {len(records)} records to temp table: {time_mod.time() - copy_start:.3f}s")
# Insert from temp table with ON CONFLICT (single query for all rows)
insert_start = time_mod.time()
await conn.execute(f"""
INSERT INTO {fq_table("memory_links")} (from_unit_id, to_unit_id, link_type, weight, entity_id)
await conn.execute("""
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
SELECT from_unit_id, to_unit_id, link_type, weight, entity_id
FROM _temp_entity_links
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
@@ -734,8 +665,8 @@ async def insert_entity_links_batch(conn, links: list[EntityLink], chunk_size: i
async def create_causal_links_batch(
conn,
unit_ids: list[str],
causal_relations_per_fact: list[list[dict]],
unit_ids: List[str],
causal_relations_per_fact: List[List[dict]],
) -> int:
"""
Create causal links between facts based on LLM-extracted causal relationships.
@@ -763,7 +694,6 @@ async def create_causal_links_batch(
try:
import time as time_mod
create_start = time_mod.time()
# Build links list
@@ -775,12 +705,12 @@ async def create_causal_links_batch(
from_unit_id = unit_ids[fact_idx]
for relation in causal_relations:
target_idx = relation["target_fact_index"]
relation_type = relation["relation_type"]
strength = relation.get("strength", 1.0)
target_idx = relation['target_fact_index']
relation_type = relation['relation_type']
strength = relation.get('strength', 1.0)
# Validate relation_type - must match database constraint
valid_types = {"causes", "caused_by", "enables", "prevents"}
valid_types = {'causes', 'caused_by', 'enables', 'prevents'}
if relation_type not in valid_types:
logger.error(
f"Invalid relation_type '{relation_type}' (type: {type(relation_type).__name__}) "
@@ -805,25 +735,24 @@ async def create_causal_links_batch(
# weight is the strength of the relationship
links.append((from_unit_id, to_unit_id, relation_type, strength, None))
if links:
insert_start = time_mod.time()
try:
await conn.executemany(
f"""
INSERT INTO {fq_table("memory_links")} (from_unit_id, to_unit_id, link_type, weight, entity_id)
"""
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
""",
links,
links
)
except Exception as db_error:
# Log the actual data being inserted for debugging
logger.error(f"Database insert failed for causal links. Error: {db_error}")
logger.error(f"Attempted to insert {len(links)} links. First few:")
for i, link in enumerate(links[:3]):
logger.error(
f" Link {i}: from={link[0]}, to={link[1]}, type='{link[2]}' (repr={repr(link[2])}), weight={link[3]}, entity={link[4]}"
)
logger.error(f" Link {i}: from={link[0]}, to={link[1]}, type='{link[2]}' (repr={repr(link[2])}), weight={link[3]}, entity={link[4]}")
raise
return len(links)
@@ -831,6 +760,5 @@ async def create_causal_links_batch(
except Exception as e:
logger.error(f"Failed to create causal links: {str(e)}")
import traceback
traceback.print_exc()
raise
@@ -3,15 +3,15 @@ Observation regeneration for retain pipeline.
Regenerates entity observations as part of the retain transaction.
"""
import logging
import time
import uuid
from datetime import UTC, datetime
from datetime import datetime, timezone
from typing import List, Dict, Optional
from ..memory_engine import fq_table
from ..search import observation_utils
from . import embedding_utils
from ..db_utils import acquire_with_retry
from .types import EntityLink
logger = logging.getLogger(__name__)
@@ -19,12 +19,12 @@ logger = logging.getLogger(__name__)
def utcnow():
"""Get current UTC time."""
return datetime.now(UTC)
return datetime.now(timezone.utc)
# Simple dataclass-like container for facts (avoid importing from memory_engine)
class MemoryFactForObservation:
def __init__(self, id: str, text: str, fact_type: str, context: str, occurred_start: str | None):
def __init__(self, id: str, text: str, fact_type: str, context: str, occurred_start: Optional[str]):
self.id = id
self.text = text
self.fact_type = fact_type
@@ -33,7 +33,12 @@ class MemoryFactForObservation:
async def regenerate_observations_batch(
conn, embeddings_model, llm_config, bank_id: str, entity_links: list[EntityLink], log_buffer: list[str] = None
conn,
embeddings_model,
llm_config,
bank_id: str,
entity_links: List[EntityLink],
log_buffer: List[str] = None
) -> None:
"""
Regenerate observations for top entities in this batch.
@@ -56,7 +61,7 @@ async def regenerate_observations_batch(
return
# Count mentions per entity in this batch
entity_mention_counts: dict[str, int] = {}
entity_mention_counts: Dict[str, int] = {}
for link in entity_links:
if link.entity_id:
entity_id = str(link.entity_id)
@@ -66,7 +71,11 @@ async def regenerate_observations_batch(
return
# Sort by mention count descending and take top N
sorted_entities = sorted(entity_mention_counts.items(), key=lambda x: x[1], reverse=True)
sorted_entities = sorted(
entity_mention_counts.items(),
key=lambda x: x[1],
reverse=True
)
entities_to_process = [e[0] for e in sorted_entities[:TOP_N_ENTITIES]]
obs_start = time.time()
@@ -76,28 +85,26 @@ async def regenerate_observations_batch(
# Batch query for entity names
entity_rows = await conn.fetch(
f"""
SELECT id, canonical_name FROM {fq_table("entities")}
"""
SELECT id, canonical_name FROM entities
WHERE id = ANY($1) AND bank_id = $2
""",
entity_uuids,
bank_id,
entity_uuids, bank_id
)
entity_names = {row["id"]: row["canonical_name"] for row in entity_rows}
entity_names = {row['id']: row['canonical_name'] for row in entity_rows}
# Batch query for fact counts
fact_counts = await conn.fetch(
f"""
"""
SELECT ue.entity_id, COUNT(*) as cnt
FROM {fq_table("unit_entities")} ue
JOIN {fq_table("memory_units")} mu ON ue.unit_id = mu.id
FROM unit_entities ue
JOIN memory_units mu ON ue.unit_id = mu.id
WHERE ue.entity_id = ANY($1) AND mu.bank_id = $2
GROUP BY ue.entity_id
""",
entity_uuids,
bank_id,
entity_uuids, bank_id
)
entity_fact_counts = {row["entity_id"]: row["cnt"] for row in fact_counts}
entity_fact_counts = {row['entity_id']: row['cnt'] for row in fact_counts}
# Filter entities that meet the threshold
entities_with_names = []
@@ -119,7 +126,8 @@ async def regenerate_observations_batch(
for entity_id, entity_name in entities_with_names:
try:
obs_ids = await _regenerate_entity_observations(
conn, embeddings_model, llm_config, bank_id, entity_id, entity_name
conn, embeddings_model, llm_config,
bank_id, entity_id, entity_name
)
total_observations += len(obs_ids)
except Exception as e:
@@ -127,14 +135,17 @@ async def regenerate_observations_batch(
obs_time = time.time() - obs_start
if log_buffer is not None:
log_buffer.append(
f"[11] Observations: {total_observations} observations for {len(entities_with_names)} entities in {obs_time:.3f}s"
)
log_buffer.append(f"[11] Observations: {total_observations} observations for {len(entities_with_names)} entities in {obs_time:.3f}s")
async def _regenerate_entity_observations(
conn, embeddings_model, llm_config, bank_id: str, entity_id: str, entity_name: str
) -> list[str]:
conn,
embeddings_model,
llm_config,
bank_id: str,
entity_id: str,
entity_name: str
) -> List[str]:
"""
Regenerate observations for a single entity.
@@ -155,18 +166,17 @@ async def _regenerate_entity_observations(
# Get all facts mentioning this entity (exclude observations themselves)
rows = await conn.fetch(
f"""
"""
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.fact_type
FROM {fq_table("memory_units")} mu
JOIN {fq_table("unit_entities")} ue ON mu.id = ue.unit_id
FROM memory_units mu
JOIN unit_entities ue ON mu.id = ue.unit_id
WHERE mu.bank_id = $1
AND ue.entity_id = $2
AND mu.fact_type IN ('world', 'experience')
ORDER BY mu.occurred_start DESC
LIMIT 50
""",
bank_id,
entity_uuid,
bank_id, entity_uuid
)
if not rows:
@@ -175,42 +185,45 @@ async def _regenerate_entity_observations(
# Convert to fact objects for observation extraction
facts = []
for row in rows:
occurred_start = row["occurred_start"].isoformat() if row["occurred_start"] else None
facts.append(
MemoryFactForObservation(
id=str(row["id"]),
text=row["text"],
fact_type=row["fact_type"],
context=row["context"],
occurred_start=occurred_start,
)
)
occurred_start = row['occurred_start'].isoformat() if row['occurred_start'] else None
facts.append(MemoryFactForObservation(
id=str(row['id']),
text=row['text'],
fact_type=row['fact_type'],
context=row['context'],
occurred_start=occurred_start
))
# Extract observations using LLM
observations = await observation_utils.extract_observations_from_facts(llm_config, entity_name, facts)
observations = await observation_utils.extract_observations_from_facts(
llm_config,
entity_name,
facts
)
if not observations:
return []
# Delete old observations for this entity
await conn.execute(
f"""
DELETE FROM {fq_table("memory_units")}
"""
DELETE FROM memory_units
WHERE id IN (
SELECT mu.id
FROM {fq_table("memory_units")} mu
JOIN {fq_table("unit_entities")} ue ON mu.id = ue.unit_id
FROM memory_units mu
JOIN unit_entities ue ON mu.id = ue.unit_id
WHERE mu.bank_id = $1
AND mu.fact_type = 'observation'
AND ue.entity_id = $2
)
""",
bank_id,
entity_uuid,
bank_id, entity_uuid
)
# Generate embeddings for new observations
embeddings = await embedding_utils.generate_embeddings_batch(embeddings_model, observations)
embeddings = await embedding_utils.generate_embeddings_batch(
embeddings_model, observations
)
# Insert new observations
current_time = utcnow()
@@ -218,8 +231,8 @@ async def _regenerate_entity_observations(
for obs_text, embedding in zip(observations, embeddings):
result = await conn.fetchrow(
f"""
INSERT INTO {fq_table("memory_units")} (
"""
INSERT INTO memory_units (
bank_id, text, embedding, context, event_date,
occurred_start, occurred_end, mentioned_at,
fact_type, access_count
@@ -234,19 +247,18 @@ async def _regenerate_entity_observations(
current_time,
current_time,
current_time,
current_time,
current_time
)
obs_id = str(result["id"])
obs_id = str(result['id'])
created_ids.append(obs_id)
# Link observation to entity
await conn.execute(
f"""
INSERT INTO {fq_table("unit_entities")} (unit_id, entity_id)
"""
INSERT INTO unit_entities (unit_id, entity_id)
VALUES ($1, $2)
""",
uuid.UUID(obs_id),
entity_uuid,
uuid.UUID(obs_id), entity_uuid
)
return created_ids
@@ -3,32 +3,31 @@ Main orchestrator for the retain pipeline.
Coordinates all retain pipeline modules to store memories efficiently.
"""
import logging
import time
import uuid
from datetime import UTC, datetime
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional
from ..db_utils import acquire_with_retry
from . import bank_utils
from ..db_utils import acquire_with_retry
def utcnow():
"""Get current UTC time."""
return datetime.now(UTC)
return datetime.now(timezone.utc)
from .types import RetainContent, ExtractedFact, ProcessedFact, EntityLink
from . import (
chunk_storage,
deduplication,
embedding_processing,
entity_processing,
fact_extraction,
embedding_processing,
deduplication,
chunk_storage,
fact_storage,
entity_processing,
link_creation,
observation_regeneration,
observation_regeneration
)
from .types import ExtractedFact, ProcessedFact, RetainContent, RetainContentDict
logger = logging.getLogger(__name__)
@@ -42,12 +41,12 @@ async def retain_batch(
format_date_fn,
duplicate_checker_fn,
bank_id: str,
contents_dicts: list[RetainContentDict],
document_id: str | None = None,
contents_dicts: List[Dict[str, Any]],
document_id: Optional[str] = None,
is_first_batch: bool = True,
fact_type_override: str | None = None,
confidence_score: float | None = None,
) -> list[list[str]]:
fact_type_override: Optional[str] = None,
confidence_score: Optional[float] = None,
) -> List[List[str]]:
"""
Process a batch of content through the retain pipeline.
@@ -74,10 +73,10 @@ async def retain_batch(
# Buffer all logs
log_buffer = []
log_buffer.append(f"{'=' * 60}")
log_buffer.append(f"{'='*60}")
log_buffer.append(f"RETAIN_BATCH START: {bank_id}")
log_buffer.append(f"Batch size: {len(contents_dicts)} content items, {total_chars:,} chars")
log_buffer.append(f"{'=' * 60}")
log_buffer.append(f"{'='*60}")
# Get bank profile
profile = await bank_utils.get_bank_profile(pool, bank_id)
@@ -90,79 +89,23 @@ async def retain_batch(
content=item["content"],
context=item.get("context", ""),
event_date=item.get("event_date") or utcnow(),
metadata=item.get("metadata", {}),
metadata=item.get("metadata", {})
)
contents.append(content)
# Step 1: Extract facts from all contents
step_start = time.time()
extract_opinions = fact_type_override == "opinion"
extract_opinions = (fact_type_override == 'opinion')
extracted_facts, chunks = await fact_extraction.extract_facts_from_contents(
contents, llm_config, agent_name, extract_opinions
)
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"
contents,
llm_config,
agent_name,
extract_opinions
)
log_buffer.append(f"[1] Extract facts: {len(extracted_facts)} facts, {len(chunks)} chunks from {len(contents)} contents in {time.time() - step_start:.3f}s")
if not extracted_facts:
# Still need to create document if document_id was provided
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
await fact_storage.ensure_bank_exists(conn, bank_id)
# Handle document tracking even with no facts
if document_id:
combined_content = "\n".join([c.get("content", "") for c in contents_dicts])
retain_params = {}
if contents_dicts:
first_item = contents_dicts[0]
if first_item.get("context"):
retain_params["context"] = first_item["context"]
if first_item.get("event_date"):
retain_params["event_date"] = (
first_item["event_date"].isoformat()
if hasattr(first_item["event_date"], "isoformat")
else str(first_item["event_date"])
)
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn, bank_id, document_id, combined_content, is_first_batch, retain_params
)
else:
# Check for per-item document_ids
from collections import defaultdict
contents_by_doc = defaultdict(list)
for idx, content_dict in enumerate(contents_dicts):
doc_id = content_dict.get("document_id")
if doc_id:
contents_by_doc[doc_id].append((idx, content_dict))
for doc_id, doc_contents in contents_by_doc.items():
combined_content = "\n".join([c.get("content", "") for _, c in doc_contents])
retain_params = {}
if doc_contents:
first_item = doc_contents[0][1]
if first_item.get("context"):
retain_params["context"] = first_item["context"]
if first_item.get("event_date"):
retain_params["event_date"] = (
first_item["event_date"].isoformat()
if hasattr(first_item["event_date"], "isoformat")
else str(first_item["event_date"])
)
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
await fact_storage.handle_document_tracking(
conn, bank_id, doc_id, combined_content, is_first_batch, retain_params
)
total_time = time.time() - start_time
logger.info(
f"RETAIN_BATCH COMPLETE: 0 facts extracted from {len(contents)} contents in {total_time:.3f}s (document tracked, no facts)"
)
return [[] for _ in contents]
# Apply fact_type_override if provided
@@ -187,7 +130,6 @@ async def retain_batch(
# Group contents by document_id for document tracking and chunk storage
from collections import defaultdict
contents_by_doc = defaultdict(list)
for idx, content_dict in enumerate(contents_dicts):
doc_id = content_dict.get("document_id")
@@ -213,11 +155,7 @@ async def retain_batch(
if first_item.get("context"):
retain_params["context"] = first_item["context"]
if first_item.get("event_date"):
retain_params["event_date"] = (
first_item["event_date"].isoformat()
if hasattr(first_item["event_date"], "isoformat")
else str(first_item["event_date"])
)
retain_params["event_date"] = first_item["event_date"].isoformat() if hasattr(first_item["event_date"], "isoformat") else str(first_item["event_date"])
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
@@ -257,11 +195,7 @@ async def retain_batch(
if first_item.get("context"):
retain_params["context"] = first_item["context"]
if first_item.get("event_date"):
retain_params["event_date"] = (
first_item["event_date"].isoformat()
if hasattr(first_item["event_date"], "isoformat")
else str(first_item["event_date"])
)
retain_params["event_date"] = first_item["event_date"].isoformat() if hasattr(first_item["event_date"], "isoformat") else str(first_item["event_date"])
if first_item.get("metadata"):
retain_params["metadata"] = first_item["metadata"]
@@ -271,9 +205,7 @@ async def retain_batch(
document_ids_added.append(actual_doc_id)
if document_ids_added:
log_buffer.append(
f"[2.5] Document tracking: {len(document_ids_added)} documents in {time.time() - step_start:.3f}s"
)
log_buffer.append(f"[2.5] Document tracking: {len(document_ids_added)} documents in {time.time() - step_start:.3f}s")
# Store chunks and map to facts for all documents
step_start = time.time()
@@ -298,9 +230,7 @@ async def retain_batch(
for chunk_idx, chunk_id in chunk_id_map.items():
chunk_id_map_by_doc[(doc_id, chunk_idx)] = chunk_id
log_buffer.append(
f"[3] Store chunks: {len(chunks)} chunks for {len(chunks_by_doc)} documents in {time.time() - step_start:.3f}s"
)
log_buffer.append(f"[3] Store chunks: {len(chunks)} chunks for {len(chunks_by_doc)} documents in {time.time() - step_start:.3f}s")
# Map chunk_ids and document_ids to facts
for fact, processed_fact in zip(extracted_facts, processed_facts):
@@ -335,9 +265,7 @@ async def retain_batch(
is_duplicate_flags = await deduplication.check_duplicates_batch(
conn, bank_id, processed_facts, duplicate_checker_fn
)
log_buffer.append(
f"[4] Deduplication: {sum(is_duplicate_flags)} duplicates in {time.time() - step_start:.3f}s"
)
log_buffer.append(f"[4] Deduplication: {sum(is_duplicate_flags)} duplicates in {time.time() - step_start:.3f}s")
# Filter out duplicates
non_duplicate_facts = deduplication.filter_duplicates(processed_facts, is_duplicate_flags)
@@ -365,18 +293,14 @@ async def retain_batch(
# Create semantic links
step_start = time.time()
embeddings_for_links = [fact.embedding for fact in non_duplicate_facts]
semantic_link_count = await link_creation.create_semantic_links_batch(
conn, bank_id, unit_ids, embeddings_for_links
)
semantic_link_count = await link_creation.create_semantic_links_batch(conn, bank_id, unit_ids, embeddings_for_links)
log_buffer.append(f"[8] Semantic links: {semantic_link_count} links in {time.time() - step_start:.3f}s")
# Insert entity links
step_start = time.time()
if entity_links:
await entity_processing.insert_entity_links_batch(conn, entity_links)
log_buffer.append(
f"[9] Entity links: {len(entity_links) if entity_links else 0} links in {time.time() - step_start:.3f}s"
)
log_buffer.append(f"[9] Entity links: {len(entity_links) if entity_links else 0} links in {time.time() - step_start:.3f}s")
# Create causal links
step_start = time.time()
@@ -385,22 +309,34 @@ async def retain_batch(
# Regenerate observations INSIDE transaction for atomicity
await observation_regeneration.regenerate_observations_batch(
conn, embeddings_model, llm_config, bank_id, entity_links, log_buffer
conn,
embeddings_model,
llm_config,
bank_id,
entity_links,
log_buffer
)
# Map results back to original content items
result_unit_ids = _map_results_to_contents(contents, extracted_facts, is_duplicate_flags, unit_ids)
result_unit_ids = _map_results_to_contents(
contents, extracted_facts, is_duplicate_flags, unit_ids
)
# Trigger background tasks AFTER transaction commits (opinion reinforcement only)
await _trigger_background_tasks(task_backend, bank_id, unit_ids, non_duplicate_facts)
await _trigger_background_tasks(
task_backend,
bank_id,
unit_ids,
non_duplicate_facts
)
# Log final summary
total_time = time.time() - start_time
log_buffer.append(f"{'=' * 60}")
log_buffer.append(f"{'='*60}")
log_buffer.append(f"RETAIN_BATCH COMPLETE: {len(unit_ids)} units in {total_time:.3f}s")
if document_ids_added:
log_buffer.append(f"Documents: {', '.join(document_ids_added)}")
log_buffer.append(f"{'=' * 60}")
log_buffer.append(f"{'='*60}")
logger.info("\n" + "\n".join(log_buffer) + "\n")
@@ -408,11 +344,11 @@ async def retain_batch(
def _map_results_to_contents(
contents: list[RetainContent],
extracted_facts: list[ExtractedFact],
is_duplicate_flags: list[bool],
unit_ids: list[str],
) -> list[list[str]]:
contents: List[RetainContent],
extracted_facts: List[ExtractedFact],
is_duplicate_flags: List[bool],
unit_ids: List[str]
) -> List[List[str]]:
"""
Map created unit IDs back to original content items.
@@ -440,19 +376,17 @@ def _map_results_to_contents(
async def _trigger_background_tasks(
task_backend,
bank_id: str,
unit_ids: list[str],
facts: list[ProcessedFact],
unit_ids: List[str],
facts: List[ProcessedFact],
) -> None:
"""Trigger opinion reinforcement as background task (after transaction commits)."""
# Trigger opinion reinforcement if there are entities
fact_entities = [[e.name for e in fact.entities] for fact in facts]
if any(fact_entities):
await task_backend.submit_task(
{
"type": "reinforce_opinion",
"bank_id": bank_id,
"created_unit_ids": unit_ids,
"unit_texts": [fact.fact_text for fact in facts],
"unit_entities": fact_entities,
}
)
await task_backend.submit_task({
'type': 'reinforce_opinion',
'bank_id': bank_id,
'created_unit_ids': unit_ids,
'unit_texts': [fact.fact_text for fact in facts],
'unit_entities': fact_entities
})
@@ -6,34 +6,11 @@ from content input to fact storage.
"""
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import TypedDict
from typing import List, Optional, Dict, Any
from datetime import datetime
from uuid import UUID
class RetainContentDict(TypedDict, total=False):
"""Type definition for content items in retain_batch_async.
Fields:
content: Text content to store (required)
context: Context about the content (optional)
event_date: When the content occurred (optional, defaults to now)
metadata: Custom key-value metadata (optional)
document_id: Document ID for this content item (optional)
"""
content: str # Required
context: str
event_date: datetime
metadata: dict[str, str]
document_id: str
def _now_utc() -> datetime:
"""Factory function for default event_date."""
return datetime.now(UTC)
@dataclass
class RetainContent:
"""
@@ -41,11 +18,16 @@ class RetainContent:
Represents a single piece of content to extract facts from.
"""
content: str
context: str = ""
event_date: datetime = field(default_factory=_now_utc)
metadata: dict[str, str] = field(default_factory=dict)
event_date: Optional[datetime] = None
metadata: Dict[str, str] = field(default_factory=dict)
def __post_init__(self):
"""Ensure event_date is set."""
if self.event_date is None:
from datetime import datetime, timezone
self.event_date = datetime.now(timezone.utc)
@dataclass
@@ -55,7 +37,6 @@ class ChunkMetadata:
Used to track which facts were extracted from which chunks.
"""
chunk_text: str
fact_count: int
content_index: int # Index of the source content
@@ -69,10 +50,9 @@ class EntityRef:
Entities are extracted by the LLM during fact extraction.
"""
name: str
canonical_name: str | None = None # Resolved canonical name
entity_id: UUID | None = None # Resolved entity ID
canonical_name: Optional[str] = None # Resolved canonical name
entity_id: Optional[UUID] = None # Resolved entity ID
@dataclass
@@ -82,7 +62,6 @@ class CausalRelation:
Represents how one fact causes, enables, or prevents another.
"""
relation_type: str # "causes", "enables", "prevents", "caused_by"
target_fact_index: int # Index of the target fact in the batch
strength: float = 1.0 # Strength of the causal relationship
@@ -95,21 +74,20 @@ class ExtractedFact:
This is the raw output from fact extraction before processing.
"""
fact_text: str
fact_type: str # "world", "experience", "opinion", "observation"
entities: list[str] = field(default_factory=list)
occurred_start: datetime | None = None
occurred_end: datetime | None = None
where: str | None = None # WHERE the fact occurred or is about
causal_relations: list[CausalRelation] = field(default_factory=list)
entities: List[str] = field(default_factory=list)
occurred_start: Optional[datetime] = None
occurred_end: Optional[datetime] = None
where: Optional[str] = None # WHERE the fact occurred or is about
causal_relations: List[CausalRelation] = field(default_factory=list)
# Context from the content item
content_index: int = 0 # Which content this fact came from
chunk_index: int = 0 # Which chunk this fact came from
context: str = ""
mentioned_at: datetime | None = None
metadata: dict[str, str] = field(default_factory=dict)
mentioned_at: Optional[datetime] = None
metadata: Dict[str, str] = field(default_factory=dict)
@dataclass
@@ -119,38 +97,37 @@ class ProcessedFact:
Includes resolved entities, embeddings, and all necessary fields.
"""
# Core fact data
fact_text: str
fact_type: str
embedding: list[float]
embedding: List[float]
# Temporal data
occurred_start: datetime | None
occurred_end: datetime | None
occurred_start: Optional[datetime]
occurred_end: Optional[datetime]
mentioned_at: datetime
# Context and metadata
context: str
metadata: dict[str, str]
metadata: Dict[str, str]
# Location data
where: str | None = None
where: Optional[str] = None
# Entities
entities: list[EntityRef] = field(default_factory=list)
entities: List[EntityRef] = field(default_factory=list)
# Causal relations
causal_relations: list[CausalRelation] = field(default_factory=list)
causal_relations: List[CausalRelation] = field(default_factory=list)
# Chunk reference
chunk_id: str | None = None
chunk_id: Optional[str] = None
# Document reference (denormalized for query performance)
document_id: str | None = None
document_id: Optional[str] = None
# DB fields (set after insertion)
unit_id: UUID | None = None
unit_id: Optional[UUID] = None
@property
def is_duplicate(self) -> bool:
@@ -159,8 +136,10 @@ class ProcessedFact:
@staticmethod
def from_extracted_fact(
extracted_fact: "ExtractedFact", embedding: list[float], chunk_id: str | None = None
) -> "ProcessedFact":
extracted_fact: 'ExtractedFact',
embedding: List[float],
chunk_id: Optional[str] = None
) -> 'ProcessedFact':
"""
Create ProcessedFact from ExtractedFact.
@@ -172,12 +151,12 @@ class ProcessedFact:
Returns:
ProcessedFact ready for storage
"""
from datetime import datetime
from datetime import datetime, timezone
# Use occurred dates only if explicitly provided by LLM
occurred_start = extracted_fact.occurred_start
occurred_end = extracted_fact.occurred_end
mentioned_at = extracted_fact.mentioned_at or datetime.now(UTC)
mentioned_at = extracted_fact.mentioned_at or datetime.now(timezone.utc)
# Convert entity strings to EntityRef objects
entities = [EntityRef(name=name) for name in extracted_fact.entities]
@@ -193,7 +172,7 @@ class ProcessedFact:
metadata=extracted_fact.metadata,
entities=entities,
causal_relations=extracted_fact.causal_relations,
chunk_id=chunk_id,
chunk_id=chunk_id
)
@@ -204,11 +183,10 @@ class EntityLink:
Used for entity-based graph connections in the memory graph.
"""
from_unit_id: UUID
to_unit_id: UUID
entity_id: UUID
link_type: str = "entity"
link_type: str = 'entity'
weight: float = 1.0
@@ -219,25 +197,24 @@ class RetainBatch:
Tracks all facts, chunks, and metadata for a batch operation.
"""
bank_id: str
contents: list[RetainContent]
document_id: str | None = None
fact_type_override: str | None = None
confidence_score: float | None = None
contents: List[RetainContent]
document_id: Optional[str] = None
fact_type_override: Optional[str] = None
confidence_score: Optional[float] = None
# Extracted data (populated during processing)
extracted_facts: list[ExtractedFact] = field(default_factory=list)
processed_facts: list[ProcessedFact] = field(default_factory=list)
chunks: list[ChunkMetadata] = field(default_factory=list)
extracted_facts: List[ExtractedFact] = field(default_factory=list)
processed_facts: List[ProcessedFact] = field(default_factory=list)
chunks: List[ChunkMetadata] = field(default_factory=list)
# Results (populated after storage)
unit_ids_by_content: list[list[str]] = field(default_factory=list)
unit_ids_by_content: List[List[str]] = field(default_factory=list)
def get_facts_for_content(self, content_index: int) -> list[ExtractedFact]:
def get_facts_for_content(self, content_index: int) -> List[ExtractedFact]:
"""Get all extracted facts for a specific content item."""
return [f for f in self.extracted_facts if f.content_index == content_index]
def get_chunks_for_content(self, content_index: int) -> list[ChunkMetadata]:
def get_chunks_for_content(self, content_index: int) -> List[ChunkMetadata]:
"""Get all chunks for a specific content item."""
return [c for c in self.chunks if c.content_index == content_index]
@@ -3,27 +3,13 @@ Search module for memory retrieval.
Provides modular search architecture:
- Retrieval: 4-way parallel (semantic + BM25 + graph + temporal)
- Graph retrieval: Pluggable strategies (BFS, PPR)
- Reranking: Pluggable strategies (heuristic, cross-encoder)
"""
from .graph_retrieval import BFSGraphRetriever, GraphRetriever
from .mpfp_retrieval import MPFPGraphRetriever
from .retrieval import retrieve_parallel
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",
"GraphRetriever",
"BFSGraphRetriever",
"MPFPGraphRetriever",
"CrossEncoderReranker",
]
@@ -2,12 +2,15 @@
Helper functions for hybrid search (semantic + BM25 + graph).
"""
from typing import Any
from .types import MergedCandidate, RetrievalResult
from typing import List, Dict, Any, Tuple
import asyncio
from .types import RetrievalResult, MergedCandidate
def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 60) -> list[MergedCandidate]:
def reciprocal_rank_fusion(
result_lists: List[List[RetrievalResult]],
k: int = 60
) -> List[MergedCandidate]:
"""
Merge multiple ranked result lists using Reciprocal Rank Fusion.
@@ -70,14 +73,20 @@ def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 6
sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True), start=1
):
merged_candidate = MergedCandidate(
retrieval=all_retrievals[doc_id], rrf_score=rrf_score, rrf_rank=rrf_rank, source_ranks=source_ranks[doc_id]
retrieval=all_retrievals[doc_id],
rrf_score=rrf_score,
rrf_rank=rrf_rank,
source_ranks=source_ranks[doc_id]
)
merged_results.append(merged_candidate)
return merged_results
def normalize_scores_on_deltas(results: list[dict[str, Any]], score_keys: list[str]) -> list[dict[str, Any]]:
def normalize_scores_on_deltas(
results: List[Dict[str, Any]],
score_keys: List[str]
) -> List[Dict[str, Any]]:
"""
Normalize scores based on deltas (min-max normalization within result set).
@@ -1,235 +0,0 @@
"""
Graph retrieval strategies for memory recall.
This module provides an abstraction for graph-based memory retrieval,
allowing different algorithms (BFS spreading activation, PPR, etc.) to be
swapped without changing the rest of the recall pipeline.
"""
import logging
from abc import ABC, abstractmethod
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from .types import RetrievalResult
logger = logging.getLogger(__name__)
class GraphRetriever(ABC):
"""
Abstract base class for graph-based memory retrieval.
Implementations traverse the memory graph (entity links, temporal links,
causal links) to find relevant facts that might not be found by
semantic or keyword search alone.
"""
@property
@abstractmethod
def name(self) -> str:
"""Return identifier for this retrieval strategy (e.g., 'bfs', 'mpfp')."""
pass
@abstractmethod
async def retrieve(
self,
pool,
query_embedding_str: str,
bank_id: str,
fact_type: str,
budget: int,
query_text: str | None = None,
semantic_seeds: list[RetrievalResult] | None = None,
temporal_seeds: list[RetrievalResult] | None = None,
) -> list[RetrievalResult]:
"""
Retrieve relevant facts via graph traversal.
Args:
pool: Database connection pool
query_embedding_str: Query embedding as string (for finding entry points)
bank_id: Memory bank identifier
fact_type: Fact type to filter ('world', 'experience', 'opinion', 'observation')
budget: Maximum number of nodes to explore/return
query_text: Original query text (optional, for some strategies)
semantic_seeds: Pre-computed semantic entry points (from semantic retrieval)
temporal_seeds: Pre-computed temporal entry points (from temporal retrieval)
Returns:
List of RetrievalResult objects with activation scores set
"""
pass
class BFSGraphRetriever(GraphRetriever):
"""
Graph retrieval using BFS-style spreading activation.
Starting from semantic entry points, spreads activation through
the memory graph (entity, temporal, causal links) using breadth-first
traversal with decaying activation.
This is the original Hindsight graph retrieval algorithm.
"""
def __init__(
self,
entry_point_limit: int = 5,
entry_point_threshold: float = 0.5,
activation_decay: float = 0.8,
min_activation: float = 0.1,
batch_size: int = 20,
):
"""
Initialize BFS graph retriever.
Args:
entry_point_limit: Maximum number of entry points to start from
entry_point_threshold: Minimum semantic similarity for entry points
activation_decay: Decay factor per hop (activation *= decay)
min_activation: Minimum activation to continue spreading
batch_size: Number of nodes to process per batch (for neighbor fetching)
"""
self.entry_point_limit = entry_point_limit
self.entry_point_threshold = entry_point_threshold
self.activation_decay = activation_decay
self.min_activation = min_activation
self.batch_size = batch_size
@property
def name(self) -> str:
return "bfs"
async def retrieve(
self,
pool,
query_embedding_str: str,
bank_id: str,
fact_type: str,
budget: int,
query_text: str | None = None,
semantic_seeds: list[RetrievalResult] | None = None,
temporal_seeds: list[RetrievalResult] | None = None,
) -> list[RetrievalResult]:
"""
Retrieve facts using BFS spreading activation.
Algorithm:
1. Find entry points (top semantic matches above threshold)
2. BFS traversal: visit neighbors, propagate decaying activation
3. Boost causal links (causes, enables, prevents)
4. Return visited nodes up to budget
Note: BFS finds its own entry points via embedding search.
The semantic_seeds and temporal_seeds parameters are accepted
for interface compatibility but not used.
"""
async with acquire_with_retry(pool) as conn:
return await self._retrieve_with_conn(conn, query_embedding_str, bank_id, fact_type, budget)
async def _retrieve_with_conn(
self,
conn,
query_embedding_str: str,
bank_id: str,
fact_type: str,
budget: int,
) -> list[RetrievalResult]:
"""Internal implementation with connection."""
# Step 1: Find entry points
entry_points = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND embedding IS NOT NULL
AND fact_type = $3
AND (1 - (embedding <=> $1::vector)) >= $4
ORDER BY embedding <=> $1::vector
LIMIT $5
""",
query_embedding_str,
bank_id,
fact_type,
self.entry_point_threshold,
self.entry_point_limit,
)
if not entry_points:
return []
# Step 2: BFS spreading activation
visited = set()
results = []
queue = [(RetrievalResult.from_db_row(dict(r)), r["similarity"]) for r in entry_points]
budget_remaining = budget
while queue and budget_remaining > 0:
# Collect a batch of nodes to process
batch_nodes = []
batch_activations = {}
while queue and len(batch_nodes) < self.batch_size and budget_remaining > 0:
current, activation = queue.pop(0)
unit_id = current.id
if unit_id not in visited:
visited.add(unit_id)
budget_remaining -= 1
current.activation = activation
results.append(current)
batch_nodes.append(current.id)
batch_activations[unit_id] = activation
# Batch fetch neighbors
if batch_nodes and budget_remaining > 0:
max_neighbors = len(batch_nodes) * 20
neighbors = await conn.fetch(
f"""
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.occurred_end,
mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type,
mu.document_id, mu.chunk_id,
ml.weight, ml.link_type, ml.from_unit_id
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.weight >= $2
AND mu.fact_type = $3
ORDER BY ml.weight DESC
LIMIT $4
""",
batch_nodes,
self.min_activation,
fact_type,
max_neighbors,
)
for n in neighbors:
neighbor_id = str(n["id"])
if neighbor_id not in visited:
parent_id = str(n["from_unit_id"])
parent_activation = batch_activations.get(parent_id, 0.5)
# Boost causal links
link_type = n["link_type"]
base_weight = n["weight"]
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
effective_weight = base_weight * causal_boost
new_activation = parent_activation * effective_weight * self.activation_decay
if new_activation > self.min_activation:
neighbor_result = RetrievalResult.from_db_row(dict(n))
queue.append((neighbor_result, new_activation))
return results
@@ -1,439 +0,0 @@
"""
Meta-Path Forward Push (MPFP) graph retrieval.
A sublinear graph traversal algorithm for memory retrieval over heterogeneous
graphs with multiple edge types (semantic, temporal, causal, entity).
Combines meta-path patterns from HIN literature with Forward Push local
propagation from Approximate PPR.
Key properties:
- Sublinear in graph size (threshold pruning bounds active nodes)
- Predefined patterns capture different retrieval intents
- All patterns run in parallel, results fused via RRF
- No LLM in the loop during traversal
"""
import asyncio
import logging
from collections import defaultdict
from dataclasses import dataclass, field
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from .graph_retrieval import GraphRetriever
from .types import RetrievalResult
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Data Classes
# -----------------------------------------------------------------------------
@dataclass
class EdgeTarget:
"""A neighbor node with its edge weight."""
node_id: str
weight: float
@dataclass
class TypedAdjacency:
"""Adjacency lists split by edge type."""
# edge_type -> from_node_id -> list of (to_node_id, weight)
graphs: dict[str, dict[str, list[EdgeTarget]]] = field(default_factory=dict)
def get_neighbors(self, edge_type: str, node_id: str) -> list[EdgeTarget]:
"""Get neighbors for a node via a specific edge type."""
return self.graphs.get(edge_type, {}).get(node_id, [])
def get_normalized_neighbors(self, edge_type: str, node_id: str, top_k: int) -> list[EdgeTarget]:
"""Get top-k neighbors with weights normalized to sum to 1."""
neighbors = self.get_neighbors(edge_type, node_id)[:top_k]
if not neighbors:
return []
total = sum(n.weight for n in neighbors)
if total == 0:
return []
return [EdgeTarget(node_id=n.node_id, weight=n.weight / total) for n in neighbors]
@dataclass
class PatternResult:
"""Result from a single pattern traversal."""
pattern: list[str]
scores: dict[str, float] # node_id -> accumulated mass
@dataclass
class MPFPConfig:
"""Configuration for MPFP algorithm."""
alpha: float = 0.15 # teleport/keep probability
threshold: float = 1e-6 # mass pruning threshold (lower = explore more)
top_k_neighbors: int = 20 # fan-out limit per node
# Patterns from semantic seeds
patterns_semantic: list[list[str]] = field(
default_factory=lambda: [
["semantic", "semantic"], # topic expansion
["entity", "temporal"], # entity timeline
["semantic", "causes"], # reasoning chains (forward)
["semantic", "caused_by"], # reasoning chains (backward)
["entity", "semantic"], # entity context
]
)
# Patterns from temporal seeds
patterns_temporal: list[list[str]] = field(
default_factory=lambda: [
["temporal", "semantic"], # what was happening then
["temporal", "entity"], # who was involved then
]
)
@dataclass
class SeedNode:
"""An entry point node with its initial score."""
node_id: str
score: float # initial mass (e.g., similarity score)
# -----------------------------------------------------------------------------
# Core Algorithm
# -----------------------------------------------------------------------------
def mpfp_traverse(
seeds: list[SeedNode],
pattern: list[str],
adjacency: TypedAdjacency,
config: MPFPConfig,
) -> PatternResult:
"""
Forward Push traversal following a meta-path pattern.
Args:
seeds: Entry point nodes with initial scores
pattern: Sequence of edge types to follow
adjacency: Typed adjacency structure
config: Algorithm parameters
Returns:
PatternResult with accumulated scores per node
"""
if not seeds:
return PatternResult(pattern=pattern, scores={})
scores: dict[str, float] = {}
# Initialize frontier with seed masses (normalized)
total_seed_score = sum(s.score for s in seeds)
if total_seed_score == 0:
total_seed_score = len(seeds) # fallback to uniform
frontier: dict[str, float] = {s.node_id: s.score / total_seed_score for s in seeds}
# Follow pattern hop by hop
for edge_type in pattern:
next_frontier: dict[str, float] = {}
for node_id, mass in frontier.items():
if mass < config.threshold:
continue
# Keep α portion for this node
scores[node_id] = scores.get(node_id, 0) + config.alpha * mass
# Push (1-α) to neighbors
push_mass = (1 - config.alpha) * mass
neighbors = adjacency.get_normalized_neighbors(edge_type, node_id, config.top_k_neighbors)
for neighbor in neighbors:
next_frontier[neighbor.node_id] = next_frontier.get(neighbor.node_id, 0) + push_mass * neighbor.weight
frontier = next_frontier
# Final frontier nodes get their remaining mass
for node_id, mass in frontier.items():
if mass >= config.threshold:
scores[node_id] = scores.get(node_id, 0) + mass
return PatternResult(pattern=pattern, scores=scores)
def rrf_fusion(
results: list[PatternResult],
k: int = 60,
top_k: int = 50,
) -> list[tuple[str, float]]:
"""
Reciprocal Rank Fusion to combine pattern results.
Args:
results: List of pattern results
k: RRF constant (higher = more uniform weighting)
top_k: Number of results to return
Returns:
List of (node_id, fused_score) tuples, sorted by score descending
"""
fused: dict[str, float] = {}
for result in results:
if not result.scores:
continue
# Rank nodes by their score in this pattern
ranked = sorted(result.scores.keys(), key=lambda n: result.scores[n], reverse=True)
for rank, node_id in enumerate(ranked):
fused[node_id] = fused.get(node_id, 0) + 1.0 / (k + rank + 1)
# Sort by fused score and return top-k
sorted_results = sorted(fused.items(), key=lambda x: x[1], reverse=True)
return sorted_results[:top_k]
# -----------------------------------------------------------------------------
# Database Loading
# -----------------------------------------------------------------------------
async def load_typed_adjacency(pool, bank_id: str) -> TypedAdjacency:
"""
Load all edges for a bank, split by edge type.
Single query, then organize in-memory for fast traversal.
"""
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
f"""
SELECT ml.from_unit_id, ml.to_unit_id, ml.link_type, ml.weight
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
WHERE mu.bank_id = $1
AND ml.weight >= 0.1
ORDER BY ml.from_unit_id, ml.weight DESC
""",
bank_id,
)
graphs: dict[str, dict[str, list[EdgeTarget]]] = defaultdict(lambda: defaultdict(list))
for row in rows:
from_id = str(row["from_unit_id"])
to_id = str(row["to_unit_id"])
link_type = row["link_type"]
weight = row["weight"]
graphs[link_type][from_id].append(EdgeTarget(node_id=to_id, weight=weight))
return TypedAdjacency(graphs=dict(graphs))
async def fetch_memory_units_by_ids(
pool,
node_ids: list[str],
fact_type: str,
) -> list[RetrievalResult]:
"""Fetch full memory unit details for a list of node IDs."""
if not node_ids:
return []
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
f"""
SELECT id, text, context, event_date, occurred_start, occurred_end,
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id
FROM {fq_table("memory_units")}
WHERE id = ANY($1::uuid[])
AND fact_type = $2
""",
node_ids,
fact_type,
)
return [RetrievalResult.from_db_row(dict(r)) for r in rows]
# -----------------------------------------------------------------------------
# Graph Retriever Implementation
# -----------------------------------------------------------------------------
class MPFPGraphRetriever(GraphRetriever):
"""
Graph retrieval using Meta-Path Forward Push.
Runs predefined patterns in parallel from semantic and temporal seeds,
then fuses results via RRF.
"""
def __init__(self, config: MPFPConfig | None = None):
"""
Initialize MPFP retriever.
Args:
config: Algorithm configuration (uses defaults if None)
"""
self.config = config or MPFPConfig()
self._adjacency_cache: dict[str, TypedAdjacency] = {}
@property
def name(self) -> str:
return "mpfp"
async def retrieve(
self,
pool,
query_embedding_str: str,
bank_id: str,
fact_type: str,
budget: int,
query_text: str | None = None,
semantic_seeds: list[RetrievalResult] | None = None,
temporal_seeds: list[RetrievalResult] | None = None,
) -> list[RetrievalResult]:
"""
Retrieve facts using MPFP algorithm.
Args:
pool: Database connection pool
query_embedding_str: Query embedding (used for fallback seed finding)
bank_id: Memory bank ID
fact_type: Fact type to filter
budget: Maximum results to return
query_text: Original query text (optional)
semantic_seeds: Pre-computed semantic entry points
temporal_seeds: Pre-computed temporal entry points
Returns:
List of RetrievalResult with activation scores
"""
# Load typed adjacency (could cache per bank_id with TTL)
adjacency = await load_typed_adjacency(pool, bank_id)
# Convert seeds to SeedNode format
semantic_seed_nodes = self._convert_seeds(semantic_seeds, "similarity")
temporal_seed_nodes = self._convert_seeds(temporal_seeds, "temporal_score")
# If no semantic seeds provided, fall back to finding our own
if not semantic_seed_nodes:
semantic_seed_nodes = await self._find_semantic_seeds(pool, query_embedding_str, bank_id, fact_type)
# Run all patterns in parallel
tasks = []
# Patterns from semantic seeds
for pattern in self.config.patterns_semantic:
if semantic_seed_nodes:
tasks.append(
asyncio.to_thread(
mpfp_traverse,
semantic_seed_nodes,
pattern,
adjacency,
self.config,
)
)
# Patterns from temporal seeds
for pattern in self.config.patterns_temporal:
if temporal_seed_nodes:
tasks.append(
asyncio.to_thread(
mpfp_traverse,
temporal_seed_nodes,
pattern,
adjacency,
self.config,
)
)
if not tasks:
return []
# Gather pattern results
pattern_results = await asyncio.gather(*tasks)
# Fuse results
fused = rrf_fusion(pattern_results, top_k=budget)
if not fused:
return []
# Get top result IDs (don't exclude seeds - they may be highly relevant)
result_ids = [node_id for node_id, score in fused][:budget]
# Fetch full details
results = await fetch_memory_units_by_ids(pool, result_ids, fact_type)
# Add activation scores from fusion
score_map = {node_id: score for node_id, score in fused}
for result in results:
result.activation = score_map.get(result.id, 0.0)
# Sort by activation
results.sort(key=lambda r: r.activation or 0, reverse=True)
return results
def _convert_seeds(
self,
seeds: list[RetrievalResult] | None,
score_attr: str,
) -> list[SeedNode]:
"""Convert RetrievalResult seeds to SeedNode format."""
if not seeds:
return []
result = []
for seed in seeds:
score = getattr(seed, score_attr, None)
if score is None:
score = seed.activation or seed.similarity or 1.0
result.append(SeedNode(node_id=seed.id, score=score))
return result
async def _find_semantic_seeds(
self,
pool,
query_embedding_str: str,
bank_id: str,
fact_type: str,
limit: int = 20,
threshold: float = 0.3,
) -> list[SeedNode]:
"""Fallback: find semantic seeds via embedding search."""
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
f"""
SELECT id, 1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
WHERE bank_id = $2
AND embedding IS NOT NULL
AND fact_type = $3
AND (1 - (embedding <=> $1::vector)) >= $4
ORDER BY embedding <=> $1::vector
LIMIT $5
""",
query_embedding_str,
bank_id,
fact_type,
threshold,
limit,
)
return [SeedNode(node_id=str(r["id"]), score=r["similarity"]) for r in rows]
@@ -6,7 +6,7 @@ about an entity, without personality influence.
"""
import logging
from typing import List, Dict, Any
from pydantic import BaseModel, Field
from ..response_models import MemoryFact
@@ -16,17 +16,18 @@ logger = logging.getLogger(__name__)
class Observation(BaseModel):
"""An observation about an entity."""
observation: str = Field(description="The observation text - a factual statement about the entity")
class ObservationExtractionResponse(BaseModel):
"""Response containing extracted observations."""
observations: list[Observation] = Field(default_factory=list, description="List of observations about the entity")
observations: List[Observation] = Field(
default_factory=list,
description="List of observations about the entity"
)
def format_facts_for_observation_prompt(facts: list[MemoryFact]) -> str:
def format_facts_for_observation_prompt(facts: List[MemoryFact]) -> str:
"""Format facts as text for observation extraction prompt."""
import json
@@ -34,7 +35,9 @@ def format_facts_for_observation_prompt(facts: list[MemoryFact]) -> str:
return "[]"
formatted = []
for fact in facts:
fact_obj = {"text": fact.text}
fact_obj = {
"text": fact.text
}
# Add context if available
if fact.context:
@@ -89,7 +92,11 @@ def get_observation_system_message() -> str:
return "You are an objective observer synthesizing facts about an entity. Generate clear, factual observations without opinions or personality influence. Be concise and accurate."
async def extract_observations_from_facts(llm_config, entity_name: str, facts: list[MemoryFact]) -> list[str]:
async def extract_observations_from_facts(
llm_config,
entity_name: str,
facts: List[MemoryFact]
) -> List[str]:
"""
Extract observations from facts about an entity using LLM.
@@ -111,10 +118,10 @@ async def extract_observations_from_facts(llm_config, entity_name: str, facts: l
result = await llm_config.call(
messages=[
{"role": "system", "content": get_observation_system_message()},
{"role": "user", "content": prompt},
{"role": "user", "content": prompt}
],
response_format=ObservationExtractionResponse,
scope="memory_extract_observation",
scope="memory_extract_observation"
)
observations = [op.observation for op in result.observations]
@@ -2,6 +2,7 @@
Cross-encoder neural reranking for search results.
"""
from typing import List
from .types import MergedCandidate, ScoredResult
@@ -23,28 +24,14 @@ class CrossEncoderReranker:
"""
if cross_encoder is None:
from hindsight_api.engine.cross_encoder import create_cross_encoder_from_env
cross_encoder = create_cross_encoder_from_env()
self.cross_encoder = cross_encoder
self._initialized = False
async def ensure_initialized(self):
"""Ensure the cross-encoder model is initialized (for lazy initialization)."""
if self._initialized:
return
import asyncio
cross_encoder = self.cross_encoder
# For local providers, run in thread pool to avoid blocking event loop
if cross_encoder.provider_name == "local":
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: asyncio.run(cross_encoder.initialize()))
else:
await cross_encoder.initialize()
self._initialized = True
def rerank(self, query: str, candidates: list[MergedCandidate]) -> list[ScoredResult]:
def rerank(
self,
query: str,
candidates: List[MergedCandidate]
) -> List[ScoredResult]:
"""
Rerank candidates using cross-encoder scores.
@@ -90,7 +77,6 @@ class CrossEncoderReranker:
# Normalize scores using sigmoid to [0, 1] range
# Cross-encoder returns logits which can be negative
import numpy as np
def sigmoid(x):
return 1 / (1 + np.exp(-x))
@@ -103,7 +89,7 @@ class CrossEncoderReranker:
candidate=candidate,
cross_encoder_score=float(raw_score),
cross_encoder_score_normalized=float(norm_score),
weight=float(norm_score), # Initial weight is just cross-encoder score
weight=float(norm_score) # Initial weight is just cross-encoder score
)
scored_results.append(scored_result)
@@ -4,69 +4,24 @@ Retrieval module for 4-way parallel search.
Implements:
1. Semantic retrieval (vector similarity)
2. BM25 retrieval (keyword/full-text search)
3. Graph retrieval (via pluggable GraphRetriever interface)
3. Graph retrieval (spreading activation)
4. Temporal retrieval (time-aware search with spreading)
"""
from typing import List, Dict, Any, Tuple, Optional
from datetime import datetime
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Optional
from ...config import get_config
from ..db_utils import acquire_with_retry
from ..memory_engine import fq_table
from .graph_retrieval import BFSGraphRetriever, GraphRetriever
from .mpfp_retrieval import MPFPGraphRetriever
from .types import RetrievalResult
logger = logging.getLogger(__name__)
@dataclass
class ParallelRetrievalResult:
"""Result from parallel retrieval across all methods."""
semantic: list[RetrievalResult]
bm25: list[RetrievalResult]
graph: list[RetrievalResult]
temporal: list[RetrievalResult] | None
timings: dict[str, float] = field(default_factory=dict)
temporal_constraint: tuple | None = None # (start_date, end_date)
# Default graph retriever instance (can be overridden)
_default_graph_retriever: GraphRetriever | None = None
def get_default_graph_retriever() -> GraphRetriever:
"""Get or create the default graph retriever based on config."""
global _default_graph_retriever
if _default_graph_retriever is None:
config = get_config()
retriever_type = config.graph_retriever.lower()
if retriever_type == "mpfp":
_default_graph_retriever = MPFPGraphRetriever()
logger.info("Using MPFP graph retriever")
elif retriever_type == "bfs":
_default_graph_retriever = BFSGraphRetriever()
logger.info("Using BFS graph retriever")
else:
logger.warning(f"Unknown graph retriever '{retriever_type}', falling back to MPFP")
_default_graph_retriever = MPFPGraphRetriever()
return _default_graph_retriever
def set_default_graph_retriever(retriever: GraphRetriever) -> None:
"""Set the default graph retriever (for configuration/testing)."""
global _default_graph_retriever
_default_graph_retriever = retriever
async def retrieve_semantic(
conn, query_emb_str: str, bank_id: str, fact_type: str, limit: int
) -> list[RetrievalResult]:
conn,
query_emb_str: str,
bank_id: str,
fact_type: str,
limit: int
) -> List[RetrievalResult]:
"""
Semantic retrieval via vector similarity.
@@ -81,10 +36,10 @@ async def retrieve_semantic(
List of RetrievalResult objects
"""
results = await conn.fetch(
f"""
"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
FROM memory_units
WHERE bank_id = $2
AND embedding IS NOT NULL
AND fact_type = $3
@@ -92,15 +47,18 @@ async def retrieve_semantic(
ORDER BY embedding <=> $1::vector
LIMIT $4
""",
query_emb_str,
bank_id,
fact_type,
limit,
query_emb_str, bank_id, fact_type, limit
)
return [RetrievalResult.from_db_row(dict(r)) for r in results]
async def retrieve_bm25(conn, query_text: str, bank_id: str, fact_type: str, limit: int) -> list[RetrievalResult]:
async def retrieve_bm25(
conn,
query_text: str,
bank_id: str,
fact_type: str,
limit: int
) -> List[RetrievalResult]:
"""
BM25 keyword retrieval via full-text search.
@@ -118,7 +76,7 @@ async def retrieve_bm25(conn, query_text: str, bank_id: str, fact_type: str, lim
# Sanitize query text: remove special characters that have meaning in tsquery
# Keep only alphanumeric characters and spaces
sanitized_text = re.sub(r"[^\w\s]", " ", query_text.lower())
sanitized_text = re.sub(r'[^\w\s]', ' ', query_text.lower())
# Split and filter empty strings
tokens = [token for token in sanitized_text.split() if token]
@@ -132,24 +90,136 @@ async def retrieve_bm25(conn, query_text: str, bank_id: str, fact_type: str, lim
query_tsquery = " | ".join(tokens)
results = await conn.fetch(
f"""
"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
ts_rank_cd(search_vector, to_tsquery('english', $1)) AS bm25_score
FROM {fq_table("memory_units")}
FROM memory_units
WHERE bank_id = $2
AND fact_type = $3
AND search_vector @@ to_tsquery('english', $1)
ORDER BY bm25_score DESC
LIMIT $4
""",
query_tsquery,
bank_id,
fact_type,
limit,
query_tsquery, bank_id, fact_type, limit
)
return [RetrievalResult.from_db_row(dict(r)) for r in results]
async def retrieve_graph(
conn,
query_emb_str: str,
bank_id: str,
fact_type: str,
budget: int
) -> List[RetrievalResult]:
"""
Graph retrieval via spreading activation.
Args:
conn: Database connection
query_emb_str: Query embedding as string
agent_id: bank ID
fact_type: Fact type to filter
budget: Node budget for graph traversal
Returns:
List of RetrievalResult objects
"""
# Find entry points
entry_points = await conn.fetch(
"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
1 - (embedding <=> $1::vector) AS similarity
FROM memory_units
WHERE bank_id = $2
AND embedding IS NOT NULL
AND fact_type = $3
AND (1 - (embedding <=> $1::vector)) >= 0.5
ORDER BY embedding <=> $1::vector
LIMIT 5
""",
query_emb_str, bank_id, fact_type
)
if not entry_points:
return []
# BFS-style spreading activation with batched neighbor fetching
visited = set()
results = []
queue = [(RetrievalResult.from_db_row(dict(r)), r["similarity"]) for r in entry_points]
budget_remaining = budget
# Process nodes in batches to reduce DB roundtrips
batch_size = 20 # Fetch neighbors for up to 20 nodes at once
while queue and budget_remaining > 0:
# Collect a batch of nodes to process
batch_nodes = []
batch_activations = {}
while queue and len(batch_nodes) < batch_size and budget_remaining > 0:
current, activation = queue.pop(0)
unit_id = current.id
if unit_id not in visited:
visited.add(unit_id)
budget_remaining -= 1
results.append(current)
batch_nodes.append(current.id)
batch_activations[unit_id] = activation
# Batch fetch neighbors for all nodes in this batch
# Fetch top weighted neighbors (batch_size * 20 = ~400 for good distribution)
if batch_nodes and budget_remaining > 0:
max_neighbors = len(batch_nodes) * 20
neighbors = await conn.fetch(
"""
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.occurred_end, mu.mentioned_at,
mu.access_count, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id,
ml.weight, ml.link_type, ml.from_unit_id
FROM memory_links ml
JOIN memory_units mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id = ANY($1::uuid[])
AND ml.weight >= 0.1
AND mu.fact_type = $2
ORDER BY ml.weight DESC
LIMIT $3
""",
batch_nodes, fact_type, max_neighbors
)
for n in neighbors:
neighbor_id = str(n["id"])
if neighbor_id not in visited:
# Get parent activation
parent_id = str(n["from_unit_id"])
activation = batch_activations.get(parent_id, 0.5)
# Boost activation for causal links (they're high-value relationships)
link_type = n["link_type"]
base_weight = n["weight"]
# Causal links get 1.5-2.0x boost depending on type
if link_type in ("causes", "caused_by"):
# Direct causation - very strong relationship
causal_boost = 2.0
elif link_type in ("enables", "prevents"):
# Conditional causation - strong but not as direct
causal_boost = 1.5
else:
# Temporal, semantic, entity links - standard weight
causal_boost = 1.0
effective_weight = base_weight * causal_boost
new_activation = activation * effective_weight * 0.8
if new_activation > 0.1:
neighbor_result = RetrievalResult.from_db_row(dict(n))
queue.append((neighbor_result, new_activation))
return results
async def retrieve_temporal(
conn,
query_emb_str: str,
@@ -158,8 +228,8 @@ async def retrieve_temporal(
start_date: datetime,
end_date: datetime,
budget: int,
semantic_threshold: float = 0.1,
) -> list[RetrievalResult]:
semantic_threshold: float = 0.1
) -> List[RetrievalResult]:
"""
Temporal retrieval with spreading activation.
@@ -181,18 +251,19 @@ async def retrieve_temporal(
Returns:
List of RetrievalResult objects with temporal scores
"""
from datetime import timezone
# Ensure start_date and end_date are timezone-aware (UTC) to match database datetimes
if start_date.tzinfo is None:
start_date = start_date.replace(tzinfo=UTC)
start_date = start_date.replace(tzinfo=timezone.utc)
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=UTC)
end_date = end_date.replace(tzinfo=timezone.utc)
entry_points = await conn.fetch(
f"""
"""
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
1 - (embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_units")}
FROM memory_units
WHERE bank_id = $2
AND fact_type = $3
AND embedding IS NOT NULL
@@ -213,12 +284,7 @@ async def retrieve_temporal(
ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC, (embedding <=> $1::vector) ASC
LIMIT 10
""",
query_emb_str,
bank_id,
fact_type,
start_date,
end_date,
semantic_threshold,
query_emb_str, bank_id, fact_type, start_date, end_date, semantic_threshold
)
if not entry_points:
@@ -261,9 +327,7 @@ async def retrieve_temporal(
results.append(ep_result)
# Spread through temporal links
queue = [
(RetrievalResult.from_db_row(dict(ep)), ep["similarity"], 1.0) for ep in entry_points
] # (unit, semantic_sim, temporal_score)
queue = [(RetrievalResult.from_db_row(dict(ep)), ep["similarity"], 1.0) for ep in entry_points] # (unit, semantic_sim, temporal_score)
budget_remaining = budget - len(entry_points)
while queue and budget_remaining > 0:
@@ -273,12 +337,12 @@ async def retrieve_temporal(
# Get neighbors via temporal and causal links
if budget_remaining > 0:
neighbors = await conn.fetch(
f"""
"""
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id,
ml.weight, ml.link_type,
1 - (mu.embedding <=> $1::vector) AS similarity
FROM {fq_table("memory_links")} ml
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
FROM memory_links ml
JOIN memory_units mu ON ml.to_unit_id = mu.id
WHERE ml.from_unit_id = $2
AND ml.link_type IN ('temporal', 'causes', 'caused_by', 'enables', 'prevents')
AND ml.weight >= 0.1
@@ -288,10 +352,7 @@ async def retrieve_temporal(
ORDER BY ml.weight DESC
LIMIT 10
""",
query_emb_str,
current.id,
fact_type,
semantic_threshold,
query_emb_str, current.id, fact_type, semantic_threshold
)
for n in neighbors:
@@ -315,9 +376,7 @@ async def retrieve_temporal(
if neighbor_best_date:
days_from_mid = abs((neighbor_best_date - mid_date).total_seconds() / 86400)
neighbor_temporal_proximity = (
1.0 - min(days_from_mid / (total_days / 2), 1.0) if total_days > 0 else 1.0
)
neighbor_temporal_proximity = 1.0 - min(days_from_mid / (total_days / 2), 1.0) if total_days > 0 else 1.0
else:
neighbor_temporal_proximity = 0.3 # Lower score if no temporal data
@@ -359,10 +418,9 @@ async def retrieve_parallel(
bank_id: str,
fact_type: str,
thinking_budget: int,
question_date: datetime | None = None,
query_analyzer: Optional["QueryAnalyzer"] = None,
graph_retriever: GraphRetriever | None = None,
) -> ParallelRetrievalResult:
question_date: Optional[datetime] = None,
query_analyzer: Optional["QueryAnalyzer"] = None
) -> Tuple[List[RetrievalResult], List[RetrievalResult], List[RetrievalResult], Optional[List[RetrievalResult]], Dict[str, float], Optional[Tuple[datetime, datetime]]]:
"""
Run 3-way or 4-way parallel retrieval (adds temporal if detected).
@@ -370,330 +428,76 @@ async def retrieve_parallel(
pool: Database connection pool
query_text: Query text
query_embedding_str: Query embedding as string
bank_id: Bank ID
agent_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)
Returns:
ParallelRetrievalResult with semantic, bm25, graph, temporal results and timings
Tuple of (semantic_results, bm25_results, graph_results, temporal_results, timings, temporal_constraint)
Each results list contains RetrievalResult objects
temporal_results is None if no temporal constraint detected
timings is a dict with per-method latencies in seconds
temporal_constraint is the (start_date, end_date) tuple if detected, else None
"""
# Detect temporal constraint
from .temporal_extraction import extract_temporal_constraint
temporal_constraint = extract_temporal_constraint(query_text, reference_date=question_date, analyzer=query_analyzer)
retriever = graph_retriever or get_default_graph_retriever()
if retriever.name == "mpfp":
return await _retrieve_parallel_mpfp(
pool, query_text, query_embedding_str, bank_id, fact_type, thinking_budget, temporal_constraint, retriever
)
else:
return await _retrieve_parallel_bfs(
pool, query_text, query_embedding_str, bank_id, fact_type, thinking_budget, temporal_constraint, retriever
)
@dataclass
class _SemanticGraphResult:
"""Internal result from semantic→graph chain."""
semantic: list[RetrievalResult]
graph: list[RetrievalResult]
semantic_time: float
graph_time: float
@dataclass
class _TimedResult:
"""Internal result with timing."""
results: list[RetrievalResult]
time: float
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,
) -> ParallelRetrievalResult:
"""
MPFP retrieval with optimized parallelization.
Runs 2-3 parallel task chains:
- Task 1: Semantic → Graph (chained, graph uses semantic seeds)
- Task 2: BM25 (independent)
- Task 3: Temporal (if constraint detected)
"""
import time
async def run_semantic_then_graph() -> _SemanticGraphResult:
"""Chain: semantic retrieval → graph retrieval (using semantic as seeds)."""
start = time.time()
async with acquire_with_retry(pool) as conn:
semantic = await retrieve_semantic(conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget)
semantic_time = time.time() - start
# Get temporal seeds if needed (quick query, part of this chain)
temporal_seeds = None
if temporal_constraint:
tc_start, tc_end = temporal_constraint
async with acquire_with_retry(pool) as conn:
temporal_seeds = await _get_temporal_entry_points(
conn, query_embedding_str, bank_id, fact_type, tc_start, tc_end, limit=20
)
# Run graph with seeds
start = time.time()
graph = 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=semantic,
temporal_seeds=temporal_seeds,
)
graph_time = time.time() - start
return _SemanticGraphResult(semantic, graph, semantic_time, graph_time)
async def run_bm25() -> _TimedResult:
"""Independent BM25 retrieval."""
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)
return _TimedResult(results, time.time() - start)
async def run_temporal(tc_start, tc_end) -> _TimedResult:
"""Temporal retrieval (uses its own entry point finding)."""
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,
)
return _TimedResult(results, time.time() - start)
# Run parallel task chains
if temporal_constraint:
tc_start, tc_end = temporal_constraint
sg_result, bm25_result, temporal_result = await asyncio.gather(
run_semantic_then_graph(),
run_bm25(),
run_temporal(tc_start, tc_end),
)
return ParallelRetrievalResult(
semantic=sg_result.semantic,
bm25=bm25_result.results,
graph=sg_result.graph,
temporal=temporal_result.results,
timings={
"semantic": sg_result.semantic_time,
"graph": sg_result.graph_time,
"bm25": bm25_result.time,
"temporal": temporal_result.time,
},
temporal_constraint=temporal_constraint,
)
else:
sg_result, bm25_result = await asyncio.gather(
run_semantic_then_graph(),
run_bm25(),
)
return ParallelRetrievalResult(
semantic=sg_result.semantic,
bm25=bm25_result.results,
graph=sg_result.graph,
temporal=None,
timings={
"semantic": sg_result.semantic_time,
"graph": sg_result.graph_time,
"bm25": bm25_result.time,
},
temporal_constraint=None,
)
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,
access_count, 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,
temporal_constraint = extract_temporal_constraint(
query_text, reference_date=question_date, analyzer=query_analyzer
)
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,
) -> ParallelRetrievalResult:
"""BFS retrieval: all methods run in parallel (original behavior)."""
import time
async def run_semantic() -> _TimedResult:
# Wrapper to track timing for each retrieval method
async def timed_retrieval(name: str, coro):
start = time.time()
result = await coro
duration = time.time() - start
return result, name, duration
async def run_semantic():
async with acquire_with_retry(pool) as conn:
results = await retrieve_semantic(conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget)
return _TimedResult(results, time.time() - start)
return await retrieve_semantic(conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget)
async def run_bm25() -> _TimedResult:
start = time.time()
async def run_bm25():
async with acquire_with_retry(pool) as conn:
results = await retrieve_bm25(conn, query_text, bank_id, fact_type, limit=thinking_budget)
return _TimedResult(results, time.time() - start)
return await retrieve_bm25(conn, query_text, bank_id, fact_type, limit=thinking_budget)
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,
)
return _TimedResult(results, time.time() - start)
async def run_temporal(tc_start, tc_end) -> _TimedResult:
start = time.time()
async def run_graph():
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,
return await retrieve_graph(conn, query_embedding_str, bank_id, fact_type, budget=thinking_budget)
async def run_temporal(start_date, end_date):
async with acquire_with_retry(pool) as conn:
return await retrieve_temporal(
conn, query_embedding_str, bank_id, fact_type,
start_date, end_date, budget=thinking_budget, semantic_threshold=0.1
)
return _TimedResult(results, time.time() - start)
# Run retrievals in parallel with timing
timings = {}
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,
start_date, end_date = temporal_constraint
results = await asyncio.gather(
timed_retrieval("semantic", run_semantic()),
timed_retrieval("bm25", run_bm25()),
timed_retrieval("graph", run_graph()),
timed_retrieval("temporal", run_temporal(start_date, end_date))
)
semantic_results, _, timings["semantic"] = results[0]
bm25_results, _, timings["bm25"] = results[1]
graph_results, _, timings["graph"] = results[2]
temporal_results, _, timings["temporal"] = results[3]
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,
results = await asyncio.gather(
timed_retrieval("semantic", run_semantic()),
timed_retrieval("bm25", run_bm25()),
timed_retrieval("graph", run_graph())
)
semantic_results, _, timings["semantic"] = results[0]
bm25_results, _, timings["bm25"] = results[1]
graph_results, _, timings["graph"] = results[2]
temporal_results = None
return semantic_results, bm25_results, graph_results, temporal_results, timings, temporal_constraint
@@ -4,11 +4,11 @@ Scoring functions for memory search and retrieval.
Includes recency weighting, frequency weighting, temporal proximity,
and similarity calculations used in memory activation and ranking.
"""
from datetime import datetime
from typing import List
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
def cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
"""
Calculate cosine similarity between two vectors.
@@ -58,7 +58,6 @@ def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -
Weight between 0 and 1
"""
import math
# Logarithmic decay: 1 / (1 + log(1 + days_since/half_life))
# This decays much slower than exponential, giving better long-term differentiation
normalized_age = days_since / half_life_days
@@ -80,7 +79,6 @@ def calculate_frequency_weight(access_count: int, max_boost: float = 2.0) -> flo
Weight between 1.0 and max_boost
"""
import math
if access_count <= 0:
return 1.0
@@ -118,7 +116,11 @@ def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime)
return midpoint
def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float:
def calculate_temporal_proximity(
anchor_a: datetime,
anchor_b: datetime,
half_life_days: float = 30.0
) -> float:
"""
Calculate temporal proximity between two temporal anchors.
@@ -4,16 +4,16 @@ Temporal extraction for time-aware search queries.
Handles natural language temporal expressions using transformer-based query analysis.
"""
import logging
from typing import Optional, Tuple
from datetime import datetime
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer, QueryAnalyzer
import logging
from hindsight_api.engine.query_analyzer import QueryAnalyzer, DateparserQueryAnalyzer
logger = logging.getLogger(__name__)
# Global default analyzer instance
# Can be overridden by passing a custom analyzer to extract_temporal_constraint
_default_analyzer: QueryAnalyzer | None = None
_default_analyzer: Optional[QueryAnalyzer] = None
def get_default_analyzer() -> QueryAnalyzer:
@@ -33,9 +33,9 @@ def get_default_analyzer() -> QueryAnalyzer:
def extract_temporal_constraint(
query: str,
reference_date: datetime | None = None,
analyzer: QueryAnalyzer | None = None,
) -> tuple[datetime, datetime] | None:
reference_date: Optional[datetime] = None,
analyzer: Optional[QueryAnalyzer] = None,
) -> Optional[Tuple[datetime, datetime]]:
"""
Extract temporal constraint from query.
@@ -55,7 +55,10 @@ def extract_temporal_constraint(
analysis = analyzer.analyze(query, reference_date)
if analysis.temporal_constraint:
result = (analysis.temporal_constraint.start_date, analysis.temporal_constraint.end_date)
result = (
analysis.temporal_constraint.start_date,
analysis.temporal_constraint.end_date
)
return result
return None
@@ -2,35 +2,41 @@
Think operation utilities for formulating answers based on agent and world facts.
"""
import asyncio
import logging
import re
from datetime import datetime
from datetime import datetime, timezone
from typing import Dict, List, Any
from pydantic import BaseModel, Field
from ..response_models import DispositionTraits, MemoryFact
from ..response_models import ReflectResult, MemoryFact, DispositionTraits
logger = logging.getLogger(__name__)
class Opinion(BaseModel):
"""An opinion formed by the bank."""
opinion: str = Field(description="The opinion or perspective with reasoning included")
confidence: float = Field(description="Confidence score for this opinion (0.0 to 1.0, where 1.0 is very confident)")
class OpinionExtractionResponse(BaseModel):
"""Response containing extracted opinions."""
opinions: list[Opinion] = Field(
default_factory=list, description="List of opinions formed with their supporting reasons and confidence scores"
opinions: List[Opinion] = Field(
default_factory=list,
description="List of opinions formed with their supporting reasons and confidence scores"
)
def describe_trait_level(value: int) -> str:
"""Convert trait value (1-5) to descriptive text."""
levels = {1: "very low", 2: "low", 3: "moderate", 4: "high", 5: "very high"}
levels = {
1: "very low",
2: "low",
3: "moderate",
4: "high",
5: "very high"
}
return levels.get(value, "moderate")
@@ -41,7 +47,7 @@ def build_disposition_description(disposition: DispositionTraits) -> str:
2: "You tend to trust information but may question obvious inconsistencies.",
3: "You have a balanced approach to information, neither too trusting nor too skeptical.",
4: "You are somewhat skeptical and often question the reliability of information.",
5: "You are highly skeptical and critically examine all information for accuracy and hidden motives.",
5: "You are highly skeptical and critically examine all information for accuracy and hidden motives."
}
literalism_desc = {
@@ -49,7 +55,7 @@ def build_disposition_description(disposition: DispositionTraits) -> str:
2: "You tend to consider context and implied meaning alongside literal statements.",
3: "You balance literal interpretation with contextual understanding.",
4: "You prefer to interpret information more literally and precisely.",
5: "You interpret information very literally and focus on exact wording and commitments.",
5: "You interpret information very literally and focus on exact wording and commitments."
}
empathy_desc = {
@@ -57,7 +63,7 @@ def build_disposition_description(disposition: DispositionTraits) -> str:
2: "You consider facts first but acknowledge emotional factors exist.",
3: "You balance factual analysis with emotional understanding.",
4: "You give significant weight to emotional context and human factors.",
5: "You strongly consider the emotional state and circumstances of others when forming memories.",
5: "You strongly consider the emotional state and circumstances of others when forming memories."
}
return f"""Your disposition traits:
@@ -66,7 +72,7 @@ def build_disposition_description(disposition: DispositionTraits) -> str:
- Empathy ({describe_trait_level(disposition.empathy)}): {empathy_desc.get(disposition.empathy, empathy_desc[3])}"""
def format_facts_for_prompt(facts: list[MemoryFact]) -> str:
def format_facts_for_prompt(facts: List[MemoryFact]) -> str:
"""Format facts as JSON for LLM prompt."""
import json
@@ -74,7 +80,9 @@ def format_facts_for_prompt(facts: list[MemoryFact]) -> str:
return "[]"
formatted = []
for fact in facts:
fact_obj = {"text": fact.text}
fact_obj = {
"text": fact.text
}
# Add context if available
if fact.context:
@@ -86,7 +94,7 @@ def format_facts_for_prompt(facts: list[MemoryFact]) -> str:
if isinstance(occurred_start, str):
fact_obj["occurred_start"] = occurred_start
elif isinstance(occurred_start, datetime):
fact_obj["occurred_start"] = occurred_start.strftime("%Y-%m-%d %H:%M:%S")
fact_obj["occurred_start"] = occurred_start.strftime('%Y-%m-%d %H:%M:%S')
formatted.append(fact_obj)
@@ -101,7 +109,7 @@ def build_think_prompt(
name: str,
disposition: DispositionTraits,
background: str,
context: str | None = None,
context: str = None,
) -> str:
"""Build the think prompt for the LLM."""
disposition_desc = build_disposition_description(disposition)
@@ -168,14 +176,16 @@ def get_system_message(disposition: DispositionTraits) -> str:
elif disposition.empathy <= 2:
instructions.append("Focus on facts and outcomes rather than emotional context.")
disposition_instruction = (
" ".join(instructions) if instructions else "Balance your disposition traits when interpreting information."
)
disposition_instruction = " ".join(instructions) if instructions else "Balance your disposition traits when interpreting information."
return f"You are a person with your own thoughts, experiences, opinions, and disposition. When asked a question, respond naturally like a human would - share what you genuinely think based on what you know and have experienced. {disposition_instruction} Be direct, express your views confidently, and use 'I think', 'I believe', 'in my view', etc. Respond in plain text without markdown formatting."
async def extract_opinions_from_text(llm_config, text: str, query: str) -> list[Opinion]:
async def extract_opinions_from_text(
llm_config,
text: str,
query: str
) -> List[Opinion]:
"""
Extract opinions with reasons and confidence from text using LLM.
@@ -228,14 +238,11 @@ If no genuine opinions are expressed (e.g., the response just says "I don't know
try:
result = await llm_config.call(
messages=[
{
"role": "system",
"content": "You are converting opinions from text into first-person statements. Always use 'I think', 'I believe', 'I feel', etc. NEVER use third-person like 'The speaker' or 'They'.",
},
{"role": "user", "content": extraction_prompt},
{"role": "system", "content": "You are converting opinions from text into first-person statements. Always use 'I think', 'I believe', 'I feel', etc. NEVER use third-person like 'The speaker' or 'They'."},
{"role": "user", "content": extraction_prompt}
],
response_format=OpinionExtractionResponse,
scope="memory_extract_opinion",
scope="memory_extract_opinion"
)
# Format opinions with confidence score and convert to first-person
@@ -246,18 +253,14 @@ If no genuine opinions are expressed (e.g., the response just says "I don't know
# Replace common third-person patterns with first-person
def singularize_verb(verb):
if verb.endswith("es"):
if verb.endswith('es'):
return verb[:-1] # believes -> believe
elif verb.endswith("s"):
elif verb.endswith('s'):
return verb[:-1] # thinks -> think
return verb
# Pattern: "The speaker/user [verb]..." -> "I [verb]..."
match = re.match(
r"^(The speaker|The user|They|It is believed) (believes?|thinks?|feels?|says|asserts?|considers?)(\s+that)?(.*)$",
opinion_text,
re.IGNORECASE,
)
match = re.match(r'^(The speaker|The user|They|It is believed) (believes?|thinks?|feels?|says|asserts?|considers?)(\s+that)?(.*)$', opinion_text, re.IGNORECASE)
if match:
verb = singularize_verb(match.group(2))
that_part = match.group(3) or "" # Keep " that" if present
@@ -265,96 +268,17 @@ If no genuine opinions are expressed (e.g., the response just says "I don't know
opinion_text = f"I {verb}{that_part}{rest}"
# If still doesn't start with first-person, prepend "I believe that "
first_person_starters = [
"I think",
"I believe",
"I feel",
"In my view",
"I've come to believe",
"Previously I",
]
first_person_starters = ["I think", "I believe", "I feel", "In my view", "I've come to believe", "Previously I"]
if not any(opinion_text.startswith(starter) for starter in first_person_starters):
opinion_text = "I believe that " + opinion_text[0].lower() + opinion_text[1:]
formatted_opinions.append(Opinion(opinion=opinion_text, confidence=op.confidence))
formatted_opinions.append(Opinion(
opinion=opinion_text,
confidence=op.confidence
))
return formatted_opinions
except Exception as e:
logger.warning(f"Failed to extract opinions: {str(e)}")
return []
async def reflect(
llm_config,
query: str,
experience_facts: list[str] = None,
world_facts: list[str] = None,
opinion_facts: list[str] = None,
name: str = "Assistant",
disposition: DispositionTraits = None,
background: str = "",
context: str = None,
) -> str:
"""
Standalone reflect function for generating answers based on facts.
This is a static version of the reflect operation that can be called
without a MemoryEngine instance, useful for testing.
Args:
llm_config: LLM provider instance
query: Question to answer
experience_facts: List of experience/agent fact strings
world_facts: List of world fact strings
opinion_facts: List of opinion fact strings
name: Name of the agent/persona
disposition: Disposition traits (defaults to neutral)
background: Background information
context: Additional context for the prompt
Returns:
Generated answer text
"""
# Default disposition if not provided
if disposition is None:
disposition = DispositionTraits(skepticism=3, literalism=3, empathy=3)
# Convert string lists to MemoryFact format for formatting
def to_memory_facts(facts: list[str], fact_type: str) -> list[MemoryFact]:
if not facts:
return []
return [MemoryFact(id=f"test-{i}", text=f, fact_type=fact_type) for i, f in enumerate(facts)]
agent_results = to_memory_facts(experience_facts or [], "experience")
world_results = to_memory_facts(world_facts or [], "world")
opinion_results = to_memory_facts(opinion_facts or [], "opinion")
# Format facts for prompt
agent_facts_text = format_facts_for_prompt(agent_results)
world_facts_text = format_facts_for_prompt(world_results)
opinion_facts_text = format_facts_for_prompt(opinion_results)
# Build prompt
prompt = build_think_prompt(
agent_facts_text=agent_facts_text,
world_facts_text=world_facts_text,
opinion_facts_text=opinion_facts_text,
query=query,
name=name,
disposition=disposition,
background=background,
context=context,
)
system_message = get_system_message(disposition)
# Call LLM
answer_text = await llm_config.call(
messages=[{"role": "system", "content": system_message}, {"role": "user", "content": prompt}],
scope="memory_think",
temperature=0.9,
max_completion_tokens=1000,
)
return answer_text.strip()
@@ -4,18 +4,15 @@ Search trace models for debugging and visualization.
These Pydantic models define the structure of search traces, capturing
every step of the spreading activation search process for analysis.
"""
from datetime import datetime
from typing import Any, Literal
from typing import List, Optional, Dict, Any, Literal
from pydantic import BaseModel, Field
class QueryInfo(BaseModel):
"""Information about the search query."""
query_text: str = Field(description="Original query text")
query_embedding: list[float] = Field(description="Generated query embedding vector")
query_embedding: List[float] = Field(description="Generated query embedding vector")
timestamp: datetime = Field(description="When the query was executed")
budget: int = Field(description="Maximum nodes to explore")
max_tokens: int = Field(description="Maximum tokens to return in results")
@@ -23,7 +20,6 @@ class QueryInfo(BaseModel):
class EntryPoint(BaseModel):
"""An entry point node selected for search."""
node_id: str = Field(description="Memory unit ID")
text: str = Field(description="Memory unit text content")
similarity_score: float = Field(description="Cosine similarity to query", ge=0.0, le=1.0)
@@ -32,7 +28,6 @@ class EntryPoint(BaseModel):
class WeightComponents(BaseModel):
"""Breakdown of weight calculation components."""
activation: float = Field(description="Activation from spreading (can exceed 1.0 through accumulation)", ge=0.0)
semantic_similarity: float = Field(description="Semantic similarity to query", ge=0.0, le=1.0)
recency: float = Field(description="Recency weight", ge=0.0, le=1.0)
@@ -48,120 +43,98 @@ class WeightComponents(BaseModel):
class LinkInfo(BaseModel):
"""Information about a link to a neighbor."""
to_node_id: str = Field(description="Target node ID")
link_type: Literal["temporal", "semantic", "entity"] = Field(description="Type of link")
link_weight: float = Field(
description="Weight of the link (can exceed 1.0 when aggregating multiple connections)", ge=0.0
)
entity_id: str | None = Field(default=None, description="Entity ID if link_type is 'entity'")
new_activation: float | None = Field(
default=None, description="Activation that would be passed to neighbor (None for supplementary links)"
)
link_weight: float = Field(description="Weight of the link (can exceed 1.0 when aggregating multiple connections)", ge=0.0)
entity_id: Optional[str] = Field(default=None, description="Entity ID if link_type is 'entity'")
new_activation: Optional[float] = Field(default=None, description="Activation that would be passed to neighbor (None for supplementary links)")
followed: bool = Field(description="Whether this link was followed (or pruned)")
prune_reason: str | None = Field(default=None, description="Why link was not followed (if not followed)")
is_supplementary: bool = Field(
default=False, description="Whether this is a supplementary link (multiple connections to same node)"
)
prune_reason: Optional[str] = Field(default=None, description="Why link was not followed (if not followed)")
is_supplementary: bool = Field(default=False, description="Whether this is a supplementary link (multiple connections to same node)")
class NodeVisit(BaseModel):
"""Information about visiting a node during search."""
step: int = Field(description="Step number in search (1-based)")
node_id: str = Field(description="Memory unit ID")
text: str = Field(description="Memory unit text content")
context: str = Field(description="Memory unit context")
event_date: datetime | None = Field(default=None, description="When the memory occurred")
event_date: Optional[datetime] = Field(default=None, description="When the memory occurred")
access_count: int = Field(description="Number of times accessed before this search")
# How this node was reached
is_entry_point: bool = Field(description="Whether this is an entry point")
parent_node_id: str | None = Field(default=None, description="Node that led to this one")
link_type: Literal["temporal", "semantic", "entity"] | None = Field(
default=None, description="Type of link from parent"
)
link_weight: float | None = Field(default=None, description="Weight of link from parent")
parent_node_id: Optional[str] = Field(default=None, description="Node that led to this one")
link_type: Optional[Literal["temporal", "semantic", "entity"]] = Field(default=None, description="Type of link from parent")
link_weight: Optional[float] = Field(default=None, description="Weight of link from parent")
# Weights
weights: WeightComponents = Field(description="Weight calculation breakdown")
# Neighbors discovered from this node
neighbors_explored: list[LinkInfo] = Field(default_factory=list, description="Links explored from this node")
neighbors_explored: List[LinkInfo] = Field(default_factory=list, description="Links explored from this node")
# Ranking
final_rank: int | None = Field(default=None, description="Final rank in results (1-based, None if not in top-k)")
final_rank: Optional[int] = Field(default=None, description="Final rank in results (1-based, None if not in top-k)")
class PruningDecision(BaseModel):
"""Records when a node was considered but not visited."""
node_id: str = Field(description="Node that was pruned")
reason: Literal["already_visited", "activation_too_low", "budget_exhausted"] = Field(
description="Why it was pruned"
)
reason: Literal["already_visited", "activation_too_low", "budget_exhausted"] = Field(description="Why it was pruned")
activation: float = Field(description="Activation value when pruned")
would_have_been_step: int = Field(description="What step it would have been if visited")
class SearchPhaseMetrics(BaseModel):
"""Performance metrics for a search phase."""
phase_name: str = Field(description="Name of the phase")
duration_seconds: float = Field(description="Time taken in seconds")
details: dict[str, Any] = Field(default_factory=dict, description="Additional phase-specific metrics")
details: Dict[str, Any] = Field(default_factory=dict, description="Additional phase-specific metrics")
class RetrievalResult(BaseModel):
"""A single result from a retrieval method."""
rank: int = Field(description="Rank in this retrieval method (1-based)")
node_id: str = Field(description="Memory unit ID")
text: str = Field(description="Memory unit text content")
context: str = Field(default="", description="Memory unit context")
event_date: datetime | None = Field(default=None, description="When the memory occurred")
fact_type: str | None = Field(default=None, description="Fact type (world, experience, opinion)")
event_date: Optional[datetime] = Field(default=None, description="When the memory occurred")
fact_type: Optional[str] = Field(default=None, description="Fact type (world, experience, opinion)")
score: float = Field(description="Score from this retrieval method")
score_name: str = Field(description="Name of the score (e.g., 'similarity', 'bm25_score', 'activation')")
class RetrievalMethodResults(BaseModel):
"""Results from a single retrieval method."""
method_name: Literal["semantic", "bm25", "graph", "temporal"] = Field(description="Name of retrieval method")
fact_type: str | None = Field(
default=None, description="Fact type this retrieval was for (world, experience, opinion)"
)
results: list[RetrievalResult] = Field(description="Retrieved results with ranks")
results: List[RetrievalResult] = Field(description="Retrieved results with ranks")
duration_seconds: float = Field(description="Time taken for this retrieval")
metadata: dict[str, Any] = Field(default_factory=dict, description="Method-specific metadata")
metadata: Dict[str, Any] = Field(default_factory=dict, description="Method-specific metadata")
class RRFMergeResult(BaseModel):
"""A result after RRF merging."""
node_id: str = Field(description="Memory unit ID")
text: str = Field(description="Memory unit text content")
rrf_score: float = Field(description="Reciprocal Rank Fusion score")
source_ranks: dict[str, int] = Field(description="Rank in each source that contributed (method_name -> rank)")
source_ranks: Dict[str, int] = Field(description="Rank in each source that contributed (method_name -> rank)")
final_rrf_rank: int = Field(description="Rank after RRF merge (1-based)")
class RerankedResult(BaseModel):
"""A result after reranking."""
node_id: str = Field(description="Memory unit ID")
text: str = Field(description="Memory unit text content")
rerank_score: float = Field(description="Final reranking score")
rerank_rank: int = Field(description="Rank after reranking (1-based)")
rrf_rank: int = Field(description="Original RRF rank before reranking")
rank_change: int = Field(description="Change in rank (positive = moved up)")
score_components: dict[str, float] = Field(default_factory=dict, description="Score breakdown")
score_components: Dict[str, float] = Field(default_factory=dict, description="Score breakdown")
class SearchSummary(BaseModel):
"""Summary statistics about the search."""
total_nodes_visited: int = Field(description="Total nodes visited")
total_nodes_pruned: int = Field(description="Total nodes pruned")
entry_points_found: int = Field(description="Number of entry points")
@@ -176,36 +149,33 @@ class SearchSummary(BaseModel):
entity_links_followed: int = Field(default=0, description="Entity links followed")
# Phase timings
phase_metrics: list[SearchPhaseMetrics] = Field(default_factory=list, description="Metrics for each phase")
phase_metrics: List[SearchPhaseMetrics] = Field(default_factory=list, description="Metrics for each phase")
class SearchTrace(BaseModel):
"""Complete trace of a search operation."""
query: QueryInfo = Field(description="Query information")
# New 4-way retrieval architecture
retrieval_results: list[RetrievalMethodResults] = Field(
default_factory=list, description="Results from each retrieval method"
)
rrf_merged: list[RRFMergeResult] = Field(default_factory=list, description="Results after RRF merging")
reranked: list[RerankedResult] = Field(default_factory=list, description="Results after reranking")
retrieval_results: List[RetrievalMethodResults] = Field(default_factory=list, description="Results from each retrieval method")
rrf_merged: List[RRFMergeResult] = Field(default_factory=list, description="Results after RRF merging")
reranked: List[RerankedResult] = Field(default_factory=list, description="Results after reranking")
# Legacy fields (kept for backward compatibility with graph/temporal visualizations)
entry_points: list[EntryPoint] = Field(
default_factory=list, description="Entry points selected for search (legacy)"
)
visits: list[NodeVisit] = Field(
default_factory=list, description="All nodes visited during search (legacy, for graph viz)"
)
pruned: list[PruningDecision] = Field(default_factory=list, description="Nodes that were pruned (legacy)")
entry_points: List[EntryPoint] = Field(default_factory=list, description="Entry points selected for search (legacy)")
visits: List[NodeVisit] = Field(default_factory=list, description="All nodes visited during search (legacy, for graph viz)")
pruned: List[PruningDecision] = Field(default_factory=list, description="Nodes that were pruned (legacy)")
summary: SearchSummary = Field(description="Summary statistics")
# Final results (for comparison with visits)
final_results: list[dict[str, Any]] = Field(description="Final ranked results returned to user")
final_results: List[Dict[str, Any]] = Field(description="Final ranked results returned to user")
model_config = {"json_encoders": {datetime: lambda v: v.isoformat()}}
model_config = {
"json_encoders": {
datetime: lambda v: v.isoformat()
}
}
def to_json(self, **kwargs) -> str:
"""Export trace as JSON string."""
@@ -215,14 +185,14 @@ class SearchTrace(BaseModel):
"""Export trace as dictionary."""
return self.model_dump()
def get_visit_by_node_id(self, node_id: str) -> NodeVisit | None:
def get_visit_by_node_id(self, node_id: str) -> Optional[NodeVisit]:
"""Find a visit by node ID."""
for visit in self.visits:
if visit.node_id == node_id:
return visit
return None
def get_search_path_to_node(self, node_id: str) -> list[NodeVisit]:
def get_search_path_to_node(self, node_id: str) -> List[NodeVisit]:
"""Get the path from entry point to a specific node."""
path = []
current_visit = self.get_visit_by_node_id(node_id)
@@ -236,10 +206,10 @@ class SearchTrace(BaseModel):
return path
def get_nodes_by_link_type(self, link_type: Literal["temporal", "semantic", "entity"]) -> list[NodeVisit]:
def get_nodes_by_link_type(self, link_type: Literal["temporal", "semantic", "entity"]) -> List[NodeVisit]:
"""Get all nodes reached via a specific link type."""
return [v for v in self.visits if v.link_type == link_type]
def get_entry_point_nodes(self) -> list[NodeVisit]:
def get_entry_point_nodes(self) -> List[NodeVisit]:
"""Get all entry point visits."""
return [v for v in self.visits if v.is_entry_point]
@@ -4,25 +4,24 @@ Search tracer for collecting detailed search execution traces.
The SearchTracer collects comprehensive information about each step
of the spreading activation search process for debugging and visualization.
"""
import time
from datetime import UTC, datetime
from typing import Any, Literal
from datetime import datetime, timezone
from typing import List, Optional, Dict, Any, Literal
from .trace import (
EntryPoint,
LinkInfo,
NodeVisit,
PruningDecision,
QueryInfo,
RerankedResult,
RetrievalMethodResults,
RetrievalResult,
RRFMergeResult,
SearchPhaseMetrics,
SearchSummary,
SearchTrace,
QueryInfo,
EntryPoint,
NodeVisit,
WeightComponents,
LinkInfo,
PruningDecision,
SearchSummary,
SearchPhaseMetrics,
RetrievalResult,
RetrievalMethodResults,
RRFMergeResult,
RerankedResult,
)
@@ -59,17 +58,17 @@ class SearchTracer:
self.max_tokens = max_tokens
# Trace data
self.query_embedding: list[float] | None = None
self.start_time: float | None = None
self.entry_points: list[EntryPoint] = []
self.visits: list[NodeVisit] = []
self.pruned: list[PruningDecision] = []
self.phase_metrics: list[SearchPhaseMetrics] = []
self.query_embedding: Optional[List[float]] = None
self.start_time: Optional[float] = None
self.entry_points: List[EntryPoint] = []
self.visits: List[NodeVisit] = []
self.pruned: List[PruningDecision] = []
self.phase_metrics: List[SearchPhaseMetrics] = []
# New 4-way retrieval tracking
self.retrieval_results: list[RetrievalMethodResults] = []
self.rrf_merged: list[RRFMergeResult] = []
self.reranked: list[RerankedResult] = []
self.retrieval_results: List[RetrievalMethodResults] = []
self.rrf_merged: List[RRFMergeResult] = []
self.reranked: List[RerankedResult] = []
# Tracking state
self.current_step = 0
@@ -84,7 +83,7 @@ class SearchTracer:
"""Start timing the search."""
self.start_time = time.time()
def record_query_embedding(self, embedding: list[float]):
def record_query_embedding(self, embedding: List[float]):
"""Record the query embedding."""
self.query_embedding = embedding
@@ -115,12 +114,12 @@ class SearchTracer:
node_id: str,
text: str,
context: str,
event_date: datetime | None,
event_date: datetime,
access_count: int,
is_entry_point: bool,
parent_node_id: str | None,
link_type: Literal["temporal", "semantic", "entity"] | None,
link_weight: float | None,
parent_node_id: Optional[str],
link_type: Optional[Literal["temporal", "semantic", "entity"]],
link_weight: Optional[float],
activation: float,
semantic_similarity: float,
recency: float,
@@ -200,10 +199,10 @@ class SearchTracer:
to_node_id: str,
link_type: Literal["temporal", "semantic", "entity"],
link_weight: float,
entity_id: str | None,
new_activation: float | None,
entity_id: Optional[str],
new_activation: Optional[float],
followed: bool,
prune_reason: str | None = None,
prune_reason: Optional[str] = None,
is_supplementary: bool = False,
):
"""
@@ -267,7 +266,7 @@ class SearchTracer:
)
)
def add_phase_metric(self, phase_name: str, duration_seconds: float, details: dict[str, Any] | None = None):
def add_phase_metric(self, phase_name: str, duration_seconds: float, details: Optional[Dict[str, Any]] = None):
"""
Record metrics for a search phase.
@@ -287,11 +286,10 @@ class SearchTracer:
def add_retrieval_results(
self,
method_name: Literal["semantic", "bm25", "graph", "temporal"],
results: list[tuple], # List of (doc_id, data) tuples
results: List[tuple], # List of (doc_id, data) tuples
duration_seconds: float,
score_field: str, # e.g., "similarity", "bm25_score"
metadata: dict[str, Any] | None = None,
fact_type: str | None = None,
metadata: Optional[Dict[str, Any]] = None
):
"""
Record results from a single retrieval method.
@@ -302,7 +300,6 @@ class SearchTracer:
duration_seconds: Time taken for this retrieval
score_field: Field name containing the score in data dict
metadata: Optional metadata about this retrieval method
fact_type: Fact type this retrieval was for (world, experience, opinion)
"""
retrieval_results = []
for rank, (doc_id, data) in enumerate(results, start=1):
@@ -316,7 +313,7 @@ class SearchTracer:
text=data.get("text", ""),
context=data.get("context", ""),
event_date=data.get("event_date"),
fact_type=data.get("fact_type") or fact_type,
fact_type=data.get("fact_type"),
score=score,
score_name=score_field,
)
@@ -325,14 +322,13 @@ class SearchTracer:
self.retrieval_results.append(
RetrievalMethodResults(
method_name=method_name,
fact_type=fact_type,
results=retrieval_results,
duration_seconds=duration_seconds,
metadata=metadata or {},
)
)
def add_rrf_merged(self, merged_results: list[tuple]):
def add_rrf_merged(self, merged_results: List[tuple]):
"""
Record RRF merged results.
@@ -351,7 +347,7 @@ class SearchTracer:
)
)
def add_reranked(self, reranked_results: list[dict[str, Any]], rrf_merged: list):
def add_reranked(self, reranked_results: List[Dict[str, Any]], rrf_merged: List):
"""
Record reranked results.
@@ -371,18 +367,8 @@ class SearchTracer:
rank_change = rrf_rank - rank # Positive = moved up
# Extract score components (only include non-None values)
# Keys from ScoredResult.to_dict(): cross_encoder_score, cross_encoder_score_normalized,
# rrf_normalized, temporal, recency, combined_score, weight
score_components = {}
for key in [
"cross_encoder_score",
"cross_encoder_score_normalized",
"rrf_score",
"rrf_normalized",
"temporal",
"recency",
"combined_score",
]:
for key in ["semantic_similarity", "bm25_score", "rrf_score", "recency_normalized", "frequency_normalized", "cross_encoder_score", "cross_encoder_score_normalized"]:
if key in result and result[key] is not None:
score_components[key] = result[key]
@@ -398,7 +384,7 @@ class SearchTracer:
)
)
def finalize(self, final_results: list[dict[str, Any]]) -> SearchTrace:
def finalize(self, final_results: List[Dict[str, Any]]) -> SearchTrace:
"""
Finalize the trace and return the complete SearchTrace object.
@@ -425,7 +411,7 @@ class SearchTracer:
query_info = QueryInfo(
query_text=self.query_text,
query_embedding=self.query_embedding or [],
timestamp=datetime.now(UTC),
timestamp=datetime.now(timezone.utc),
budget=self.budget,
max_tokens=self.max_tokens,
)
@@ -6,8 +6,8 @@ providing type safety and making data flow explicit.
"""
from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any
from datetime import datetime
from typing import Any
@dataclass
@@ -17,29 +17,27 @@ class RetrievalResult:
This represents a raw result from the database query, before merging or reranking.
"""
id: str
text: str
fact_type: str
context: str | None = None
event_date: datetime | None = None
occurred_start: datetime | None = None
occurred_end: datetime | None = None
mentioned_at: datetime | None = None
document_id: str | None = None
chunk_id: str | None = None
context: Optional[str] = None
event_date: Optional[datetime] = None
occurred_start: Optional[datetime] = None
occurred_end: Optional[datetime] = None
mentioned_at: Optional[datetime] = None
document_id: Optional[str] = None
chunk_id: Optional[str] = None
access_count: int = 0
embedding: list[float] | None = None
embedding: Optional[List[float]] = None
# Retrieval-specific scores (only one will be set depending on retrieval method)
similarity: float | None = None # Semantic retrieval
bm25_score: float | None = None # BM25 retrieval
activation: float | None = None # Graph retrieval (spreading activation)
temporal_score: float | None = None # Temporal retrieval
temporal_proximity: float | None = None # Temporal retrieval
similarity: Optional[float] = None # Semantic/graph retrieval
bm25_score: Optional[float] = None # BM25 retrieval
temporal_score: Optional[float] = None # Temporal retrieval
temporal_proximity: Optional[float] = None # Temporal retrieval
@classmethod
def from_db_row(cls, row: dict[str, Any]) -> "RetrievalResult":
def from_db_row(cls, row: Dict[str, Any]) -> "RetrievalResult":
"""Create from a database row (asyncpg Record converted to dict)."""
return cls(
id=str(row["id"]),
@@ -56,7 +54,6 @@ class RetrievalResult:
embedding=row.get("embedding"),
similarity=row.get("similarity"),
bm25_score=row.get("bm25_score"),
activation=row.get("activation"),
temporal_score=row.get("temporal_score"),
temporal_proximity=row.get("temporal_proximity"),
)
@@ -69,14 +66,13 @@ class MergedCandidate:
Contains the original retrieval data plus RRF metadata.
"""
# Original retrieval data
retrieval: RetrievalResult
# RRF metadata
rrf_score: float
rrf_rank: int = 0
source_ranks: dict[str, int] = field(default_factory=dict) # method_name -> rank
source_ranks: Dict[str, int] = field(default_factory=dict) # method_name -> rank
@property
def id(self) -> str:
@@ -91,7 +87,6 @@ class ScoredResult:
Contains all retrieval/merge data plus reranking scores and combined score.
"""
# Original merged candidate
candidate: MergedCandidate
@@ -118,7 +113,7 @@ class ScoredResult:
"""Convenience property to access retrieval data."""
return self.candidate.retrieval
def to_dict(self) -> dict[str, Any]:
def to_dict(self) -> Dict[str, Any]:
"""
Convert to dict for backwards compatibility.
@@ -157,7 +152,6 @@ class ScoredResult:
result["cross_encoder_score"] = self.cross_encoder_score
result["cross_encoder_score_normalized"] = self.cross_encoder_score_normalized
result["rrf_normalized"] = self.rrf_normalized
result["temporal"] = self.temporal
result["recency"] = self.recency
result["combined_score"] = self.combined_score
result["weight"] = self.weight
@@ -6,12 +6,10 @@ This provides an abstraction that can be adapted to different execution models:
- Pub/Sub architectures (future)
- Message brokers (future)
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, Callable, Awaitable
import asyncio
import logging
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from typing import Any
logger = logging.getLogger(__name__)
@@ -31,10 +29,10 @@ class TaskBackend(ABC):
def __init__(self):
"""Initialize the task backend."""
self._executor: Callable[[dict[str, Any]], Awaitable[None]] | None = None
self._executor: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None
self._initialized = False
def set_executor(self, executor: Callable[[dict[str, Any]], Awaitable[None]]):
def set_executor(self, executor: Callable[[Dict[str, Any]], Awaitable[None]]):
"""
Set the executor callback for processing tasks.
@@ -51,7 +49,7 @@ class TaskBackend(ABC):
pass
@abstractmethod
async def submit_task(self, task_dict: dict[str, Any]):
async def submit_task(self, task_dict: Dict[str, Any]):
"""
Submit a task for execution.
@@ -67,7 +65,7 @@ class TaskBackend(ABC):
"""
pass
async def _execute_task(self, task_dict: dict[str, Any]):
async def _execute_task(self, task_dict: Dict[str, Any]):
"""
Execute a task through the registered executor.
@@ -75,52 +73,19 @@ class TaskBackend(ABC):
task_dict: Task dictionary to execute
"""
if self._executor is None:
task_type = task_dict.get("type", "unknown")
task_type = task_dict.get('type', 'unknown')
logger.warning(f"No executor registered, skipping task {task_type}")
return
try:
await self._executor(task_dict)
except Exception as e:
task_type = task_dict.get("type", "unknown")
task_type = task_dict.get('type', 'unknown')
logger.error(f"Error executing task {task_type}: {e}")
import traceback
traceback.print_exc()
class SyncTaskBackend(TaskBackend):
"""
Synchronous task backend that executes tasks immediately.
This is useful for embedded/CLI usage where we don't want background
workers that prevent clean exit. Tasks are executed inline rather than
being queued.
"""
async def initialize(self):
"""No-op for sync backend."""
self._initialized = True
logger.debug("SyncTaskBackend initialized")
async def submit_task(self, task_dict: dict[str, Any]):
"""
Execute the task immediately (synchronously).
Args:
task_dict: Task dictionary to execute
"""
if not self._initialized:
await self.initialize()
await self._execute_task(task_dict)
async def shutdown(self):
"""No-op for sync backend."""
self._initialized = False
logger.debug("SyncTaskBackend shutdown")
class AsyncIOQueueBackend(TaskBackend):
"""
Task backend implementation using asyncio queues.
@@ -129,7 +94,11 @@ class AsyncIOQueueBackend(TaskBackend):
and a periodic consumer worker.
"""
def __init__(self, batch_size: int = 100, batch_interval: float = 1.0):
def __init__(
self,
batch_size: int = 100,
batch_interval: float = 1.0
):
"""
Initialize AsyncIO queue backend.
@@ -138,9 +107,9 @@ class AsyncIOQueueBackend(TaskBackend):
batch_interval: Maximum time (seconds) to wait before processing batch
"""
super().__init__()
self._queue: asyncio.Queue | None = None
self._worker_task: asyncio.Task | None = None
self._shutdown_event: asyncio.Event | None = None
self._queue: Optional[asyncio.Queue] = None
self._worker_task: Optional[asyncio.Task] = None
self._shutdown_event: Optional[asyncio.Event] = None
self._batch_size = batch_size
self._batch_interval = batch_interval
@@ -155,7 +124,7 @@ class AsyncIOQueueBackend(TaskBackend):
self._initialized = True
logger.info("AsyncIOQueueBackend initialized")
async def submit_task(self, task_dict: dict[str, Any]):
async def submit_task(self, task_dict: Dict[str, Any]):
"""
Submit a task by putting it in the queue.
@@ -166,8 +135,8 @@ class AsyncIOQueueBackend(TaskBackend):
await self.initialize()
await self._queue.put(task_dict)
task_type = task_dict.get("type", "unknown")
task_id = task_dict.get("id")
task_type = task_dict.get('type', 'unknown')
task_id = task_dict.get('id')
async def wait_for_pending_tasks(self, timeout: float = 5.0):
"""
@@ -231,16 +200,20 @@ class AsyncIOQueueBackend(TaskBackend):
while len(tasks) < self._batch_size and asyncio.get_event_loop().time() < deadline:
try:
remaining_time = max(0.1, deadline - asyncio.get_event_loop().time())
task_dict = await asyncio.wait_for(self._queue.get(), timeout=remaining_time)
task_dict = await asyncio.wait_for(
self._queue.get(),
timeout=remaining_time
)
tasks.append(task_dict)
except TimeoutError:
except asyncio.TimeoutError:
break
# Process batch
if tasks:
# Execute tasks concurrently
await asyncio.gather(
*[self._execute_task(task_dict) for task_dict in tasks], return_exceptions=True
*[self._execute_task(task_dict) for task_dict in tasks],
return_exceptions=True
)
except asyncio.CancelledError:
+10 -25
View File
@@ -1,10 +1,9 @@
"""
Utility functions for memory system.
"""
import logging
from datetime import datetime
from typing import TYPE_CHECKING
from typing import List, Dict, TYPE_CHECKING
if TYPE_CHECKING:
from .llm_wrapper import LLMConfig
@@ -13,14 +12,7 @@ if TYPE_CHECKING:
from .retain.fact_extraction import extract_facts_from_text
async def extract_facts(
text: str,
event_date: datetime,
context: str = "",
llm_config: "LLMConfig" = None,
agent_name: str = None,
extract_opinions: bool = False,
) -> tuple[list["Fact"], list[tuple[str, int]]]:
async def extract_facts(text: str, event_date: datetime, context: str = "", llm_config: 'LLMConfig' = None, agent_name: str = None, extract_opinions: bool = False) -> tuple[List['Fact'], List[tuple[str, int]]]:
"""
Extract semantic facts from text using LLM.
@@ -49,25 +41,16 @@ async def extract_facts(
if not text or not text.strip():
return [], []
facts, chunks = await extract_facts_from_text(
text,
event_date,
context=context,
llm_config=llm_config,
agent_name=agent_name,
extract_opinions=extract_opinions,
)
facts, chunks = await extract_facts_from_text(text, event_date, context=context, llm_config=llm_config, agent_name=agent_name, extract_opinions=extract_opinions)
if not facts:
logging.warning(
f"LLM extracted 0 facts from text of length {len(text)}. This may indicate the text contains no meaningful information, or the LLM failed to extract facts. Full text: {text}"
)
logging.warning(f"LLM extracted 0 facts from text of length {len(text)}. This may indicate the text contains no meaningful information, or the LLM failed to extract facts. Full text: {text}")
return [], chunks
return facts, chunks
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
def cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
"""
Calculate cosine similarity between two vectors.
@@ -117,7 +100,6 @@ def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -
Weight between 0 and 1
"""
import math
# Logarithmic decay: 1 / (1 + log(1 + days_since/half_life))
# This decays much slower than exponential, giving better long-term differentiation
normalized_age = days_since / half_life_days
@@ -139,7 +121,6 @@ def calculate_frequency_weight(access_count: int, max_boost: float = 2.0) -> flo
Weight between 1.0 and max_boost
"""
import math
if access_count <= 0:
return 1.0
@@ -177,7 +158,11 @@ def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime)
return midpoint
def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float:
def calculate_temporal_proximity(
anchor_a: datetime,
anchor_b: datetime,
half_life_days: float = 30.0
) -> float:
"""
Calculate temporal proximity between two temporal anchors.
@@ -1,66 +0,0 @@
"""
Hindsight Extensions System.
Extensions allow customizing and extending Hindsight behavior without modifying core code.
Extensions are loaded via environment variables pointing to implementation classes.
Example:
HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator
HINDSIGHT_API_OPERATION_VALIDATOR_MAX_RETRIES=3
HINDSIGHT_API_HTTP_EXTENSION=mypackage.http:MyHttpExtension
HINDSIGHT_API_HTTP_SOME_CONFIG=value
Extensions receive an ExtensionContext that provides a controlled API for interacting
with the system (e.g., running migrations for tenant schemas).
"""
from hindsight_api.extensions.base import Extension
from hindsight_api.extensions.builtin import ApiKeyTenantExtension
from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionContext
from hindsight_api.extensions.http import HttpExtension
from hindsight_api.extensions.loader import load_extension
from hindsight_api.extensions.operation_validator import (
OperationValidationError,
OperationValidatorExtension,
RecallContext,
RecallResult,
ReflectContext,
ReflectResultContext,
RetainContext,
RetainResult,
ValidationResult,
)
from hindsight_api.extensions.tenant import (
AuthenticationError,
TenantContext,
TenantExtension,
)
from hindsight_api.models import RequestContext
__all__ = [
# Base
"Extension",
"load_extension",
# Context
"ExtensionContext",
"DefaultExtensionContext",
# HTTP Extension
"HttpExtension",
# Operation Validator
"OperationValidationError",
"OperationValidatorExtension",
"RecallContext",
"RecallResult",
"ReflectContext",
"ReflectResultContext",
"RetainContext",
"RetainResult",
"ValidationResult",
# Tenant/Auth
"ApiKeyTenantExtension",
"AuthenticationError",
"RequestContext",
"TenantContext",
"TenantExtension",
]
@@ -1,81 +0,0 @@
"""Base Extension class for all Hindsight extensions."""
from abc import ABC
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from hindsight_api.extensions.context import ExtensionContext
class Extension(ABC):
"""
Base class for all Hindsight extensions.
Extensions are loaded via environment variables and receive configuration
from prefixed environment variables.
Example:
HINDSIGHT_API_MY_EXTENSION=mypackage.ext:MyExtension
HINDSIGHT_API_MY_SOME_CONFIG=value
The extension receives: {"some_config": "value"}
Extensions also receive an ExtensionContext that provides a controlled API
for interacting with the system (e.g., running migrations for tenant schemas).
"""
def __init__(self, config: dict[str, str]):
"""
Initialize the extension with configuration.
Args:
config: Dictionary of configuration values from environment variables.
Keys are lowercased with the prefix stripped.
"""
self.config = config
self._context: "ExtensionContext | None" = None
def set_context(self, context: "ExtensionContext") -> None:
"""
Set the extension context.
Called by the extension loader after instantiation.
Extensions should not call this directly.
Args:
context: The ExtensionContext providing system APIs.
"""
self._context = context
@property
def context(self) -> "ExtensionContext":
"""
Get the extension context.
Returns:
The ExtensionContext providing system APIs.
Raises:
RuntimeError: If context has not been set yet.
"""
if self._context is None:
raise RuntimeError(
"Extension context not set. Context is available after the extension is loaded by the system."
)
return self._context
async def on_startup(self) -> None:
"""
Called when the application starts.
Override to perform initialization tasks like connecting to external services.
"""
pass
async def on_shutdown(self) -> None:
"""
Called when the application shuts down.
Override to perform cleanup tasks like closing connections.
"""
pass
@@ -1,18 +0,0 @@
"""
Built-in extension implementations.
These are ready-to-use implementations of the extension interfaces.
They can be used directly or serve as examples for custom implementations.
Available built-in extensions:
- ApiKeyTenantExtension: Simple API key validation with public schema
Example usage:
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
"""
from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension
__all__ = [
"ApiKeyTenantExtension",
]
@@ -1,33 +0,0 @@
"""Built-in tenant extension implementations."""
from hindsight_api.extensions.tenant import AuthenticationError, TenantContext, TenantExtension
from hindsight_api.models import RequestContext
class ApiKeyTenantExtension(TenantExtension):
"""
Built-in tenant extension that validates API key against an environment variable.
This is a simple implementation that:
1. Validates the API key matches HINDSIGHT_API_TENANT_API_KEY
2. Returns 'public' as the schema for all authenticated requests
Configuration:
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
For multi-tenant setups with separate schemas per tenant, implement a custom
TenantExtension that looks up the schema based on the API key or token claims.
"""
def __init__(self, config: dict[str, str]):
super().__init__(config)
self.expected_api_key = config.get("api_key")
if not self.expected_api_key:
raise ValueError("HINDSIGHT_API_TENANT_API_KEY is required when using ApiKeyTenantExtension")
async def authenticate(self, context: RequestContext) -> TenantContext:
"""Validate API key and return public schema context."""
if context.api_key != self.expected_api_key:
raise AuthenticationError("Invalid API key")
return TenantContext(schema_name="public")
@@ -1,110 +0,0 @@
"""Extension context providing a controlled API for extensions to interact with the system."""
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from hindsight_api.engine.interface import MemoryEngineInterface
class ExtensionContext(ABC):
"""
Abstract context providing a controlled API for extensions.
Extensions receive this context instead of direct access to internal
components like MemoryEngine or database connections. This provides:
- A stable API that won't break when internals change
- Security by limiting what extensions can access
- Clear documentation of what extensions can do
Built-in implementation:
hindsight_api.extensions.builtin.context.DefaultExtensionContext
Example usage in an extension:
class MyTenantExtension(TenantExtension):
async def on_startup(self) -> None:
# Run migrations for a new tenant schema
await self.context.run_migration("tenant_acme")
class MyHttpExtension(HttpExtension):
def get_router(self, memory):
# Use memory engine for custom endpoints
engine = self.context.get_memory_engine()
...
"""
@abstractmethod
async def run_migration(self, schema: str) -> None:
"""
Run database migrations for a specific schema.
This creates the schema if it doesn't exist and runs all pending
migrations. Uses advisory locks to coordinate between distributed workers.
Args:
schema: PostgreSQL schema name (e.g., "tenant_acme").
The schema will be created if it doesn't exist.
Raises:
RuntimeError: If migrations fail to complete.
Example:
# Provision a new tenant schema
await context.run_migration("tenant_acme")
"""
...
@abstractmethod
def get_memory_engine(self) -> "MemoryEngineInterface":
"""
Get the memory engine interface.
Returns the MemoryEngineInterface for performing memory operations
like retain, recall, reflect, and entity/document management.
Returns:
MemoryEngineInterface instance.
Example:
engine = context.get_memory_engine()
result = await engine.recall_async(bank_id, query)
"""
...
class DefaultExtensionContext(ExtensionContext):
"""
Default implementation of ExtensionContext.
Uses the system's database URL and migration infrastructure.
"""
def __init__(
self,
database_url: str,
memory_engine: "MemoryEngineInterface | None" = None,
):
"""
Initialize the context.
Args:
database_url: SQLAlchemy database URL for migrations.
memory_engine: Optional MemoryEngine instance for memory operations.
"""
self._database_url = database_url
self._memory_engine = memory_engine
async def run_migration(self, schema: str) -> None:
"""Run migrations for a specific schema."""
from hindsight_api.migrations import run_migrations
run_migrations(self._database_url, schema=schema)
def get_memory_engine(self) -> "MemoryEngineInterface":
"""Get the memory engine interface."""
if self._memory_engine is None:
raise RuntimeError(
"Memory engine not configured in ExtensionContext. "
"Ensure the context was created with a memory_engine parameter."
)
return self._memory_engine
@@ -1,89 +0,0 @@
"""
HTTP Extension for adding custom endpoints to the Hindsight API.
This extension allows adding custom HTTP endpoints under the /ext/ path prefix.
The extension provides a FastAPI router that is mounted on the main application.
"""
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from fastapi import APIRouter
from hindsight_api.extensions.base import Extension
if TYPE_CHECKING:
from hindsight_api import MemoryEngine
class HttpExtension(Extension, ABC):
"""
Base class for HTTP extensions that add custom API endpoints.
HTTP extensions provide a FastAPI router that gets mounted under /ext/.
The extension has full control over the routes, request/response models, and handlers.
Example:
```python
from fastapi import APIRouter
from hindsight_api.extensions import HttpExtension
class MyHttpExtension(HttpExtension):
def get_router(self, memory: MemoryEngine) -> APIRouter:
router = APIRouter()
@router.get("/hello")
async def hello():
return {"message": "Hello from extension!"}
@router.post("/custom/{bank_id}/action")
async def custom_action(bank_id: str):
# Access memory engine for database operations
pool = await memory._get_pool()
# ... custom logic
return {"status": "ok"}
return router
```
The routes will be available at:
- GET /ext/hello
- POST /ext/custom/{bank_id}/action
Configuration via environment variables:
HINDSIGHT_API_HTTP_EXTENSION=mypackage.ext:MyHttpExtension
HINDSIGHT_API_HTTP_SOME_CONFIG=value
The extension receives config: {"some_config": "value"}
"""
@abstractmethod
def get_router(self, memory: "MemoryEngine") -> APIRouter:
"""
Return a FastAPI router with custom endpoints.
The router will be mounted at /ext/ on the main application.
All routes defined in the router will be prefixed with /ext/.
Args:
memory: The MemoryEngine instance for database access and core operations.
Use this to access the connection pool, run queries, or call
memory operations like retain, recall, etc.
Returns:
A FastAPI APIRouter with the custom endpoints defined.
Example:
```python
def get_router(self, memory: MemoryEngine) -> APIRouter:
router = APIRouter(tags=["My Extension"])
@router.get("/status")
async def status():
health = await memory.health_check()
return {"extension": "healthy", "memory": health}
return router
```
"""
pass
@@ -1,125 +0,0 @@
"""Extension loader utilities."""
import importlib
import logging
import os
from typing import TYPE_CHECKING, TypeVar
from hindsight_api.extensions.base import Extension
if TYPE_CHECKING:
from hindsight_api.extensions.context import ExtensionContext
logger = logging.getLogger(__name__)
T = TypeVar("T", bound=Extension)
class ExtensionLoadError(Exception):
"""Raised when an extension fails to load."""
pass
def load_extension(
prefix: str,
base_class: type[T],
env_prefix: str = "HINDSIGHT_API",
context: "ExtensionContext | None" = None,
) -> T | None:
"""
Load an extension from environment variable configuration.
The extension class is specified via {env_prefix}_{prefix}_EXTENSION environment
variable in the format "module.path:ClassName".
Configuration for the extension is collected from all environment variables
matching {env_prefix}_{prefix}_* (excluding the EXTENSION variable itself).
Args:
prefix: The extension prefix (e.g., "OPERATION_VALIDATOR").
base_class: The base class that the extension must inherit from.
env_prefix: The environment variable prefix (default: "HINDSIGHT_API").
context: Optional ExtensionContext to provide system APIs to the extension.
Returns:
An instance of the extension, or None if not configured.
Raises:
ExtensionLoadError: If the extension fails to load or validate.
Example:
HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator
HINDSIGHT_API_OPERATION_VALIDATOR_MAX_REQUESTS=100
ext = load_extension("OPERATION_VALIDATOR", OperationValidatorExtension)
# ext.config == {"max_requests": "100"}
"""
env_var = f"{env_prefix}_{prefix}_EXTENSION"
ext_path = os.getenv(env_var)
if not ext_path:
logger.debug(f"No extension configured for {env_var}")
return None
logger.info(f"Loading extension from {env_var}={ext_path}")
# Parse "module.path:ClassName"
if ":" not in ext_path:
raise ExtensionLoadError(f"Invalid extension path '{ext_path}'. Expected format: 'module.path:ClassName'")
module_path, class_name = ext_path.rsplit(":", 1)
# Import the module
try:
module = importlib.import_module(module_path)
except ImportError as e:
raise ExtensionLoadError(f"Failed to import extension module '{module_path}': {e}") from e
# Get the class
try:
ext_class = getattr(module, class_name)
except AttributeError as e:
raise ExtensionLoadError(f"Extension class '{class_name}' not found in module '{module_path}'") from e
# Validate inheritance
if not isinstance(ext_class, type) or not issubclass(ext_class, base_class):
raise ExtensionLoadError(f"Extension class '{ext_class.__name__}' must inherit from '{base_class.__name__}'")
# Collect configuration from environment variables
config = _collect_config(env_prefix, prefix)
logger.info(f"Loaded extension {ext_class.__name__} with config keys: {list(config.keys())}")
# Instantiate the extension
try:
extension = ext_class(config)
except Exception as e:
raise ExtensionLoadError(f"Failed to instantiate extension '{ext_class.__name__}': {e}") from e
# Set the context if provided
if context is not None:
extension.set_context(context)
logger.debug(f"Set context on extension {ext_class.__name__}")
return extension
def _collect_config(env_prefix: str, prefix: str) -> dict[str, str]:
"""
Collect configuration from environment variables.
Collects all variables matching {env_prefix}_{prefix}_* except for
{env_prefix}_{prefix}_EXTENSION, strips the prefix, and lowercases keys.
"""
config = {}
full_prefix = f"{env_prefix}_{prefix}_"
extension_var = f"{full_prefix}EXTENSION"
for key, value in os.environ.items():
if key.startswith(full_prefix) and key != extension_var:
# Strip prefix and lowercase the key
config_key = key[len(full_prefix) :].lower()
config[config_key] = value
return config
@@ -1,325 +0,0 @@
"""Operation Validator Extension for validating retain/recall/reflect operations."""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Any
from hindsight_api.extensions.base import Extension
if TYPE_CHECKING:
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import RecallResult as RecallResultModel
from hindsight_api.engine.response_models import ReflectResult
from hindsight_api.models import RequestContext
class OperationValidationError(Exception):
"""Raised when an operation fails validation."""
def __init__(self, reason: str):
self.reason = reason
super().__init__(f"Operation validation failed: {reason}")
@dataclass
class ValidationResult:
"""Result of an operation validation."""
allowed: bool
reason: str | None = None
@classmethod
def accept(cls) -> "ValidationResult":
"""Create an accepted validation result."""
return cls(allowed=True)
@classmethod
def reject(cls, reason: str) -> "ValidationResult":
"""Create a rejected validation result with a reason."""
return cls(allowed=False, reason=reason)
# =============================================================================
# Pre-operation Contexts (all user-provided parameters)
# =============================================================================
@dataclass
class RetainContext:
"""Context for a retain operation validation (pre-operation).
Contains ALL user-provided parameters for the retain operation.
"""
bank_id: str
contents: list[dict] # List of {content, context, event_date, document_id}
request_context: "RequestContext"
document_id: str | None = None
fact_type_override: str | None = None
confidence_score: float | None = None
@dataclass
class RecallContext:
"""Context for a recall operation validation (pre-operation).
Contains ALL user-provided parameters for the recall operation.
"""
bank_id: str
query: str
request_context: "RequestContext"
budget: "Budget | None" = None
max_tokens: int = 4096
enable_trace: bool = False
fact_types: list[str] = field(default_factory=list)
question_date: datetime | None = None
include_entities: bool = False
max_entity_tokens: int = 500
include_chunks: bool = False
max_chunk_tokens: int = 8192
@dataclass
class ReflectContext:
"""Context for a reflect operation validation (pre-operation).
Contains ALL user-provided parameters for the reflect operation.
"""
bank_id: str
query: str
request_context: "RequestContext"
budget: "Budget | None" = None
context: str | None = None
# =============================================================================
# Post-operation Contexts (includes results)
# =============================================================================
@dataclass
class RetainResult:
"""Result context for post-retain hook.
Contains the operation parameters and the result.
"""
bank_id: str
contents: list[dict]
request_context: "RequestContext"
document_id: str | None
fact_type_override: str | None
confidence_score: float | None
# Result
unit_ids: list[list[str]] # List of unit IDs per content item
success: bool = True
error: str | None = None
@dataclass
class RecallResult:
"""Result context for post-recall hook.
Contains the operation parameters and the result.
"""
bank_id: str
query: str
request_context: "RequestContext"
budget: "Budget | None"
max_tokens: int
enable_trace: bool
fact_types: list[str]
question_date: datetime | None
include_entities: bool
max_entity_tokens: int
include_chunks: bool
max_chunk_tokens: int
# Result
result: "RecallResultModel | None" = None
success: bool = True
error: str | None = None
@dataclass
class ReflectResultContext:
"""Result context for post-reflect hook.
Contains the operation parameters and the result.
"""
bank_id: str
query: str
request_context: "RequestContext"
budget: "Budget | None"
context: str | None
# Result
result: "ReflectResult | None" = None
success: bool = True
error: str | None = None
class OperationValidatorExtension(Extension, ABC):
"""
Validates and hooks into retain/recall/reflect operations.
This extension allows implementing custom logic such as:
- Rate limiting (pre-operation)
- Quota enforcement (pre-operation)
- Permission checks (pre-operation)
- Content filtering (pre-operation)
- Usage tracking (post-operation)
- Audit logging (post-operation)
- Metrics collection (post-operation)
Enable via environment variable:
HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator
Configuration is passed from prefixed environment variables:
HINDSIGHT_API_OPERATION_VALIDATOR_MAX_REQUESTS=100
-> config = {"max_requests": "100"}
Hook execution order:
1. validate_retain/validate_recall/validate_reflect (pre-operation)
2. [operation executes]
3. on_retain_complete/on_recall_complete/on_reflect_complete (post-operation)
"""
# =========================================================================
# Pre-operation validation hooks (abstract - must be implemented)
# =========================================================================
@abstractmethod
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
"""
Validate a retain operation before execution.
Called before the retain operation is processed. Return ValidationResult.reject()
to prevent the operation from executing.
Args:
ctx: Context containing all user-provided parameters:
- bank_id: Bank identifier
- contents: List of content dicts
- request_context: Request context with auth info
- document_id: Optional document ID
- fact_type_override: Optional fact type override
- confidence_score: Optional confidence score
Returns:
ValidationResult indicating whether the operation is allowed.
"""
...
@abstractmethod
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
"""
Validate a recall operation before execution.
Called before the recall operation is processed. Return ValidationResult.reject()
to prevent the operation from executing.
Args:
ctx: Context containing all user-provided parameters:
- bank_id: Bank identifier
- query: Search query
- request_context: Request context with auth info
- budget: Budget level
- max_tokens: Maximum tokens to return
- enable_trace: Whether to include trace info
- fact_types: List of fact types to search
- question_date: Optional date context for query
- include_entities: Whether to include entity data
- max_entity_tokens: Max tokens for entities
- include_chunks: Whether to include chunks
- max_chunk_tokens: Max tokens for chunks
Returns:
ValidationResult indicating whether the operation is allowed.
"""
...
@abstractmethod
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
"""
Validate a reflect operation before execution.
Called before the reflect operation is processed. Return ValidationResult.reject()
to prevent the operation from executing.
Args:
ctx: Context containing all user-provided parameters:
- bank_id: Bank identifier
- query: Question to answer
- request_context: Request context with auth info
- budget: Budget level
- context: Optional additional context
Returns:
ValidationResult indicating whether the operation is allowed.
"""
...
# =========================================================================
# Post-operation hooks (optional - override to implement)
# =========================================================================
async def on_retain_complete(self, result: RetainResult) -> None:
"""
Called after a retain operation completes (success or failure).
Override this method to implement post-operation logic such as:
- Usage tracking
- Audit logging
- Metrics collection
- Notifications
Args:
result: Result context containing:
- All original operation parameters
- unit_ids: List of created unit IDs (if success)
- success: Whether the operation succeeded
- error: Error message (if failed)
"""
pass
async def on_recall_complete(self, result: RecallResult) -> None:
"""
Called after a recall operation completes (success or failure).
Override this method to implement post-operation logic such as:
- Usage tracking
- Audit logging
- Metrics collection
- Query analytics
Args:
result: Result context containing:
- All original operation parameters
- result: RecallResultModel (if success)
- success: Whether the operation succeeded
- error: Error message (if failed)
"""
pass
async def on_reflect_complete(self, result: ReflectResultContext) -> None:
"""
Called after a reflect operation completes (success or failure).
Override this method to implement post-operation logic such as:
- Usage tracking
- Audit logging
- Metrics collection
- Response analytics
Args:
result: Result context containing:
- All original operation parameters
- result: ReflectResult (if success)
- success: Whether the operation succeeded
- error: Error message (if failed)
"""
pass
@@ -1,63 +0,0 @@
"""Tenant Extension for multi-tenancy and API key authentication."""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from hindsight_api.extensions.base import Extension
from hindsight_api.models import RequestContext
class AuthenticationError(Exception):
"""Raised when authentication fails."""
def __init__(self, reason: str):
self.reason = reason
super().__init__(f"Authentication failed: {reason}")
@dataclass
class TenantContext:
"""
Tenant context returned by authentication.
Contains the PostgreSQL schema name for tenant isolation.
All database queries will use fully-qualified table names
with this schema (e.g., schema_name.memory_units).
"""
schema_name: str
class TenantExtension(Extension, ABC):
"""
Extension for multi-tenancy and API key authentication.
This extension validates incoming requests and returns the tenant context
including the PostgreSQL schema to use for database operations.
Built-in implementation:
hindsight_api.extensions.builtin.tenant.ApiKeyTenantExtension
Enable via environment variable:
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
The returned schema_name is used for fully-qualified table names in queries,
enabling tenant isolation at the database level.
"""
@abstractmethod
async def authenticate(self, context: RequestContext) -> TenantContext:
"""
Authenticate the action context and return tenant context.
Args:
context: The action context containing API key and other auth data.
Returns:
TenantContext with the schema_name for database operations.
Raises:
AuthenticationError: If authentication fails.
"""
...
+48 -111
View File
@@ -4,12 +4,8 @@ Command-line interface for Hindsight API.
Run the server with:
hindsight-api
Run as background daemon:
hindsight-api --daemon
Stop with Ctrl+C.
"""
import argparse
import asyncio
import atexit
@@ -17,20 +13,17 @@ import os
import signal
import sys
import warnings
from typing import Optional
import uvicorn
from . import MemoryEngine
from .api import create_app
from .config import get_config, HindsightConfig
from .banner import print_banner
from .config import HindsightConfig, get_config
from .daemon import (
DEFAULT_DAEMON_PORT,
DEFAULT_IDLE_TIMEOUT,
DaemonLock,
IdleTimeoutMiddleware,
daemonize,
)
print()
print_banner()
# Filter deprecation warnings from third-party libraries
warnings.filterwarnings("ignore", message="websockets.legacy is deprecated")
@@ -40,7 +33,7 @@ warnings.filterwarnings("ignore", message="websockets.server.WebSocketServerProt
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# Global reference for cleanup
_memory: MemoryEngine | None = None
_memory: Optional[MemoryEngine] = None
def _cleanup():
@@ -77,88 +70,62 @@ def main():
# Server options
parser.add_argument(
"--host", default=config.host, help=f"Host to bind to (default: {config.host}, env: HINDSIGHT_API_HOST)"
"--host", default=config.host,
help=f"Host to bind to (default: {config.host}, env: HINDSIGHT_API_HOST)"
)
parser.add_argument(
"--port",
type=int,
default=config.port,
help=f"Port to bind to (default: {config.port}, env: HINDSIGHT_API_PORT)",
"--port", type=int, default=config.port,
help=f"Port to bind to (default: {config.port}, env: HINDSIGHT_API_PORT)"
)
parser.add_argument(
"--log-level",
default=config.log_level,
"--log-level", default=config.log_level,
choices=["critical", "error", "warning", "info", "debug", "trace"],
help=f"Log level (default: {config.log_level}, env: HINDSIGHT_API_LOG_LEVEL)",
help=f"Log level (default: {config.log_level}, env: HINDSIGHT_API_LOG_LEVEL)"
)
# Development options
parser.add_argument("--reload", action="store_true", help="Enable auto-reload on code changes (development only)")
parser.add_argument("--workers", type=int, default=1, help="Number of worker processes (default: 1)")
parser.add_argument(
"--reload", action="store_true",
help="Enable auto-reload on code changes (development only)"
)
parser.add_argument(
"--workers", type=int, default=1,
help="Number of worker processes (default: 1)"
)
# Access log options
parser.add_argument("--access-log", action="store_true", help="Enable access log")
parser.add_argument("--no-access-log", dest="access_log", action="store_false", help="Disable access log (default)")
parser.add_argument(
"--access-log", action="store_true",
help="Enable access log"
)
parser.add_argument(
"--no-access-log", dest="access_log", action="store_false",
help="Disable access log (default)"
)
parser.set_defaults(access_log=False)
# Proxy options
parser.add_argument(
"--proxy-headers", action="store_true", help="Enable X-Forwarded-Proto, X-Forwarded-For headers"
"--proxy-headers", action="store_true",
help="Enable X-Forwarded-Proto, X-Forwarded-For headers"
)
parser.add_argument(
"--forwarded-allow-ips", default=None, help="Comma separated list of IPs to trust with proxy headers"
"--forwarded-allow-ips", default=None,
help="Comma separated list of IPs to trust with proxy headers"
)
# SSL options
parser.add_argument("--ssl-keyfile", default=None, help="SSL key file")
parser.add_argument("--ssl-certfile", default=None, help="SSL certificate file")
# Daemon mode options
parser.add_argument(
"--daemon",
action="store_true",
help=f"Run as background daemon (uses port {DEFAULT_DAEMON_PORT}, auto-exits after idle)",
"--ssl-keyfile", default=None,
help="SSL key file"
)
parser.add_argument(
"--idle-timeout",
type=int,
default=DEFAULT_IDLE_TIMEOUT,
help=f"Idle timeout in seconds before auto-exit in daemon mode (default: {DEFAULT_IDLE_TIMEOUT})",
"--ssl-certfile", default=None,
help="SSL certificate file"
)
args = parser.parse_args()
# Daemon mode handling
if args.daemon:
# Use fixed daemon port
args.port = DEFAULT_DAEMON_PORT
args.host = "127.0.0.1" # Only bind to localhost for security
# Check if another daemon is already running
daemon_lock = DaemonLock()
if not daemon_lock.acquire():
print(f"Daemon already running (PID: {daemon_lock.get_pid()})", file=sys.stderr)
sys.exit(1)
# Fork into background
daemonize()
# Re-acquire lock in child process
daemon_lock = DaemonLock()
if not daemon_lock.acquire():
sys.exit(1)
# Register cleanup to release lock
def release_lock():
daemon_lock.release()
atexit.register(release_lock)
# Print banner (not in daemon mode)
if not args.daemon:
print()
print_banner()
# Configure Python logging based on log level
# Update config with CLI override if provided
if args.log_level != config.log_level:
@@ -178,13 +145,8 @@ def main():
port=args.port,
log_level=args.log_level,
mcp_enabled=config.mcp_enabled,
graph_retriever=config.graph_retriever,
skip_llm_verification=config.skip_llm_verification,
lazy_reranker=config.lazy_reranker,
)
config.configure_logging()
if not args.daemon:
config.log_config()
# Register cleanup handlers
atexit.register(_cleanup)
@@ -203,12 +165,6 @@ def main():
initialize_memory=True,
)
# Wrap with idle timeout middleware in daemon mode
idle_middleware = None
if args.daemon:
idle_middleware = IdleTimeoutMiddleware(app, idle_timeout=args.idle_timeout)
app = idle_middleware
# Prepare uvicorn config
uvicorn_config = {
"app": app,
@@ -232,40 +188,21 @@ def main():
if args.ssl_certfile:
uvicorn_config["ssl_certfile"] = args.ssl_certfile
# Print startup info (not in daemon mode)
if not args.daemon:
from .banner import print_startup_info
print_startup_info(
host=args.host,
port=args.port,
database_url=config.database_url,
llm_provider=config.llm_provider,
llm_model=config.llm_model,
embeddings_provider=config.embeddings_provider,
reranker_provider=config.reranker_provider,
mcp_enabled=config.mcp_enabled,
)
# Start idle checker in daemon mode
if idle_middleware is not None:
# Start the idle checker in a background thread with its own event loop
import threading
from .banner import print_startup_info
print_startup_info(
host=args.host,
port=args.port,
database_url=config.database_url,
llm_provider=config.llm_provider,
llm_model=config.llm_model,
embeddings_provider=config.embeddings_provider,
reranker_provider=config.reranker_provider,
mcp_enabled=config.mcp_enabled,
)
def run_idle_checker():
import time
time.sleep(2) # Wait for uvicorn to start
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(idle_middleware._check_idle())
except Exception:
pass
threading.Thread(target=run_idle_checker, daemon=True).start()
uvicorn.run(**uvicorn_config) # type: ignore[invalid-argument-type] - dict kwargs
uvicorn.run(**uvicorn_config)
if __name__ == "__main__":
-199
View File
@@ -1,199 +0,0 @@
"""
Local MCP server for use with Claude Code (stdio transport).
This runs a fully local Hindsight instance with embedded PostgreSQL (pg0).
No external database or server required.
Run with:
hindsight-local-mcp
Or with uvx:
uvx hindsight-api@latest hindsight-local-mcp
Configure in Claude Code's MCP settings:
{
"mcpServers": {
"hindsight": {
"command": "uvx",
"args": ["hindsight-api@latest", "hindsight-local-mcp"],
"env": {
"HINDSIGHT_API_LLM_API_KEY": "your-openai-key"
}
}
}
}
Environment variables:
HINDSIGHT_API_LLM_API_KEY: Required. API key for LLM provider.
HINDSIGHT_API_LLM_PROVIDER: Optional. LLM provider (default: "openai").
HINDSIGHT_API_LLM_MODEL: Optional. LLM model (default: "gpt-4o-mini").
HINDSIGHT_API_MCP_LOCAL_BANK_ID: Optional. Memory bank ID (default: "mcp").
HINDSIGHT_API_LOG_LEVEL: Optional. Log level (default: "warning").
HINDSIGHT_API_MCP_INSTRUCTIONS: Optional. Additional instructions appended to both retain and recall tools.
Example custom instructions (these are ADDED to the default behavior):
To also store assistant actions:
HINDSIGHT_API_MCP_INSTRUCTIONS="Also store every action you take, including tool calls, code written, and decisions made."
To also store conversation summaries:
HINDSIGHT_API_MCP_INSTRUCTIONS="Also store summaries of important conversations and their outcomes."
"""
import logging
import os
import sys
from mcp.server.fastmcp import FastMCP
from mcp.types import Icon
from hindsight_api.config import (
DEFAULT_MCP_LOCAL_BANK_ID,
DEFAULT_MCP_RECALL_DESCRIPTION,
DEFAULT_MCP_RETAIN_DESCRIPTION,
ENV_MCP_INSTRUCTIONS,
ENV_MCP_LOCAL_BANK_ID,
)
# Configure logging - default to warning to avoid polluting stderr during MCP init
# MCP clients interpret stderr output as errors, so we suppress INFO logs by default
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "warning").lower()
_log_level_map = {
"critical": logging.CRITICAL,
"error": logging.ERROR,
"warning": logging.WARNING,
"info": logging.INFO,
"debug": logging.DEBUG,
}
logging.basicConfig(
level=_log_level_map.get(_log_level_str, logging.WARNING),
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
stream=sys.stderr, # MCP uses stdout for protocol, logs go to stderr
)
logger = logging.getLogger(__name__)
def create_local_mcp_server(bank_id: str, memory=None) -> FastMCP:
"""
Create a stdio MCP server with retain/recall tools.
Args:
bank_id: The memory bank ID to use for all operations.
memory: Optional MemoryEngine instance. If not provided, creates one with pg0.
Returns:
Configured FastMCP server instance.
"""
# Import here to avoid slow startup if just checking --help
from hindsight_api import MemoryEngine
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.models import RequestContext
# Create memory engine with pg0 embedded database if not provided
if memory is None:
memory = MemoryEngine(db_url="pg0://hindsight-mcp")
# Get custom instructions from environment variable (appended to both tools)
extra_instructions = os.environ.get(ENV_MCP_INSTRUCTIONS, "")
retain_description = DEFAULT_MCP_RETAIN_DESCRIPTION
recall_description = DEFAULT_MCP_RECALL_DESCRIPTION
if extra_instructions:
retain_description = f"{DEFAULT_MCP_RETAIN_DESCRIPTION}\n\nAdditional instructions: {extra_instructions}"
recall_description = f"{DEFAULT_MCP_RECALL_DESCRIPTION}\n\nAdditional instructions: {extra_instructions}"
mcp = FastMCP("hindsight")
@mcp.tool(description=retain_description)
async def retain(content: str, context: str = "general") -> dict:
"""
Args:
content: The fact/memory to store (be specific and include relevant details)
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
"""
import asyncio
async def _retain():
try:
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": content, "context": context}],
request_context=RequestContext(),
)
except Exception as e:
logger.error(f"Error storing memory: {e}", exc_info=True)
# Fire and forget - don't block on memory storage
asyncio.create_task(_retain())
return {"status": "accepted", "message": "Memory storage initiated"}
@mcp.tool(description=recall_description)
async def recall(query: str, max_tokens: int = 4096, budget: str = "low") -> dict:
"""
Args:
query: Natural language search query (e.g., "user's food preferences", "what projects is user working on")
max_tokens: Maximum tokens to return in results (default: 4096)
budget: Search budget level - "low", "mid", or "high" (default: "low")
"""
try:
# Map string budget to enum
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
budget_enum = budget_map.get(budget.lower(), Budget.LOW)
search_result = await memory.recall_async(
bank_id=bank_id,
query=query,
fact_type=list(VALID_RECALL_FACT_TYPES),
budget=budget_enum,
max_tokens=max_tokens,
request_context=RequestContext(),
)
return search_result.model_dump()
except Exception as e:
logger.error(f"Error searching: {e}", exc_info=True)
return {"error": str(e), "results": []}
return mcp
async def _initialize_and_run(bank_id: str):
"""Initialize memory and run the MCP server."""
from hindsight_api import MemoryEngine
# Create and initialize memory engine with pg0 embedded database
# Note: We avoid printing to stderr during init as MCP clients show it as "errors"
memory = MemoryEngine(db_url="pg0://hindsight-mcp")
await memory.initialize()
# Create and run the server
mcp = create_local_mcp_server(bank_id, memory=memory)
await mcp.run_stdio_async()
def main():
"""Main entry point for the stdio MCP server."""
import asyncio
from hindsight_api.config import ENV_LLM_API_KEY, get_config
# Check for required environment variables
config = get_config()
if not config.llm_api_key:
print(f"Error: {ENV_LLM_API_KEY} environment variable is required", file=sys.stderr)
print("Set it in your MCP configuration or shell environment", file=sys.stderr)
sys.exit(1)
# Get bank ID from environment, default to "mcp"
bank_id = os.environ.get(ENV_MCP_LOCAL_BANK_ID, DEFAULT_MCP_LOCAL_BANK_ID)
# Note: We don't print to stderr as MCP clients display it as "error output"
# Use HINDSIGHT_API_LOG_LEVEL=debug for verbose startup logging
# Run the async initialization and server
asyncio.run(_initialize_and_run(bank_id))
if __name__ == "__main__":
main()
+30 -44
View File
@@ -6,15 +6,16 @@ This module provides metrics for:
- Token usage (input/output) per operation
- Per-bank granularity via labels
"""
import logging
import time
from typing import Dict, Any, Optional
from contextlib import contextmanager
import time
from opentelemetry import metrics
from opentelemetry.exporter.prometheus import PrometheusMetricReader
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.resources import Resource
from opentelemetry.exporter.prometheus import PrometheusMetricReader
from prometheus_client import REGISTRY
logger = logging.getLogger(__name__)
@@ -38,18 +39,19 @@ def initialize_metrics(service_name: str = "hindsight-api", service_version: str
global _meter
# Create resource with service information
resource = Resource.create(
{
"service.name": service_name,
"service.version": service_version,
}
)
resource = Resource.create({
"service.name": service_name,
"service.version": service_version,
})
# Create Prometheus metric reader
prometheus_reader = PrometheusMetricReader()
# Create meter provider with Prometheus exporter
provider = MeterProvider(resource=resource, metric_readers=[prometheus_reader])
provider = MeterProvider(
resource=resource,
metric_readers=[prometheus_reader]
)
# Set the global meter provider
metrics.set_meter_provider(provider)
@@ -71,19 +73,11 @@ class MetricsCollectorBase:
"""Base class for metrics collectors."""
@contextmanager
def record_operation(self, operation: str, bank_id: str, budget: str | None = None, max_tokens: int | None = None):
def record_operation(self, operation: str, bank_id: str, budget: Optional[str] = None, max_tokens: Optional[int] = None):
"""Context manager to record operation duration and status."""
raise NotImplementedError
def record_tokens(
self,
operation: str,
bank_id: str,
input_tokens: int = 0,
output_tokens: int = 0,
budget: str | None = None,
max_tokens: int | None = None,
):
def record_tokens(self, operation: str, bank_id: str, input_tokens: int = 0, output_tokens: int = 0, budget: Optional[str] = None, max_tokens: Optional[int] = None):
"""Record token usage for an operation."""
raise NotImplementedError
@@ -92,19 +86,11 @@ class NoOpMetricsCollector(MetricsCollectorBase):
"""No-op metrics collector that does nothing. Used when metrics are disabled."""
@contextmanager
def record_operation(self, operation: str, bank_id: str, budget: str | None = None, max_tokens: int | None = None):
def record_operation(self, operation: str, bank_id: str, budget: Optional[str] = None, max_tokens: Optional[int] = None):
"""No-op context manager."""
yield
def record_tokens(
self,
operation: str,
bank_id: str,
input_tokens: int = 0,
output_tokens: int = 0,
budget: str | None = None,
max_tokens: int | None = None,
):
def record_tokens(self, operation: str, bank_id: str, input_tokens: int = 0, output_tokens: int = 0, budget: Optional[str] = None, max_tokens: Optional[int] = None):
"""No-op token recording."""
pass
@@ -122,25 +108,33 @@ class MetricsCollector(MetricsCollectorBase):
# Operation latency histogram (in seconds)
# Records duration of retain, recall, reflect operations
self.operation_duration = self.meter.create_histogram(
name="hindsight.operation.duration", description="Duration of Hindsight operations in seconds", unit="s"
name="hindsight.operation.duration",
description="Duration of Hindsight operations in seconds",
unit="s"
)
# Token usage counters
self.tokens_input = self.meter.create_counter(
name="hindsight.tokens.input", description="Number of input tokens consumed", unit="tokens"
name="hindsight.tokens.input",
description="Number of input tokens consumed",
unit="tokens"
)
self.tokens_output = self.meter.create_counter(
name="hindsight.tokens.output", description="Number of output tokens generated", unit="tokens"
name="hindsight.tokens.output",
description="Number of output tokens generated",
unit="tokens"
)
# Operation counter (success/failure)
self.operation_total = self.meter.create_counter(
name="hindsight.operation.total", description="Total number of operations executed", unit="operations"
name="hindsight.operation.total",
description="Total number of operations executed",
unit="operations"
)
@contextmanager
def record_operation(self, operation: str, bank_id: str, budget: str | None = None, max_tokens: int | None = None):
def record_operation(self, operation: str, bank_id: str, budget: Optional[str] = None, max_tokens: Optional[int] = None):
"""
Context manager to record operation duration and status.
@@ -181,15 +175,7 @@ class MetricsCollector(MetricsCollectorBase):
# Record operation count
self.operation_total.add(1, attributes)
def record_tokens(
self,
operation: str,
bank_id: str,
input_tokens: int = 0,
output_tokens: int = 0,
budget: str | None = None,
max_tokens: int | None = None,
):
def record_tokens(self, operation: str, bank_id: str, input_tokens: int = 0, output_tokens: int = 0, budget: Optional[str] = None, max_tokens: Optional[int] = None):
"""
Record token usage for an operation.
+16 -62
View File
@@ -6,19 +6,16 @@ on application startup. It is designed to be safe for concurrent
execution using PostgreSQL advisory locks to coordinate between
distributed workers.
Supports multi-tenant schema isolation: migrations can target a specific
PostgreSQL schema, allowing each tenant to have isolated tables.
Important: All migrations must be backward-compatible to allow
safe rolling deployments.
No alembic.ini required - all configuration is done programmatically.
"""
import hashlib
import logging
import os
import shutil
from pathlib import Path
from typing import Optional
from alembic import command
from alembic.config import Config
@@ -30,29 +27,11 @@ logger = logging.getLogger(__name__)
MIGRATION_LOCK_ID = 123456789
def _get_schema_lock_id(schema: str) -> int:
"""
Generate a unique advisory lock ID for a schema.
Uses hash of schema name to create a deterministic lock ID.
"""
# Use hash to create a unique lock ID per schema
# Keep within PostgreSQL's bigint range
hash_bytes = hashlib.sha256(schema.encode()).digest()[:8]
return int.from_bytes(hash_bytes, byteorder="big") % (2**31)
def _run_migrations_internal(database_url: str, script_location: str, schema: str | None = None) -> None:
def _run_migrations_internal(database_url: str, script_location: str) -> None:
"""
Internal function to run migrations without locking.
Args:
database_url: SQLAlchemy database URL
script_location: Path to alembic scripts
schema: Target schema (None for default/public)
"""
schema_name = schema or "public"
logger.info(f"Running database migrations to head for schema '{schema_name}'...")
logger.info(f"Running database migrations to head...")
logger.info(f"Database URL: {database_url}")
logger.info(f"Script location: {script_location}")
@@ -72,22 +51,13 @@ def _run_migrations_internal(database_url: str, script_location: str, schema: st
# Set path_separator to avoid deprecation warning
alembic_cfg.set_main_option("path_separator", "os")
# If targeting a specific schema, pass it to env.py via config
# env.py will handle setting search_path and version_table_schema
if schema:
alembic_cfg.set_main_option("target_schema", schema)
# Run migrations
# Run migrations to head (latest version)
command.upgrade(alembic_cfg, "head")
logger.info(f"Database migrations completed successfully for schema '{schema_name}'")
logger.info("Database migrations completed successfully")
def run_migrations(
database_url: str,
script_location: str | None = None,
schema: str | None = None,
) -> None:
def run_migrations(database_url: str, script_location: Optional[str] = None) -> None:
"""
Run database migrations to the latest version using programmatic Alembic configuration.
@@ -96,28 +66,19 @@ def run_migrations(
- Other workers wait for the lock, then verify migrations are complete
- If schema is already up-to-date, this is a fast no-op
Supports multi-tenant schema isolation: when a schema is specified, migrations
run in that schema instead of public. This allows tenant extensions to provision
new tenant schemas with their own isolated tables.
Args:
database_url: SQLAlchemy database URL (e.g., "postgresql://user:pass@host/db")
script_location: Path to alembic migrations directory (e.g., "/path/to/alembic").
If None, defaults to hindsight-api/alembic directory.
schema: Target PostgreSQL schema name. If None, uses default (public).
When specified, creates the schema if needed and runs migrations there.
Raises:
RuntimeError: If migrations fail to complete
FileNotFoundError: If script_location doesn't exist
Example:
# Using default location and public schema
# Using default location (hindsight_api package)
run_migrations("postgresql://user:pass@host/db")
# Run migrations for a specific tenant schema
run_migrations("postgresql://user:pass@host/db", schema="tenant_acme")
# Using custom location (when importing from another project)
run_migrations(
"postgresql://user:pass@host/db",
@@ -136,28 +97,25 @@ def run_migrations(
script_path = Path(script_location)
if not script_path.exists():
raise FileNotFoundError(
f"Alembic script location not found at {script_location}. Database migrations cannot be run."
f"Alembic script location not found at {script_location}. "
"Database migrations cannot be run."
)
# Use schema-specific lock ID for multi-tenant isolation
lock_id = _get_schema_lock_id(schema) if schema else MIGRATION_LOCK_ID
schema_name = schema or "public"
# Use PostgreSQL advisory lock to coordinate between distributed workers
engine = create_engine(database_url)
with engine.connect() as conn:
# pg_advisory_lock blocks until the lock is acquired
# The lock is automatically released when the connection closes
logger.debug(f"Acquiring migration advisory lock for schema '{schema_name}' (id={lock_id})...")
conn.execute(text(f"SELECT pg_advisory_lock({lock_id})"))
logger.debug(f"Acquiring migration advisory lock (id={MIGRATION_LOCK_ID})...")
conn.execute(text(f"SELECT pg_advisory_lock({MIGRATION_LOCK_ID})"))
logger.debug("Migration advisory lock acquired")
try:
# Run migrations while holding the lock
_run_migrations_internal(database_url, script_location, schema=schema)
_run_migrations_internal(database_url, script_location)
finally:
# Explicitly release the lock (also released on connection close)
conn.execute(text(f"SELECT pg_advisory_unlock({lock_id})"))
conn.execute(text(f"SELECT pg_advisory_unlock({MIGRATION_LOCK_ID})"))
logger.debug("Migration advisory lock released")
except FileNotFoundError:
@@ -172,9 +130,7 @@ def run_migrations(
raise RuntimeError("Database migration failed") from e
def check_migration_status(
database_url: str | None = None, script_location: str | None = None
) -> tuple[str | None, str | None]:
def check_migration_status(database_url: Optional[str] = None, script_location: Optional[str] = None) -> tuple[str | None, str | None]:
"""
Check current database schema version and latest available version.
@@ -195,9 +151,7 @@ def check_migration_status(
if database_url is None:
database_url = os.getenv("HINDSIGHT_API_DATABASE_URL")
if not database_url:
logger.warning(
"Database URL not provided and HINDSIGHT_API_DATABASE_URL not set, cannot check migration status"
)
logger.warning("Database URL not provided and HINDSIGHT_API_DATABASE_URL not set, cannot check migration status")
return None, None
# Get current revision from database
+72 -75
View File
@@ -1,62 +1,49 @@
"""
SQLAlchemy models for the memory system.
"""
from dataclasses import dataclass
from datetime import datetime
from uuid import UUID as PyUUID
from typing import Optional
from uuid import UUID as PyUUID, uuid4
@dataclass
class RequestContext:
"""
Context for request authentication and authorization.
This dataclass carries authentication data from HTTP requests to the
memory engine operations. It can be extended to include additional
context like headers, tokens, user info, etc.
"""
api_key: str | None = None
from pgvector.sqlalchemy import Vector
from sqlalchemy import (
CheckConstraint,
Column,
Float,
ForeignKey,
ForeignKeyConstraint,
Index,
Integer,
PrimaryKeyConstraint,
Text,
func,
)
from sqlalchemy import (
text as sql_text,
)
from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP, UUID
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from pgvector.sqlalchemy import Vector
class Base(AsyncAttrs, DeclarativeBase):
"""Base class for all models."""
pass
class Document(Base):
"""Source documents for memory units."""
__tablename__ = "documents"
id: Mapped[str] = mapped_column(Text, primary_key=True)
bank_id: Mapped[str] = mapped_column(Text, primary_key=True)
original_text: Mapped[str | None] = mapped_column(Text)
content_hash: Mapped[str | None] = mapped_column(Text)
original_text: Mapped[Optional[str]] = mapped_column(Text)
content_hash: Mapped[Optional[str]] = mapped_column(Text)
doc_metadata: Mapped[dict] = mapped_column("metadata", JSONB, server_default=sql_text("'{}'::jsonb"))
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
# Relationships
memory_units = relationship("MemoryUnit", back_populates="document", cascade="all, delete-orphan")
@@ -69,42 +56,45 @@ class Document(Base):
class MemoryUnit(Base):
"""Individual sentence-level memories."""
__tablename__ = "memory_units"
id: Mapped[PyUUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, server_default=sql_text("gen_random_uuid()")
)
bank_id: Mapped[str] = mapped_column(Text, nullable=False)
document_id: Mapped[str | None] = mapped_column(Text)
document_id: Mapped[Optional[str]] = mapped_column(Text)
text: Mapped[str] = mapped_column(Text, nullable=False)
embedding = mapped_column(Vector(384)) # pgvector type
context: Mapped[str | None] = mapped_column(Text)
event_date: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), nullable=False
) # Kept for backward compatibility
occurred_start: Mapped[datetime | None] = mapped_column(
TIMESTAMP(timezone=True)
) # When fact occurred (range start)
occurred_end: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) # When fact occurred (range end)
mentioned_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) # When fact was mentioned
context: Mapped[Optional[str]] = mapped_column(Text)
event_date: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) # Kept for backward compatibility
occurred_start: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP(timezone=True)) # When fact occurred (range start)
occurred_end: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP(timezone=True)) # When fact occurred (range end)
mentioned_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP(timezone=True)) # When fact was mentioned
fact_type: Mapped[str] = mapped_column(Text, nullable=False, server_default="world")
confidence_score: Mapped[float | None] = mapped_column(Float)
confidence_score: Mapped[Optional[float]] = mapped_column(Float)
access_count: Mapped[int] = mapped_column(Integer, server_default="0")
unit_metadata: Mapped[dict] = mapped_column(
"metadata", JSONB, server_default=sql_text("'{}'::jsonb")
) # User-defined metadata (str->str)
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
unit_metadata: Mapped[dict] = mapped_column("metadata", JSONB, server_default=sql_text("'{}'::jsonb")) # User-defined metadata (str->str)
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
# Relationships
document = relationship("Document", back_populates="memory_units")
unit_entities = relationship("UnitEntity", back_populates="memory_unit", cascade="all, delete-orphan")
outgoing_links = relationship(
"MemoryLink", foreign_keys="MemoryLink.from_unit_id", back_populates="from_unit", cascade="all, delete-orphan"
"MemoryLink",
foreign_keys="MemoryLink.from_unit_id",
back_populates="from_unit",
cascade="all, delete-orphan"
)
incoming_links = relationship(
"MemoryLink", foreign_keys="MemoryLink.to_unit_id", back_populates="to_unit", cascade="all, delete-orphan"
"MemoryLink",
foreign_keys="MemoryLink.to_unit_id",
back_populates="to_unit",
cascade="all, delete-orphan"
)
__table_args__ = (
@@ -120,7 +110,7 @@ class MemoryUnit(Base):
"(fact_type = 'opinion' AND confidence_score IS NOT NULL) OR "
"(fact_type = 'observation') OR "
"(fact_type NOT IN ('opinion', 'observation') AND confidence_score IS NULL)",
name="confidence_score_fact_type_check",
name="confidence_score_fact_type_check"
),
Index("idx_memory_units_bank_id", "bank_id"),
Index("idx_memory_units_document_id", "document_id"),
@@ -129,46 +119,39 @@ class MemoryUnit(Base):
Index("idx_memory_units_access_count", "access_count", postgresql_ops={"access_count": "DESC"}),
Index("idx_memory_units_fact_type", "fact_type"),
Index("idx_memory_units_bank_fact_type", "bank_id", "fact_type"),
Index(
"idx_memory_units_bank_type_date",
"bank_id",
"fact_type",
"event_date",
postgresql_ops={"event_date": "DESC"},
),
Index("idx_memory_units_bank_type_date", "bank_id", "fact_type", "event_date", postgresql_ops={"event_date": "DESC"}),
Index(
"idx_memory_units_opinion_confidence",
"bank_id",
"confidence_score",
postgresql_where=sql_text("fact_type = 'opinion'"),
postgresql_ops={"confidence_score": "DESC"},
postgresql_ops={"confidence_score": "DESC"}
),
Index(
"idx_memory_units_opinion_date",
"bank_id",
"event_date",
postgresql_where=sql_text("fact_type = 'opinion'"),
postgresql_ops={"event_date": "DESC"},
postgresql_ops={"event_date": "DESC"}
),
Index(
"idx_memory_units_observation_date",
"bank_id",
"event_date",
postgresql_where=sql_text("fact_type = 'observation'"),
postgresql_ops={"event_date": "DESC"},
postgresql_ops={"event_date": "DESC"}
),
Index(
"idx_memory_units_embedding",
"embedding",
postgresql_using="hnsw",
postgresql_ops={"embedding": "vector_cosine_ops"},
postgresql_ops={"embedding": "vector_cosine_ops"}
),
)
class Entity(Base):
"""Resolved entities (people, organizations, locations, etc.)."""
__tablename__ = "entities"
id: Mapped[PyUUID] = mapped_column(
@@ -177,8 +160,12 @@ class Entity(Base):
canonical_name: Mapped[str] = mapped_column(Text, nullable=False)
bank_id: Mapped[str] = mapped_column(Text, nullable=False)
entity_metadata: Mapped[dict] = mapped_column("metadata", JSONB, server_default=sql_text("'{}'::jsonb"))
first_seen: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
last_seen: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
first_seen: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
last_seen: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
mention_count: Mapped[int] = mapped_column(Integer, server_default="1")
# Relationships
@@ -188,13 +175,13 @@ class Entity(Base):
"EntityCooccurrence",
foreign_keys="EntityCooccurrence.entity_id_1",
back_populates="entity_1",
cascade="all, delete-orphan",
cascade="all, delete-orphan"
)
cooccurrences_2 = relationship(
"EntityCooccurrence",
foreign_keys="EntityCooccurrence.entity_id_2",
back_populates="entity_2",
cascade="all, delete-orphan",
cascade="all, delete-orphan"
)
__table_args__ = (
@@ -206,7 +193,6 @@ class Entity(Base):
class UnitEntity(Base):
"""Association between memory units and entities."""
__tablename__ = "unit_entities"
unit_id: Mapped[PyUUID] = mapped_column(
@@ -228,7 +214,6 @@ class UnitEntity(Base):
class EntityCooccurrence(Base):
"""Materialized cache of entity co-occurrences."""
__tablename__ = "entity_cooccurrences"
entity_id_1: Mapped[PyUUID] = mapped_column(
@@ -238,7 +223,9 @@ class EntityCooccurrence(Base):
UUID(as_uuid=True), ForeignKey("entities.id", ondelete="CASCADE"), primary_key=True
)
cooccurrence_count: Mapped[int] = mapped_column(Integer, server_default="1")
last_cooccurred: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
last_cooccurred: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
# Relationships
entity_1 = relationship("Entity", foreign_keys=[entity_id_1], back_populates="cooccurrences_1")
@@ -254,7 +241,6 @@ class EntityCooccurrence(Base):
class MemoryLink(Base):
"""Links between memory units (temporal, semantic, entity)."""
__tablename__ = "memory_links"
from_unit_id: Mapped[PyUUID] = mapped_column(
@@ -264,11 +250,13 @@ class MemoryLink(Base):
UUID(as_uuid=True), ForeignKey("memory_units.id", ondelete="CASCADE"), primary_key=True
)
link_type: Mapped[str] = mapped_column(Text, primary_key=True)
entity_id: Mapped[PyUUID | None] = mapped_column(
entity_id: Mapped[Optional[PyUUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("entities.id", ondelete="CASCADE"), primary_key=True
)
weight: Mapped[float] = mapped_column(Float, nullable=False, server_default="1.0")
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
# Relationships
from_unit = relationship("MemoryUnit", foreign_keys=[from_unit_id], back_populates="outgoing_links")
@@ -278,7 +266,7 @@ class MemoryLink(Base):
__table_args__ = (
CheckConstraint(
"link_type IN ('temporal', 'semantic', 'entity', 'causes', 'caused_by', 'enables', 'prevents')",
name="memory_links_link_type_check",
name="memory_links_link_type_check"
),
CheckConstraint("weight >= 0.0 AND weight <= 1.0", name="memory_links_weight_check"),
Index("idx_memory_links_from", "from_unit_id"),
@@ -290,22 +278,31 @@ class MemoryLink(Base):
"from_unit_id",
"weight",
postgresql_where=sql_text("weight >= 0.1"),
postgresql_ops={"weight": "DESC"},
postgresql_ops={"weight": "DESC"}
),
)
class Bank(Base):
"""Memory bank profiles with disposition traits and background."""
__tablename__ = "banks"
bank_id: Mapped[str] = mapped_column(Text, primary_key=True)
disposition: Mapped[dict] = mapped_column(
JSONB, nullable=False, server_default=sql_text('\'{"skepticism": 3, "literalism": 3, "empathy": 3}\'::jsonb')
JSONB,
nullable=False,
server_default=sql_text(
'\'{"skepticism": 3, "literalism": 3, "empathy": 3}\'::jsonb'
)
)
background: Mapped[str] = mapped_column(Text, nullable=False, server_default="")
created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now())
created_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
TIMESTAMP(timezone=True), server_default=func.now()
)
__table_args__ = (Index("idx_banks_bank_id", "bank_id"),)
__table_args__ = (
Index("idx_banks_bank_id", "bank_id"),
)
+337 -64
View File
@@ -1,134 +1,407 @@
import asyncio
import json
import logging
import os
import platform
import re
import shutil
import stat
import subprocess
from pathlib import Path
from typing import Optional
from pg0 import Pg0
import httpx
logger = logging.getLogger(__name__)
# pg0 configuration
BINARY_NAME = "pg0"
DEFAULT_PORT = 5555
DEFAULT_USERNAME = "hindsight"
DEFAULT_PASSWORD = "hindsight"
DEFAULT_DATABASE = "hindsight"
def get_platform_binary_name() -> str:
"""Get the appropriate binary name for the current platform.
Supported platforms:
- macOS ARM64 (darwin-aarch64)
- Linux x86_64 (gnu)
- Linux ARM64 (gnu)
- Windows x86_64
"""
system = platform.system().lower()
machine = platform.machine().lower()
# Normalize architecture names
if machine in ("x86_64", "amd64"):
arch = "x86_64"
elif machine in ("arm64", "aarch64"):
arch = "aarch64"
else:
raise RuntimeError(
f"Embedded PostgreSQL is not supported on architecture: {machine}. "
f"Supported architectures: x86_64/amd64 (Linux, Windows), aarch64/arm64 (macOS, Linux)"
)
if system == "darwin" and arch == "aarch64":
return "pg0-darwin-aarch64"
elif system == "linux" and arch == "x86_64":
return "pg0-linux-x86_64-gnu"
elif system == "linux" and arch == "aarch64":
return "pg0-linux-aarch64-gnu"
elif system == "windows" and arch == "x86_64":
return "pg0-windows-x86_64.exe"
else:
raise RuntimeError(
f"Embedded PostgreSQL is not supported on {system}-{arch}. "
f"Supported platforms: darwin-aarch64 (macOS ARM), linux-x86_64-gnu, linux-aarch64-gnu, windows-x86_64"
)
def get_download_url(
version: str = "latest",
repo: str = "vectorize-io/pg0",
) -> str:
"""Get the download URL for pg0 binary."""
binary_name = get_platform_binary_name()
if version == "latest":
return f"https://github.com/{repo}/releases/latest/download/{binary_name}"
else:
return f"https://github.com/{repo}/releases/download/{version}/{binary_name}"
def _find_pg0_binary() -> Optional[Path]:
"""Find pg0 binary in PATH or default install location."""
# First check PATH
pg0_in_path = shutil.which("pg0")
if pg0_in_path:
return Path(pg0_in_path)
# Fall back to default install location
default_path = Path.home() / ".hindsight" / "bin" / "pg0"
if default_path.exists() and os.access(default_path, os.X_OK):
return default_path
return None
class EmbeddedPostgres:
"""Manages an embedded PostgreSQL server instance using pg0-embedded."""
"""
Manages an embedded PostgreSQL server instance using pg0.
This class handles:
- Finding or downloading the pg0 CLI
- Starting/stopping the PostgreSQL server
- Getting the connection URI
Example:
pg = EmbeddedPostgres()
await pg.ensure_installed()
await pg.start()
uri = await pg.get_uri()
# ... use uri with asyncpg ...
await pg.stop()
"""
def __init__(
self,
port: int | None = None,
version: str = "latest",
port: int = DEFAULT_PORT,
username: str = DEFAULT_USERNAME,
password: str = DEFAULT_PASSWORD,
database: str = DEFAULT_DATABASE,
name: str = "hindsight",
**kwargs,
):
self.port = port # None means pg0 will auto-assign
"""
Initialize the embedded PostgreSQL manager.
Args:
version: Version of pg0 to download if not found. Defaults to "latest"
port: Port to listen on. Defaults to 5555
username: Username for the database. Defaults to "hindsight"
password: Password for the database. Defaults to "hindsight"
database: Database name to create. Defaults to "hindsight"
name: Instance name for pg0. Defaults to "hindsight"
"""
self.version = version
self.port = port
self.username = username
self.password = password
self.database = database
self.name = name
self._pg0: Pg0 | None = None
def _get_pg0(self) -> Pg0:
if self._pg0 is None:
kwargs = {
"name": self.name,
"username": self.username,
"password": self.password,
"database": self.database,
}
# Only set port if explicitly specified
if self.port is not None:
kwargs["port"] = self.port
self._pg0 = Pg0(**kwargs) # type: ignore[invalid-argument-type] - dict kwargs
return self._pg0
# Will be set when binary is found/installed
self._binary_path: Optional[Path] = _find_pg0_binary()
async def start(self, max_retries: int = 5, retry_delay: float = 4.0) -> str:
"""Start the PostgreSQL server with retry logic."""
port_info = f"port={self.port}" if self.port else "port=auto"
logger.info(f"Starting embedded PostgreSQL (name={self.name}, {port_info})...")
@property
def binary_path(self) -> Path:
"""Get the path to the pg0 binary."""
if self._binary_path is None:
# Default install location
return Path.home() / ".hindsight" / "bin" / "pg0"
return self._binary_path
pg0 = self._get_pg0()
last_error = None
def is_installed(self) -> bool:
"""Check if pg0 is available (in PATH or installed)."""
self._binary_path = _find_pg0_binary()
return self._binary_path is not None
for attempt in range(1, max_retries + 1):
try:
loop = asyncio.get_event_loop()
info = await loop.run_in_executor(None, pg0.start)
# Get URI from pg0 (includes auto-assigned port)
uri = info.uri
logger.info(f"PostgreSQL started: {uri}")
return uri
except Exception as e:
last_error = str(e)
if attempt < max_retries:
delay = retry_delay * (2 ** (attempt - 1))
logger.debug(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error}")
logger.debug(f"Retrying in {delay:.1f}s...")
await asyncio.sleep(delay)
else:
logger.debug(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error}")
async def ensure_installed(self) -> None:
"""
Ensure pg0 is available.
Checks PATH and default location. If not found, raises an error
instructing the user to install pg0 manually.
"""
if self.is_installed():
logger.debug(f"pg0 found at {self._binary_path}")
return
raise RuntimeError(
f"Failed to start embedded PostgreSQL after {max_retries} attempts. Last error: {last_error}"
"pg0 is not installed. Please install it manually:\n"
" curl -fsSL https://github.com/vectorize-io/pg0/releases/latest/download/pg0-linux-amd64 -o ~/.local/bin/pg0 && chmod +x ~/.local/bin/pg0\n"
"Or visit: https://github.com/vectorize-io/pg0/releases"
)
def _run_command(self, *args: str, capture_output: bool = True) -> subprocess.CompletedProcess:
"""Run a pg0 command synchronously."""
cmd = [str(self.binary_path), *args]
return subprocess.run(cmd, capture_output=capture_output, text=True)
async def _run_command_async(self, *args: str, timeout: int = 120) -> tuple[int, str, str]:
"""Run a pg0 command asynchronously."""
cmd = [str(self.binary_path), *args]
def run_sync():
try:
result = subprocess.run(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout,
)
return result.returncode, result.stdout, result.stderr
except subprocess.TimeoutExpired:
return 1, "", "Command timed out"
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, run_sync)
def _extract_uri_from_output(self, output: str) -> Optional[str]:
"""Extract the PostgreSQL URI from pg0 start output."""
match = re.search(r"Connection URI:\s*(postgresql://[^\s]+)", output)
if match:
return match.group(1)
return None
async def _get_version(self) -> str:
"""Get the pg0 version."""
returncode, stdout, stderr = await self._run_command_async("--version", timeout=10)
if returncode == 0 and stdout:
return stdout.strip()
return "unknown"
async def start(self, max_retries: int = 3, retry_delay: float = 2.0) -> str:
"""
Start the PostgreSQL server with retry logic.
Args:
max_retries: Maximum number of start attempts (default: 3)
retry_delay: Initial delay between retries in seconds (default: 2.0)
Returns:
The connection URI for the started server.
Raises:
RuntimeError: If the server fails to start after all retries.
"""
if not self.is_installed():
raise RuntimeError("pg0 is not installed. Call ensure_installed() first.")
# Log pg0 version
version = await self._get_version()
logger.info(f"Starting embedded PostgreSQL with pg0 {version} (name: {self.name}, port: {self.port})...")
last_error = None
for attempt in range(1, max_retries + 1):
returncode, stdout, stderr = await self._run_command_async(
"start",
"--name", self.name,
"--port", str(self.port),
"--username", self.username,
"--password", self.password,
"--database", self.database,
timeout=300,
)
# Try to extract URI from output
uri = self._extract_uri_from_output(stdout)
if uri:
logger.info(f"PostgreSQL started on port {self.port}")
return uri
# Check if pg0 info can find the running instance
try:
uri = await self.get_uri()
logger.info(f"PostgreSQL started on port {self.port}")
return uri
except RuntimeError:
pass
# Start failed, log and retry
last_error = stderr or f"pg0 start returned exit code {returncode}"
if attempt < max_retries:
delay = retry_delay * (2 ** (attempt - 1))
logger.debug(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error.strip()}")
logger.debug(f"Retrying in {delay:.1f}s...")
await asyncio.sleep(delay)
else:
logger.debug(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error.strip()}")
# All retries exhausted - fail
raise RuntimeError(
f"Failed to start embedded PostgreSQL after {max_retries} attempts. "
f"Last error: {last_error.strip() if last_error else 'unknown'}"
)
async def stop(self) -> None:
"""Stop the PostgreSQL server."""
pg0 = self._get_pg0()
if not self.is_installed():
return
logger.info(f"Stopping embedded PostgreSQL (name: {self.name})...")
try:
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, pg0.stop)
logger.info("Embedded PostgreSQL stopped")
except Exception as e:
if "not running" in str(e).lower():
returncode, stdout, stderr = await self._run_command_async("stop", "--name", self.name)
if returncode != 0:
if "not running" in stderr.lower():
return
raise RuntimeError(f"Failed to stop PostgreSQL: {e}")
raise RuntimeError(f"Failed to stop PostgreSQL: {stderr}")
logger.info("Embedded PostgreSQL stopped")
async def _get_info(self) -> dict:
"""Get info from pg0 using the `info -o json` command."""
if not self.is_installed():
raise RuntimeError("pg0 is not installed.")
returncode, stdout, stderr = await self._run_command_async(
"info", "--name", self.name, "-o", "json"
)
if returncode != 0:
raise RuntimeError(f"Failed to get PostgreSQL info: {stderr}")
try:
return json.loads(stdout.strip())
except json.JSONDecodeError as e:
raise RuntimeError(f"Failed to parse pg0 info output: {e}")
async def get_uri(self) -> str:
"""Get the connection URI for the PostgreSQL server."""
pg0 = self._get_pg0()
loop = asyncio.get_event_loop()
info = await loop.run_in_executor(None, pg0.info)
return info.uri
info = await self._get_info()
uri = info.get("uri")
if not uri:
raise RuntimeError("PostgreSQL server is not running or URI not available")
return uri
async def status(self) -> dict:
"""Get the status of the PostgreSQL server."""
if not self.is_installed():
return {"installed": False, "running": False}
try:
info = await self._get_info()
return {
"installed": True,
"running": info.get("running", False),
"uri": info.get("uri"),
}
except RuntimeError:
return {"installed": True, "running": False}
async def is_running(self) -> bool:
"""Check if the PostgreSQL server is currently running."""
if not self.is_installed():
return False
try:
pg0 = self._get_pg0()
loop = asyncio.get_event_loop()
info = await loop.run_in_executor(None, pg0.info)
return info is not None and info.running
except Exception:
info = await self._get_info()
return info.get("running", False)
except RuntimeError:
return False
async def ensure_running(self) -> str:
"""Ensure the PostgreSQL server is running, starting it if needed."""
"""
Ensure the PostgreSQL server is running.
Installs if needed, starts if not running.
Returns:
The connection URI.
"""
await self.ensure_installed()
if await self.is_running():
return await self.get_uri()
return await self.start()
def uninstall(self) -> None:
"""Remove the pg0 binary (only if we installed it)."""
default_path = Path.home() / ".hindsight" / "bin" / "pg0"
if default_path.exists():
default_path.unlink()
logger.info(f"Removed {default_path}")
_default_instance: EmbeddedPostgres | None = None
def clear_data(self) -> None:
"""Remove all PostgreSQL data (destructive!)."""
result = self._run_command("drop", "--name", self.name, "--force")
if result.returncode == 0:
logger.info(f"Dropped pg0 instance {self.name}")
else:
logger.warning(f"Failed to drop pg0 instance {self.name}: {result.stderr}")
# Convenience functions
_default_instance: Optional[EmbeddedPostgres] = None
def get_embedded_postgres() -> EmbeddedPostgres:
"""Get or create the default EmbeddedPostgres instance."""
global _default_instance
if _default_instance is None:
_default_instance = EmbeddedPostgres()
return _default_instance
async def start_embedded_postgres() -> str:
"""Quick start function for embedded PostgreSQL."""
return await get_embedded_postgres().ensure_running()
"""
Quick start function for embedded PostgreSQL.
Downloads, installs, and starts PostgreSQL in one call.
Returns:
Connection URI string
Example:
db_url = await start_embedded_postgres()
conn = await asyncpg.connect(db_url)
"""
pg = get_embedded_postgres()
return await pg.ensure_running()
async def stop_embedded_postgres() -> None:
"""Stop the default embedded PostgreSQL instance."""
global _default_instance
if _default_instance:
await _default_instance.stop()
+6 -3
View File
@@ -6,7 +6,6 @@ This module provides the ASGI app for uvicorn import string usage:
For CLI usage, use the hindsight-api command instead.
"""
import os
import warnings
@@ -30,11 +29,15 @@ config.configure_logging()
_memory = MemoryEngine()
# Create unified app with both HTTP and optionally MCP
app = create_app(memory=_memory, http_api_enabled=True, mcp_api_enabled=config.mcp_enabled, mcp_mount_path="/mcp")
app = create_app(
memory=_memory,
http_api_enabled=True,
mcp_api_enabled=config.mcp_enabled,
mcp_mount_path="/mcp"
)
if __name__ == "__main__":
# When run directly, delegate to the CLI
from hindsight_api.main import main
main()
+4 -61
View File
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api"
version = "0.1.16"
description = "Hindsight: Agent Memory That Works Like Human Memory"
version = "0.1.4"
description = "Temporal + Semantic + Entity Memory System for AI agents using PostgreSQL"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
@@ -25,11 +25,10 @@ dependencies = [
"greenlet>=3.2.4",
"psycopg2-binary>=2.9.11",
"transformers>=4.30.0,<4.46.0",
"torch>=2.0.0",
"torch>=2.0.0,<2.6.0",
"tiktoken>=0.12.0",
"httpx>=0.27.0",
"fastmcp>=2.3.0",
"pg0-embedded>=0.11.0",
"fastmcp>=2.0.0",
"python-dateutil>=2.8.0",
"opentelemetry-api>=1.20.0",
"opentelemetry-sdk>=1.20.0",
@@ -50,7 +49,6 @@ test = [
[project.scripts]
hindsight-api = "hindsight_api.main:main"
hindsight-local-mcp = "hindsight_api.mcp_local:main"
[tool.hatch.build.targets.wheel]
packages = ["hindsight_api"]
@@ -91,59 +89,4 @@ dev = [
"pytest-xdist>=3.8.0",
"python-dotenv>=1.2.1",
"filelock>=3.0.0",
"ruff>=0.8.0",
"ty>=0.0.1",
]
[tool.ruff]
line-length = 120
target-version = "py311"
exclude = [
"tests/",
"**/tests/",
]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
]
ignore = [
"E501", # line too long (handled by formatter)
"E402", # module import not at top of file
"F401", # unused import (too noisy during development)
"F841", # unused variable (too noisy during development)
"F811", # redefined while unused
"F821", # undefined name (forward references in type hints)
]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.ty]
# Type checking configuration
# ty is an extremely fast Python type checker from Astral (same team as ruff/uv)
[tool.ty.environment]
python-version = "3.11"
[tool.ty.src]
exclude = [
"tests/",
"hindsight_api/alembic/",
]
[tool.ty.rules]
# Disable noisy rules while keeping important ones
invalid-argument-type = "ignore" # False positives with **kwargs patterns
invalid-return-type = "ignore" # Often intentional in async code
invalid-parameter-default = "ignore" # Optional params with None default
possibly-missing-attribute = "ignore" # Common with Optional types
invalid-raise = "ignore" # False positives with exception tracking
call-non-callable = "ignore" # False positives with Optional types
invalid-key = "ignore" # Pydantic ConfigDict not understood
invalid-method-override = "ignore" # Intentional signature differences
unresolved-reference = "ignore" # Forward references not always resolved
+1 -7
View File
@@ -8,7 +8,7 @@ import os
import filelock
from pathlib import Path
from dotenv import load_dotenv
from hindsight_api import MemoryEngine, LLMConfig, LocalSTEmbeddings, RequestContext
from hindsight_api import MemoryEngine, LLMConfig, LocalSTEmbeddings
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
@@ -99,12 +99,6 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
return url
@pytest.fixture(scope="function")
def request_context():
"""Provide a default RequestContext for tests."""
return RequestContext()
@pytest.fixture(scope="session")
def llm_config():
"""
+32 -39
View File
@@ -3,7 +3,7 @@ Tests for agent management API (profile, disposition, background).
"""
import pytest
import uuid
from hindsight_api import MemoryEngine, RequestContext
from hindsight_api import MemoryEngine
from hindsight_api.api import CreateBankRequest, DispositionTraits
from hindsight_api.engine.memory_engine import Budget
@@ -17,11 +17,11 @@ class TestAgentProfile:
"""Tests for agent profile management."""
@pytest.mark.asyncio
async def test_get_agent_profile_creates_default(self, memory: MemoryEngine, request_context):
async def test_get_agent_profile_creates_default(self, memory: MemoryEngine):
"""Test that getting a profile for a new agent creates default disposition."""
bank_id = unique_agent_id("test_profile_default")
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
profile = await memory.get_bank_profile(bank_id)
assert profile is not None
assert "disposition" in profile
@@ -35,11 +35,11 @@ class TestAgentProfile:
assert profile["background"] == ""
@pytest.mark.asyncio
async def test_update_agent_disposition(self, memory: MemoryEngine, request_context):
async def test_update_agent_disposition(self, memory: MemoryEngine):
"""Test updating agent disposition traits."""
bank_id = unique_agent_id("test_profile_update")
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
profile = await memory.get_bank_profile(bank_id)
assert profile["disposition"].skepticism == 3
new_disposition = {
@@ -47,26 +47,26 @@ class TestAgentProfile:
"literalism": 4,
"empathy": 2,
}
await memory.update_bank_disposition(bank_id, new_disposition, request_context=request_context)
await memory.update_bank_disposition(bank_id, new_disposition)
updated_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
updated_profile = await memory.get_bank_profile(bank_id)
disposition = updated_profile["disposition"]
assert disposition.skepticism == new_disposition["skepticism"]
assert disposition.literalism == new_disposition["literalism"]
assert disposition.empathy == new_disposition["empathy"]
@pytest.mark.asyncio
async def test_list_agents(self, memory: MemoryEngine, request_context):
async def test_list_agents(self, memory: MemoryEngine):
"""Test listing all agents."""
agent_id_1 = unique_agent_id("test_list")
agent_id_2 = unique_agent_id("test_list")
agent_id_3 = unique_agent_id("test_list")
await memory.get_bank_profile(agent_id_1, request_context=request_context)
await memory.get_bank_profile(agent_id_2, request_context=request_context)
await memory.get_bank_profile(agent_id_3, request_context=request_context)
await memory.get_bank_profile(agent_id_1)
await memory.get_bank_profile(agent_id_2)
await memory.get_bank_profile(agent_id_3)
agents = await memory.list_banks(request_context=request_context)
agents = await memory.list_banks()
agent_ids = [a["bank_id"] for a in agents]
assert agent_id_1 in agent_ids
@@ -85,50 +85,46 @@ class TestAgentBackground:
"""Tests for agent background management."""
@pytest.mark.asyncio
async def test_merge_agent_background(self, memory: MemoryEngine, request_context):
async def test_merge_agent_background(self, memory: MemoryEngine):
"""Test merging agent background information."""
bank_id = unique_agent_id("test_profile_merge")
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
profile = await memory.get_bank_profile(bank_id)
assert profile["background"] == ""
result1 = await memory.merge_bank_background(
bank_id,
"I was born in Texas",
update_disposition=False,
request_context=request_context,
update_disposition=False
)
assert "Texas" in result1["background"]
result2 = await memory.merge_bank_background(
bank_id,
"I have 10 years of startup experience",
update_disposition=False,
request_context=request_context,
update_disposition=False
)
assert "Texas" in result2["background"] or "startup" in result2["background"]
final_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
final_profile = await memory.get_bank_profile(bank_id)
assert final_profile["background"] != ""
@pytest.mark.asyncio
async def test_merge_background_handles_conflicts(self, memory: MemoryEngine, request_context):
async def test_merge_background_handles_conflicts(self, memory: MemoryEngine):
"""Test that merging background handles conflicts (new overwrites old)."""
bank_id = unique_agent_id("test_profile_conflict")
result1 = await memory.merge_bank_background(
bank_id,
"I was born in Colorado",
update_disposition=False,
request_context=request_context,
update_disposition=False
)
assert "Colorado" in result1["background"]
result2 = await memory.merge_bank_background(
bank_id,
"You were born in Texas",
update_disposition=False,
request_context=request_context,
update_disposition=False
)
assert "Texas" in result2["background"]
@@ -137,7 +133,7 @@ class TestAgentEndpoint:
"""Tests for agent PUT endpoint logic."""
@pytest.mark.asyncio
async def test_put_agent_create(self, memory: MemoryEngine, request_context):
async def test_put_agent_create(self, memory: MemoryEngine):
"""Test creating an agent via PUT endpoint."""
bank_id = unique_agent_id("test_put_create")
@@ -150,13 +146,12 @@ class TestAgentEndpoint:
background="I am a creative software engineer"
)
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
profile = await memory.get_bank_profile(bank_id)
if request.disposition is not None:
await memory.update_bank_disposition(
bank_id,
request.disposition.model_dump(),
request_context=request_context,
request.disposition.model_dump()
)
if request.background is not None:
@@ -173,14 +168,14 @@ class TestAgentEndpoint:
request.background
)
final_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
final_profile = await memory.get_bank_profile(bank_id)
assert final_profile["disposition"].skepticism == 4
assert final_profile["disposition"].literalism == 5
assert final_profile["background"] == "I am a creative software engineer"
@pytest.mark.asyncio
async def test_put_agent_partial_update(self, memory: MemoryEngine, request_context):
async def test_put_agent_partial_update(self, memory: MemoryEngine):
"""Test updating only background."""
bank_id = unique_agent_id("test_put_partial")
@@ -188,7 +183,7 @@ class TestAgentEndpoint:
background="I am a data scientist"
)
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
profile = await memory.get_bank_profile(bank_id)
if request.background is not None:
pool = await memory._get_pool()
@@ -204,7 +199,7 @@ class TestAgentEndpoint:
request.background
)
final_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
final_profile = await memory.get_bank_profile(bank_id)
assert final_profile["disposition"].skepticism == 3 # Default
assert final_profile["background"] == "I am a data scientist"
@@ -214,7 +209,7 @@ class TestAgentDispositionIntegration:
"""Tests for disposition integration with other features."""
@pytest.mark.asyncio
async def test_think_uses_disposition(self, memory: MemoryEngine, request_context):
async def test_think_uses_disposition(self, memory: MemoryEngine):
"""Test that THINK operation uses agent disposition."""
bank_id = unique_agent_id("test_think")
@@ -223,13 +218,12 @@ class TestAgentDispositionIntegration:
"literalism": 4, # High literalism
"empathy": 2, # Low empathy
}
await memory.update_bank_disposition(bank_id, disposition, request_context=request_context)
await memory.update_bank_disposition(bank_id, disposition)
await memory.merge_bank_background(
bank_id,
"I am a creative artist who values innovation over tradition",
update_disposition=False,
request_context=request_context,
update_disposition=False
)
await memory.retain_batch_async(
@@ -238,14 +232,13 @@ class TestAgentDispositionIntegration:
{"content": "Traditional painting techniques have been used for centuries"},
{"content": "Modern digital art is changing the art world"}
],
request_context=request_context,
document_id="art_facts"
)
result = await memory.reflect_async(
bank_id=bank_id,
query="What do you think about traditional vs modern art?",
budget=Budget.LOW,
request_context=request_context,
budget=Budget.LOW
)
assert result.text is not None
+4 -6
View File
@@ -6,7 +6,7 @@ import os
@pytest.mark.asyncio
async def test_large_batch_auto_chunks(memory, request_context):
async def test_large_batch_auto_chunks(memory):
bank_id = "test_chunking_agent"
# Create a large batch that should trigger chunking
# Each item is ~2000 chars, so 30 items = 60k chars (exceeds 50k threshold)
@@ -24,8 +24,7 @@ async def test_large_batch_auto_chunks(memory, request_context):
# Ingest the large batch (should auto-chunk)
result = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
request_context=request_context,
contents=contents
)
# Verify we got results back
@@ -34,7 +33,7 @@ async def test_large_batch_auto_chunks(memory, request_context):
@pytest.mark.asyncio
async def test_small_batch_no_chunking(memory, request_context):
async def test_small_batch_no_chunking(memory):
bank_id = "test_no_chunking_agent"
# Create a small batch that should NOT trigger chunking
@@ -51,8 +50,7 @@ async def test_small_batch_no_chunking(memory, request_context):
# Ingest the small batch (should NOT auto-chunk)
result = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
request_context=request_context,
contents=contents
)
# Verify we got results back
@@ -1,334 +0,0 @@
"""
Tests for combined scoring functionality.
Verifies that:
1. RRF scores are properly normalized to [0, 1] range
2. Combined scoring formula is applied correctly
3. Tracer captures normalized values (not raw values)
"""
import pytest
from datetime import datetime, timezone
from hindsight_api.engine.search.types import RetrievalResult, MergedCandidate, ScoredResult
from hindsight_api.engine.memory_engine import Budget
from hindsight_api import RequestContext
class TestRRFNormalization:
"""Test that RRF scores are properly normalized."""
def test_rrf_normalized_range(self):
"""RRF normalized values should be in [0, 1] range, not raw [0.04, 0.06]."""
# Simulate RRF scores like what we get from actual retrieval
raw_rrf_scores = [0.0607, 0.0550, 0.0480, 0.0390]
max_rrf = max(raw_rrf_scores)
min_rrf = min(raw_rrf_scores)
rrf_range = max_rrf - min_rrf
normalized = []
for score in raw_rrf_scores:
if rrf_range > 0:
norm = (score - min_rrf) / rrf_range
else:
norm = 0.5
normalized.append(norm)
# Verify normalized values are in [0, 1]
for i, norm in enumerate(normalized):
assert 0.0 <= norm <= 1.0, f"Normalized RRF {norm} not in [0, 1] for raw {raw_rrf_scores[i]}"
# Highest raw should be 1.0
assert normalized[0] == 1.0, f"Highest RRF should normalize to 1.0, got {normalized[0]}"
# Lowest raw should be 0.0
assert normalized[-1] == 0.0, f"Lowest RRF should normalize to 0.0, got {normalized[-1]}"
def test_rrf_all_same_scores(self):
"""When all RRF scores are the same, normalized should be 0.5 (neutral)."""
raw_rrf_scores = [0.0500, 0.0500, 0.0500]
max_rrf = max(raw_rrf_scores)
min_rrf = min(raw_rrf_scores)
rrf_range = max_rrf - min_rrf
normalized = []
for score in raw_rrf_scores:
if rrf_range > 0:
norm = (score - min_rrf) / rrf_range
else:
norm = 0.5 # Neutral value when all same
normalized.append(norm)
# All should be 0.5 when scores are identical
for norm in normalized:
assert norm == 0.5, f"Expected 0.5 for identical scores, got {norm}"
class TestCombinedScoringFormula:
"""Test that the combined scoring formula is applied correctly."""
def test_combined_score_calculation(self):
"""Verify the weighted combination: 0.6*CE + 0.2*RRF + 0.1*temporal + 0.1*recency."""
# Test case 1: All components at 1.0
ce_norm = 1.0
rrf_norm = 1.0
temporal = 1.0
recency = 1.0
expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency
assert expected == 1.0, f"All 1.0 should give 1.0, got {expected}"
# Test case 2: All components at 0.0
ce_norm = 0.0
rrf_norm = 0.0
temporal = 0.0
recency = 0.0
expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency
assert expected == 0.0, f"All 0.0 should give 0.0, got {expected}"
# Test case 3: High CE, low RRF (cross-encoder finds something retrieval missed)
ce_norm = 0.999
rrf_norm = 0.0 # Lowest in set
temporal = 0.5
recency = 0.5
expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency
# 0.5994 + 0.0 + 0.05 + 0.05 = 0.6994
assert abs(expected - 0.6994) < 0.001, f"Expected ~0.6994, got {expected}"
# Test case 4: Medium CE, high RRF (retrieval consensus)
ce_norm = 0.8
rrf_norm = 1.0 # Highest in set
temporal = 0.5
recency = 0.5
expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency
# 0.48 + 0.2 + 0.05 + 0.05 = 0.78
assert abs(expected - 0.78) < 0.001, f"Expected ~0.78, got {expected}"
def test_rrf_contribution_is_significant(self):
"""Verify RRF actually contributes to the final score (not negligible)."""
# Same CE, different RRF
ce_norm = 0.8
temporal = 0.5
recency = 0.5
# Low RRF
score_low_rrf = 0.6 * ce_norm + 0.2 * 0.0 + 0.1 * temporal + 0.1 * recency
# High RRF
score_high_rrf = 0.6 * ce_norm + 0.2 * 1.0 + 0.1 * temporal + 0.1 * recency
# Difference should be 0.2 (20% contribution)
diff = score_high_rrf - score_low_rrf
assert abs(diff - 0.2) < 0.001, f"RRF should contribute 0.2 difference, got {diff}"
@pytest.mark.asyncio
async def test_trace_has_normalized_rrf(memory, request_context):
"""Integration test: verify trace contains normalized RRF values, not raw."""
bank_id = f"test_scoring_{datetime.now(timezone.utc).timestamp()}"
try:
# Store multiple memories to ensure different RRF scores
await memory.retain_async(
bank_id=bank_id,
content="Python is a programming language created by Guido van Rossum",
context="tech facts",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="JavaScript was created by Brendan Eich at Netscape",
context="tech facts",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="The Eiffel Tower is located in Paris, France",
context="geography facts",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="Mount Everest is the tallest mountain on Earth",
context="geography facts",
request_context=request_context,
)
# Search with tracing
result = await memory.recall_async(
bank_id=bank_id,
query="programming languages",
fact_type=["world"],
budget=Budget.LOW,
max_tokens=1024,
enable_trace=True,
request_context=request_context,
)
assert result.trace is not None, "Trace should be present"
trace = result.trace
# Check reranked results have proper score_components
assert "reranked" in trace, "Trace should have reranked results"
assert len(trace["reranked"]) > 0, "Should have reranked results"
has_valid_rrf = False
has_valid_temporal = False
has_valid_recency = False
for r in trace["reranked"]:
sc = r.get("score_components", {})
# Check RRF normalized is present and in valid range
if "rrf_normalized" in sc:
rrf_norm = sc["rrf_normalized"]
assert 0.0 <= rrf_norm <= 1.0, f"rrf_normalized {rrf_norm} should be in [0, 1]"
# Should NOT be raw RRF score (which would be ~0.04-0.06)
# A normalized value of exactly 0.0 or 1.0 is valid (min/max of set)
# But raw scores like 0.0607 should never appear as normalized
if rrf_norm > 0.1: # Any value > 0.1 is likely properly normalized
has_valid_rrf = True
# Check temporal is present and in valid range
if "temporal" in sc:
temporal = sc["temporal"]
assert 0.0 <= temporal <= 1.0, f"temporal {temporal} should be in [0, 1]"
has_valid_temporal = True
# Check recency is present and in valid range
if "recency" in sc:
recency = sc["recency"]
assert 0.0 <= recency <= 1.0, f"recency {recency} should be in [0, 1]"
has_valid_recency = True
# At least some results should have these components
# (might not have rrf > 0.1 if all scores are same, which is fine)
assert has_valid_temporal, "Should have temporal scores in trace"
assert has_valid_recency, "Should have recency scores in trace"
print("\n✓ Combined scoring trace test passed!")
print(f" - Reranked results: {len(trace['reranked'])}")
if trace["reranked"]:
sc = trace["reranked"][0].get("score_components", {})
print(f" - First result score components: {sc}")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_rrf_normalized_not_raw_in_trace(memory, request_context):
"""Verify that raw RRF scores (0.04-0.06 range) don't appear as normalized values."""
bank_id = f"test_rrf_raw_{datetime.now(timezone.utc).timestamp()}"
try:
# Store enough memories to get varied RRF scores
for i in range(5):
await memory.retain_async(
bank_id=bank_id,
content=f"Test fact number {i} about various topics",
context="test context",
request_context=request_context,
)
result = await memory.recall_async(
bank_id=bank_id,
query="test fact",
fact_type=["world"],
budget=Budget.LOW,
max_tokens=512,
enable_trace=True,
request_context=request_context,
)
trace = result.trace
assert trace is not None
# Check that rrf_normalized values are NOT in the raw range
raw_rrf_range = (0.01, 0.08) # Raw RRF scores are typically in this range
for r in trace.get("reranked", []):
sc = r.get("score_components", {})
if "rrf_normalized" in sc and "rrf_score" in sc:
rrf_norm = sc["rrf_normalized"]
rrf_raw = sc["rrf_score"]
# Raw should be in the typical range
assert raw_rrf_range[0] <= rrf_raw <= raw_rrf_range[1], \
f"Raw RRF {rrf_raw} should be in typical range {raw_rrf_range}"
# Normalized should either be:
# - 0.0 (min in set)
# - 1.0 (max in set)
# - 0.5 (all same)
# - Something in between (0.0 to 1.0)
# But NOT the same as raw (which would indicate no normalization)
if len(trace["reranked"]) > 1:
# If we have multiple results, normalized should differ from raw
# (unless by coincidence, which is very unlikely)
assert rrf_norm != rrf_raw, \
f"Normalized RRF ({rrf_norm}) should differ from raw ({rrf_raw})"
print("\n✓ RRF raw vs normalized test passed!")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
@pytest.mark.asyncio
async def test_combined_score_matches_components(memory, request_context):
"""Verify the final score actually equals the weighted sum of components."""
bank_id = f"test_combined_{datetime.now(timezone.utc).timestamp()}"
try:
await memory.retain_async(
bank_id=bank_id,
content="The quick brown fox jumps over the lazy dog",
context="test",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="A quick test of the emergency broadcast system",
context="test",
request_context=request_context,
)
result = await memory.recall_async(
bank_id=bank_id,
query="quick test",
fact_type=["world"],
budget=Budget.LOW,
max_tokens=512,
enable_trace=True,
request_context=request_context,
)
trace = result.trace
assert trace is not None
for r in trace.get("reranked", []):
sc = r.get("score_components", {})
final_score = r.get("rerank_score", 0)
# Get components (use defaults if missing)
ce = sc.get("cross_encoder_score_normalized", 0)
rrf = sc.get("rrf_normalized", 0.5)
tmp = sc.get("temporal", 0.5)
rec = sc.get("recency", 0.5)
# Calculate expected score
expected = 0.6 * ce + 0.2 * rrf + 0.1 * tmp + 0.1 * rec
# Allow small floating point difference
assert abs(final_score - expected) < 0.01, \
f"Final score {final_score} doesn't match expected {expected} from components"
print("\n✓ Combined score verification test passed!")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
+19 -25
View File
@@ -4,11 +4,10 @@ Tests for document tracking and upsert functionality.
import logging
import pytest
from datetime import datetime, timezone
from hindsight_api import RequestContext
@pytest.mark.asyncio
async def test_document_creation_and_retrieval(memory, request_context):
async def test_document_creation_and_retrieval(memory):
"""Test that documents are created and can be retrieved."""
bank_id = f"test_doc_{datetime.now(timezone.utc).timestamp()}"
@@ -20,12 +19,11 @@ async def test_document_creation_and_retrieval(memory, request_context):
bank_id=bank_id,
content="Alice works at Google. Bob works at Microsoft.",
context="Team meeting",
document_id=document_id,
request_context=request_context,
document_id=document_id
)
# Retrieve document
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
doc = await memory.get_document(document_id, bank_id)
assert doc is not None
assert doc["id"] == document_id
@@ -34,11 +32,11 @@ async def test_document_creation_and_retrieval(memory, request_context):
assert doc["memory_unit_count"] > 0
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_document_upsert(memory, request_context):
async def test_document_upsert(memory):
"""Test that providing the same document_id automatically upserts (deletes old units and creates new ones)."""
bank_id = f"test_upsert_{datetime.now(timezone.utc).timestamp()}"
@@ -50,12 +48,11 @@ async def test_document_upsert(memory, request_context):
bank_id=bank_id,
content="Alice works at Google.",
context="Initial",
document_id=document_id,
request_context=request_context,
document_id=document_id
)
# Get document stats
doc_v1 = await memory.get_document(document_id, bank_id, request_context=request_context)
doc_v1 = await memory.get_document(document_id, bank_id)
count_v1 = doc_v1["memory_unit_count"]
# Update with different content (automatic upsert when same document_id is provided)
@@ -63,12 +60,11 @@ async def test_document_upsert(memory, request_context):
bank_id=bank_id,
content="Alice works at Microsoft. Bob works at Apple.",
context="Updated",
document_id=document_id,
request_context=request_context,
document_id=document_id
)
# Get updated document stats
doc_v2 = await memory.get_document(document_id, bank_id, request_context=request_context)
doc_v2 = await memory.get_document(document_id, bank_id)
count_v2 = doc_v2["memory_unit_count"]
# Verify old units were replaced
@@ -79,11 +75,11 @@ async def test_document_upsert(memory, request_context):
assert set(units_v1).isdisjoint(set(units_v2))
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_document_deletion(memory, request_context):
async def test_document_deletion(memory):
"""Test that deleting a document cascades to memory units."""
bank_id = f"test_delete_{datetime.now(timezone.utc).timestamp()}"
@@ -95,30 +91,29 @@ async def test_document_deletion(memory, request_context):
bank_id=bank_id,
content="Alice works at Google.",
context="Test",
document_id=document_id,
request_context=request_context,
document_id=document_id
)
# Verify it exists
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
doc = await memory.get_document(document_id, bank_id)
assert doc is not None
assert doc["memory_unit_count"] > 0
# Delete document
result = await memory.delete_document(document_id, bank_id, request_context=request_context)
result = await memory.delete_document(document_id, bank_id)
assert result["document_deleted"] == 1
assert result["memory_units_deleted"] > 0
# Verify it's gone
doc_after = await memory.get_document(document_id, bank_id, request_context=request_context)
doc_after = await memory.get_document(document_id, bank_id)
assert doc_after is None
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_memory_without_document(memory, request_context):
async def test_memory_without_document(memory):
"""Test that memories can still be created without document tracking."""
bank_id = f"test_no_doc_{datetime.now(timezone.utc).timestamp()}"
@@ -127,11 +122,10 @@ async def test_memory_without_document(memory, request_context):
units = await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google.",
context="Test",
request_context=request_context,
context="Test"
)
assert len(units) > 0
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
-796
View File
@@ -1,796 +0,0 @@
"""Tests for the Hindsight extensions system."""
from collections import defaultdict
import pytest
from fastapi import APIRouter
from fastapi.testclient import TestClient
from hindsight_api.extensions import (
ApiKeyTenantExtension,
AuthenticationError,
Extension,
HttpExtension,
OperationValidationError,
OperationValidatorExtension,
RecallContext,
RecallResult,
ReflectContext,
ReflectResultContext,
RequestContext,
RetainContext,
RetainResult,
TenantContext,
TenantExtension,
ValidationResult,
load_extension,
)
class TestExtensionLoader:
"""Tests for extension loading and lifecycle."""
def test_load_extension_with_config(self, monkeypatch):
"""Extension receives config from prefixed env vars and supports lifecycle."""
monkeypatch.setenv(
"HINDSIGHT_API_TEST_EXTENSION",
"tests.test_extensions:LifecycleTestExtension",
)
monkeypatch.setenv("HINDSIGHT_API_TEST_API_URL", "https://example.com")
monkeypatch.setenv("HINDSIGHT_API_TEST_MAX_RETRIES", "5")
ext = load_extension("TEST", Extension)
assert ext is not None
assert ext.config["api_url"] == "https://example.com"
assert ext.config["max_retries"] == "5"
@pytest.mark.asyncio
async def test_extension_lifecycle(self, monkeypatch):
"""Extension on_startup and on_shutdown are called."""
monkeypatch.setenv(
"HINDSIGHT_API_TEST_EXTENSION",
"tests.test_extensions:LifecycleTestExtension",
)
ext = load_extension("TEST", Extension)
assert not ext.started
assert not ext.stopped
await ext.on_startup()
assert ext.started
await ext.on_shutdown()
assert ext.stopped
class LifecycleTestExtension(Extension):
"""Test extension for config and lifecycle tests."""
def __init__(self, config):
super().__init__(config)
self.started = False
self.stopped = False
async def on_startup(self):
self.started = True
async def on_shutdown(self):
self.stopped = True
class RateLimitingValidator(OperationValidatorExtension):
"""
Mock validator that blocks after N attempts per bank_id.
Used for testing the extension integration with MemoryEngine.
"""
def __init__(self, config: dict):
super().__init__(config)
self.max_attempts = int(config.get("max_attempts", "2"))
self.retain_counts: dict[str, int] = defaultdict(int)
self.recall_counts: dict[str, int] = defaultdict(int)
self.reflect_counts: dict[str, int] = defaultdict(int)
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
self.retain_counts[ctx.bank_id] += 1
if self.retain_counts[ctx.bank_id] > self.max_attempts:
return ValidationResult.reject(
f"Retain limit exceeded for bank {ctx.bank_id}"
)
return ValidationResult.accept()
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
self.recall_counts[ctx.bank_id] += 1
if self.recall_counts[ctx.bank_id] > self.max_attempts:
return ValidationResult.reject(
f"Recall limit exceeded for bank {ctx.bank_id}"
)
return ValidationResult.accept()
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
self.reflect_counts[ctx.bank_id] += 1
if self.reflect_counts[ctx.bank_id] > self.max_attempts:
return ValidationResult.reject(
f"Reflect limit exceeded for bank {ctx.bank_id}"
)
return ValidationResult.accept()
class TrackingValidator(OperationValidatorExtension):
"""
Mock validator that tracks all pre and post hook calls with full parameters.
Used for testing that hooks receive all user-provided parameters.
"""
def __init__(self, config: dict):
super().__init__(config)
# Pre-hook tracking
self.pre_retain_calls: list[RetainContext] = []
self.pre_recall_calls: list[RecallContext] = []
self.pre_reflect_calls: list[ReflectContext] = []
# Post-hook tracking
self.post_retain_calls: list[RetainResult] = []
self.post_recall_calls: list[RecallResult] = []
self.post_reflect_calls: list[ReflectResultContext] = []
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
self.pre_retain_calls.append(ctx)
return ValidationResult.accept()
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
self.pre_recall_calls.append(ctx)
return ValidationResult.accept()
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
self.pre_reflect_calls.append(ctx)
return ValidationResult.accept()
async def on_retain_complete(self, result: RetainResult) -> None:
self.post_retain_calls.append(result)
async def on_recall_complete(self, result: RecallResult) -> None:
self.post_recall_calls.append(result)
async def on_reflect_complete(self, result: ReflectResultContext) -> None:
self.post_reflect_calls.append(result)
class TestMemoryEngineValidation:
"""Tests for validation integration with MemoryEngine.
The OperationValidatorExtension is integrated at the MemoryEngine level,
so all interfaces (HTTP API, MCP, SDK) get the same validation behavior.
For retain, the batch is validated as a whole (all or nothing) using
retain_batch_async which is the public method used by the HTTP API.
"""
@pytest.mark.asyncio
async def test_retain_batch_validation(self, memory_with_validator):
"""Retain batch is validated as a whole - accepts or rejects entire batch."""
memory = memory_with_validator
bank_id = "test-retain-batch"
ctx = RequestContext()
# First batch should succeed
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "First item"},
{"content": "Second item"},
],
request_context=ctx,
)
# Second batch should succeed (2nd attempt)
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "Third item"}],
request_context=ctx,
)
# Third batch should be blocked entirely (exceeds limit)
with pytest.raises(OperationValidationError) as exc_info:
await memory.retain_batch_async(
bank_id=bank_id,
contents=[
{"content": "Should not be stored"},
{"content": "Neither should this"},
],
request_context=ctx,
)
assert "limit exceeded" in str(exc_info.value).lower()
@pytest.mark.asyncio
async def test_recall_validation(self, memory_with_validator):
"""Recall is validated before execution."""
memory = memory_with_validator
bank_id = "test-recall-validation"
ctx = RequestContext()
# First recall should pass validation
await memory.recall_async(bank_id, "test query", fact_type=["world"], request_context=ctx)
# Second recall should pass validation
await memory.recall_async(bank_id, "another query", fact_type=["world"], request_context=ctx)
# Third recall should be blocked by validator
with pytest.raises(OperationValidationError) as exc_info:
await memory.recall_async(bank_id, "blocked query", fact_type=["world"], request_context=ctx)
assert "limit exceeded" in str(exc_info.value).lower()
@pytest.mark.asyncio
async def test_reflect_validation(self, memory_with_validator):
"""Reflect is validated before execution."""
memory = memory_with_validator
bank_id = "test-reflect-validation"
ctx = RequestContext()
# First reflect should pass validation (may fail internally but validation passes)
try:
await memory.reflect_async(bank_id, "test question", request_context=ctx)
except OperationValidationError:
raise # Re-raise validation errors
except Exception:
pass # Other errors are fine (e.g., no data)
# Second reflect should pass validation
try:
await memory.reflect_async(bank_id, "another question", request_context=ctx)
except OperationValidationError:
raise
except Exception:
pass
# Third reflect should be blocked by validator
with pytest.raises(OperationValidationError) as exc_info:
await memory.reflect_async(bank_id, "blocked question", request_context=ctx)
assert "limit exceeded" in str(exc_info.value).lower()
@pytest.fixture
def memory_with_validator(memory):
"""Memory engine with a rate-limiting validator (max 2 attempts per bank)."""
validator = RateLimitingValidator({"max_attempts": "2"})
memory._operation_validator = validator
return memory
@pytest.fixture
def memory_with_tracking_validator(memory):
"""Memory engine with a tracking validator that records all hook calls."""
validator = TrackingValidator({})
memory._operation_validator = validator
return memory, validator
class TestOperationHooksParameters:
"""Tests for pre and post operation hooks receiving all user-provided parameters."""
@pytest.mark.asyncio
async def test_retain_pre_hook_receives_all_parameters(self, memory_with_tracking_validator):
"""Pre-retain hook receives all user-provided parameters."""
memory, validator = memory_with_tracking_validator
bank_id = "test-retain-params"
ctx = RequestContext(api_key="test-key")
contents = [{"content": "Test content", "context": "test context"}]
document_id = "doc-123"
await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
document_id=document_id,
fact_type_override="world",
confidence_score=0.9,
request_context=ctx,
)
assert len(validator.pre_retain_calls) == 1
pre_ctx = validator.pre_retain_calls[0]
# Verify all parameters are present
assert pre_ctx.bank_id == bank_id
# Note: contents is copied before document_id is applied to individual items
assert len(pre_ctx.contents) == len(contents)
assert pre_ctx.contents[0]["content"] == contents[0]["content"]
assert pre_ctx.document_id == document_id
assert pre_ctx.fact_type_override == "world"
assert pre_ctx.confidence_score == 0.9
assert pre_ctx.request_context == ctx
@pytest.mark.asyncio
async def test_retain_post_hook_receives_all_parameters_and_result(self, memory_with_tracking_validator):
"""Post-retain hook receives all parameters plus the result."""
memory, validator = memory_with_tracking_validator
bank_id = "test-retain-post"
ctx = RequestContext(api_key="test-key")
contents = [{"content": "Test content for post hook"}]
document_id = "doc-456"
result = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
document_id=document_id,
fact_type_override="experience",
confidence_score=0.8,
request_context=ctx,
)
assert len(validator.post_retain_calls) == 1
post_result = validator.post_retain_calls[0]
# Verify all parameters are present
assert post_result.bank_id == bank_id
assert post_result.document_id == document_id
assert post_result.fact_type_override == "experience"
assert post_result.confidence_score == 0.8
assert post_result.request_context == ctx
# Verify result data
assert post_result.success is True
assert post_result.error is None
assert post_result.unit_ids == result # Should match the return value
@pytest.mark.asyncio
async def test_recall_pre_hook_receives_all_parameters(self, memory_with_tracking_validator):
"""Pre-recall hook receives all user-provided parameters."""
from datetime import datetime, timezone
from hindsight_api.engine.memory_engine import Budget
memory, validator = memory_with_tracking_validator
bank_id = "test-recall-params"
ctx = RequestContext(api_key="test-key")
query = "test query"
question_date = datetime(2024, 1, 15, tzinfo=timezone.utc)
await memory.recall_async(
bank_id=bank_id,
query=query,
budget=Budget.HIGH,
max_tokens=2048,
enable_trace=True,
fact_type=["world", "experience"],
question_date=question_date,
include_entities=True,
max_entity_tokens=300,
include_chunks=True,
max_chunk_tokens=4096,
request_context=ctx,
)
assert len(validator.pre_recall_calls) == 1
pre_ctx = validator.pre_recall_calls[0]
# Verify all parameters are present
assert pre_ctx.bank_id == bank_id
assert pre_ctx.query == query
assert pre_ctx.budget == Budget.HIGH
assert pre_ctx.max_tokens == 2048
assert pre_ctx.enable_trace is True
assert pre_ctx.fact_types == ["world", "experience"]
assert pre_ctx.question_date == question_date
assert pre_ctx.include_entities is True
assert pre_ctx.max_entity_tokens == 300
assert pre_ctx.include_chunks is True
assert pre_ctx.max_chunk_tokens == 4096
assert pre_ctx.request_context == ctx
@pytest.mark.asyncio
async def test_recall_post_hook_receives_all_parameters_and_result(self, memory_with_tracking_validator):
"""Post-recall hook receives all parameters plus the result."""
from hindsight_api.engine.memory_engine import Budget
memory, validator = memory_with_tracking_validator
bank_id = "test-recall-post"
ctx = RequestContext(api_key="test-key")
result = await memory.recall_async(
bank_id=bank_id,
query="test query for post",
budget=Budget.LOW,
max_tokens=1024,
fact_type=["world"],
request_context=ctx,
)
assert len(validator.post_recall_calls) == 1
post_result = validator.post_recall_calls[0]
# Verify all parameters are present
assert post_result.bank_id == bank_id
assert post_result.query == "test query for post"
assert post_result.budget == Budget.LOW
assert post_result.max_tokens == 1024
assert post_result.fact_types == ["world"]
assert post_result.request_context == ctx
# Verify result data
assert post_result.success is True
assert post_result.error is None
assert post_result.result == result # Should match the return value
@pytest.mark.asyncio
async def test_reflect_pre_hook_receives_all_parameters(self, memory_with_tracking_validator):
"""Pre-reflect hook receives all user-provided parameters."""
from hindsight_api.engine.memory_engine import Budget
memory, validator = memory_with_tracking_validator
bank_id = "test-reflect-params"
ctx = RequestContext(api_key="test-key")
try:
await memory.reflect_async(
bank_id=bank_id,
query="test question",
budget=Budget.MID,
context="additional context",
request_context=ctx,
)
except Exception:
pass # May fail if no data, but pre-hook should still be called
assert len(validator.pre_reflect_calls) == 1
pre_ctx = validator.pre_reflect_calls[0]
# Verify all parameters are present
assert pre_ctx.bank_id == bank_id
assert pre_ctx.query == "test question"
assert pre_ctx.budget == Budget.MID
assert pre_ctx.context == "additional context"
assert pre_ctx.request_context == ctx
@pytest.mark.asyncio
async def test_reflect_post_hook_receives_all_parameters_and_result(self, memory_with_tracking_validator):
"""Post-reflect hook receives all parameters plus the result on success."""
from hindsight_api.engine.memory_engine import Budget
memory, validator = memory_with_tracking_validator
bank_id = "test-reflect-post"
ctx = RequestContext(api_key="test-key")
# Store some content first so reflect has something to work with
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "Alice is a software engineer at Google."}],
request_context=ctx,
)
result = await memory.reflect_async(
bank_id=bank_id,
query="What does Alice do?",
budget=Budget.LOW,
context="work context",
request_context=ctx,
)
assert len(validator.post_reflect_calls) == 1
post_result = validator.post_reflect_calls[0]
# Verify all parameters are present
assert post_result.bank_id == bank_id
assert post_result.query == "What does Alice do?"
assert post_result.budget == Budget.LOW
assert post_result.context == "work context"
assert post_result.request_context == ctx
# Verify result data
assert post_result.success is True
assert post_result.error is None
assert post_result.result == result # Should match the return value
assert post_result.result.text is not None
@pytest.mark.asyncio
async def test_post_hooks_called_in_order_after_pre_hooks(self, memory_with_tracking_validator):
"""Post hooks are called after pre hooks and after operation completes."""
memory, validator = memory_with_tracking_validator
bank_id = "test-hook-order"
ctx = RequestContext()
# Retain operation
await memory.retain_batch_async(
bank_id=bank_id,
contents=[{"content": "Test content"}],
request_context=ctx,
)
# Pre-hook should be called before post-hook
assert len(validator.pre_retain_calls) == 1
assert len(validator.post_retain_calls) == 1
# Recall operation
await memory.recall_async(
bank_id=bank_id,
query="test",
fact_type=["world"],
request_context=ctx,
)
assert len(validator.pre_recall_calls) == 1
assert len(validator.post_recall_calls) == 1
class TestTenantExtension:
"""Tests for TenantExtension and ApiKeyTenantExtension."""
@pytest.mark.asyncio
async def test_api_key_tenant_extension_valid_key(self):
"""ApiKeyTenantExtension accepts valid API key."""
ext = ApiKeyTenantExtension({"api_key": "secret-key-123"})
result = await ext.authenticate(RequestContext(api_key="secret-key-123"))
assert result.schema_name == "public"
@pytest.mark.asyncio
async def test_api_key_tenant_extension_invalid_key(self):
"""ApiKeyTenantExtension rejects invalid API key."""
ext = ApiKeyTenantExtension({"api_key": "secret-key-123"})
with pytest.raises(AuthenticationError) as exc_info:
await ext.authenticate(RequestContext(api_key="wrong-key"))
assert "Invalid API key" in str(exc_info.value)
@pytest.mark.asyncio
async def test_api_key_tenant_extension_missing_key(self):
"""ApiKeyTenantExtension rejects missing API key."""
ext = ApiKeyTenantExtension({"api_key": "secret-key-123"})
with pytest.raises(AuthenticationError):
await ext.authenticate(RequestContext(api_key=None))
def test_api_key_tenant_extension_requires_config(self):
"""ApiKeyTenantExtension requires api_key in config."""
with pytest.raises(ValueError) as exc_info:
ApiKeyTenantExtension({})
assert "HINDSIGHT_API_TENANT_API_KEY is required" in str(exc_info.value)
class TestMemoryEngineTenantAuth:
"""Tests for tenant authentication in MemoryEngine."""
@pytest.mark.asyncio
async def test_retain_requires_tenant_request_when_extension_configured(
self, memory_with_tenant
):
"""Retain fails without RequestContext when tenant extension is configured."""
memory = memory_with_tenant
with pytest.raises(AuthenticationError) as exc_info:
await memory.retain_batch_async(
bank_id="test-bank",
contents=[{"content": "test"}],
request_context=None, # Missing!
)
assert "RequestContext is required" in str(exc_info.value)
@pytest.mark.asyncio
async def test_retain_succeeds_with_valid_tenant_request(self, memory_with_tenant):
"""Retain succeeds with valid RequestContext."""
memory = memory_with_tenant
# Should not raise
await memory.retain_batch_async(
bank_id="test-bank-tenant",
contents=[{"content": "test content"}],
request_context=RequestContext(api_key="test-api-key"),
)
@pytest.mark.asyncio
async def test_retain_fails_with_invalid_api_key(self, memory_with_tenant):
"""Retain fails with invalid API key."""
memory = memory_with_tenant
with pytest.raises(AuthenticationError) as exc_info:
await memory.retain_batch_async(
bank_id="test-bank",
contents=[{"content": "test"}],
request_context=RequestContext(api_key="wrong-key"),
)
assert "Invalid API key" in str(exc_info.value)
@pytest.mark.asyncio
async def test_recall_requires_tenant_request_when_extension_configured(
self, memory_with_tenant
):
"""Recall fails without RequestContext when tenant extension is configured."""
memory = memory_with_tenant
with pytest.raises(AuthenticationError):
await memory.recall_async(
bank_id="test-bank",
query="test query",
fact_type=["world"],
request_context=None,
)
@pytest.mark.asyncio
async def test_no_tenant_request_needed_without_extension(self, memory):
"""Operations work with empty RequestContext when no tenant extension configured."""
# Should not raise - no tenant extension configured, just pass empty RequestContext
await memory.retain_batch_async(
bank_id="test-bank-no-tenant",
contents=[{"content": "test content"}],
request_context=RequestContext(),
)
@pytest.fixture
def memory_with_tenant(memory):
"""Memory engine with a tenant extension (API key auth)."""
tenant_ext = ApiKeyTenantExtension({"api_key": "test-api-key"})
memory._tenant_extension = tenant_ext
return memory
class SampleHttpExtension(HttpExtension):
"""Sample HTTP extension for testing that provides custom endpoints."""
def __init__(self, config: dict):
super().__init__(config)
self.started = False
self.stopped = False
self.request_count = 0
async def on_startup(self):
self.started = True
async def on_shutdown(self):
self.stopped = True
def get_router(self, memory) -> APIRouter:
router = APIRouter()
@router.get("/hello")
async def hello():
self.request_count += 1
return {"message": "Hello from extension!"}
@router.get("/config")
async def get_config():
return {"config": self.config}
@router.get("/health-check")
async def extension_health():
health = await memory.health_check()
return {"extension": "healthy", "memory": health}
@router.post("/echo")
async def echo(data: dict):
return {"echoed": data}
return router
class TestHttpExtensionIntegration:
"""Tests for HTTP extension integration."""
def test_load_http_extension(self, monkeypatch):
"""HttpExtension can be loaded from environment variable."""
monkeypatch.setenv(
"HINDSIGHT_API_HTTP_EXTENSION",
"tests.test_extensions:SampleHttpExtension",
)
monkeypatch.setenv("HINDSIGHT_API_HTTP_CUSTOM_PARAM", "custom_value")
ext = load_extension("HTTP", HttpExtension)
assert ext is not None
assert isinstance(ext, SampleHttpExtension)
assert ext.config["custom_param"] == "custom_value"
def test_http_extension_router_mounted_at_ext(self, memory):
"""HTTP extension router is mounted at /ext/."""
from hindsight_api.api.http import create_app
ext = SampleHttpExtension({"test_key": "test_value"})
app = create_app(memory, initialize_memory=False, http_extension=ext)
client = TestClient(app)
# Extension endpoint should be accessible at /ext/
response = client.get("/ext/hello")
assert response.status_code == 200
assert response.json() == {"message": "Hello from extension!"}
# Should track request count
assert ext.request_count == 1
# Old path should NOT work
response = client.get("/extension/hello")
assert response.status_code == 404
def test_http_extension_config_endpoint(self, memory):
"""Extension can expose its config via custom endpoint."""
from hindsight_api.api.http import create_app
ext = SampleHttpExtension({"api_key": "secret", "limit": "100"})
app = create_app(memory, initialize_memory=False, http_extension=ext)
client = TestClient(app)
response = client.get("/ext/config")
assert response.status_code == 200
assert response.json()["config"]["api_key"] == "secret"
assert response.json()["config"]["limit"] == "100"
def test_http_extension_can_access_memory(self, memory):
"""Extension endpoints can access memory engine."""
from hindsight_api.api.http import create_app
ext = SampleHttpExtension({})
app = create_app(memory, initialize_memory=False, http_extension=ext)
client = TestClient(app)
response = client.get("/ext/health-check")
assert response.status_code == 200
data = response.json()
assert data["extension"] == "healthy"
assert "memory" in data
def test_http_extension_post_endpoint(self, memory):
"""Extension can handle POST requests with JSON body."""
from hindsight_api.api.http import create_app
ext = SampleHttpExtension({})
app = create_app(memory, initialize_memory=False, http_extension=ext)
client = TestClient(app)
response = client.post("/ext/echo", json={"key": "value", "number": 42})
assert response.status_code == 200
assert response.json() == {"echoed": {"key": "value", "number": 42}}
def test_http_extension_not_mounted_when_none(self, memory):
"""No extension routes when http_extension is None."""
from hindsight_api.api.http import create_app
app = create_app(memory, initialize_memory=False, http_extension=None)
client = TestClient(app)
# Extension endpoint should not exist
response = client.get("/ext/hello")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_http_extension_lifecycle(self):
"""HTTP extension on_startup and on_shutdown are called."""
ext = SampleHttpExtension({})
assert not ext.started
assert not ext.stopped
await ext.on_startup()
assert ext.started
await ext.on_shutdown()
assert ext.stopped
def test_core_routes_still_work_with_extension(self, memory):
"""Core API routes still work when extension is mounted."""
from hindsight_api.api.http import create_app
ext = SampleHttpExtension({})
app = create_app(memory, initialize_memory=False, http_extension=ext)
client = TestClient(app)
# Health endpoint should work
response = client.get("/health")
assert response.status_code in (200, 503) # May be unhealthy if DB not connected
# Banks list endpoint should work
response = client.get("/v1/default/banks")
assert response.status_code in (200, 500) # May fail if DB not ready
@@ -12,12 +12,12 @@ This comprehensive test suite validates that the fact extraction system:
These are quality/accuracy tests that verify the LLM-based extraction
produces semantically correct and complete facts.
"""
from datetime import UTC, datetime
import pytest
from hindsight_api import LLMConfig
import re
from datetime import datetime, timezone
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
from hindsight_api import LLMConfig
# =============================================================================
# DIMENSION PRESERVATION TESTS
@@ -432,7 +432,6 @@ with a concert surrounded by music, joy and the warm summer breeze.
assert birthday_fact is not None, "Should extract fact about birthday celebration"
fact_date_str = birthday_fact.occurred_start
assert fact_date_str is not None, "occurred_start should not be None for temporal events"
if 'T' in fact_date_str:
fact_date = datetime.fromisoformat(fact_date_str.replace('Z', '+00:00'))
@@ -498,7 +497,7 @@ Yesterday I went for a morning jog for the first time in a nearby park.
async def test_extract_facts_with_relative_dates(self):
"""Test that relative dates are converted to absolute dates."""
reference_date = datetime(2024, 3, 20, 14, 0, 0, tzinfo=UTC)
reference_date = datetime(2024, 3, 20, 14, 0, 0, tzinfo=timezone.utc)
llm_config = LLMConfig.for_memory()
text = """
@@ -532,7 +531,7 @@ Yesterday I went for a morning jog for the first time in a nearby park.
async def test_extract_facts_with_no_temporal_info(self):
"""Test that facts without temporal info are still extracted."""
reference_date = datetime(2024, 3, 20, 14, 0, 0, tzinfo=UTC)
reference_date = datetime(2024, 3, 20, 14, 0, 0, tzinfo=timezone.utc)
llm_config = LLMConfig.for_memory()
text = "Alice works at Google. She loves Python programming."
@@ -556,7 +555,7 @@ Yesterday I went for a morning jog for the first time in a nearby park.
async def test_extract_facts_with_absolute_dates(self):
"""Test that absolute dates in text are preserved."""
reference_date = datetime(2024, 3, 20, 14, 0, 0, tzinfo=UTC)
reference_date = datetime(2024, 3, 20, 14, 0, 0, tzinfo=timezone.utc)
llm_config = LLMConfig.for_memory()
text = """
@@ -897,7 +896,7 @@ class TestDispositionInference:
"""Tests for LLM-based disposition trait inference from background."""
@pytest.mark.asyncio
async def test_background_merge_with_disposition_inference(self, memory, request_context):
async def test_background_merge_with_disposition_inference(self, memory):
"""Test that background merge infers disposition traits by default."""
import uuid
bank_id = f"test_infer_{uuid.uuid4().hex[:8]}"
@@ -905,8 +904,7 @@ class TestDispositionInference:
result = await memory.merge_bank_background(
bank_id,
"I am a creative software engineer who loves innovation and trying new technologies",
update_disposition=True,
request_context=request_context,
update_disposition=True
)
assert "background" in result
@@ -924,31 +922,30 @@ class TestDispositionInference:
assert 1 <= disposition[trait] <= 5
@pytest.mark.asyncio
async def test_background_merge_without_disposition_inference(self, memory, request_context):
async def test_background_merge_without_disposition_inference(self, memory):
"""Test that background merge skips disposition inference when disabled."""
import uuid
bank_id = f"test_no_infer_{uuid.uuid4().hex[:8]}"
initial_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
initial_profile = await memory.get_bank_profile(bank_id)
initial_disposition = initial_profile["disposition"]
result = await memory.merge_bank_background(
bank_id,
"I am a data scientist",
update_disposition=False,
request_context=request_context,
update_disposition=False
)
assert "background" in result
assert "disposition" not in result
final_profile = await memory.get_bank_profile(bank_id, request_context=request_context)
final_profile = await memory.get_bank_profile(bank_id)
final_disposition = final_profile["disposition"]
assert initial_disposition == final_disposition
@pytest.mark.asyncio
async def test_disposition_inference_for_lawyer(self, memory, request_context):
async def test_disposition_inference_for_lawyer(self, memory):
"""Test disposition inference for lawyer profile (high skepticism, high literalism)."""
import uuid
bank_id = f"test_lawyer_{uuid.uuid4().hex[:8]}"
@@ -956,8 +953,7 @@ class TestDispositionInference:
result = await memory.merge_bank_background(
bank_id,
"I am a lawyer who focuses on contract details and never takes claims at face value",
update_disposition=True,
request_context=request_context,
update_disposition=True
)
disposition = result["disposition"]
@@ -967,7 +963,7 @@ class TestDispositionInference:
assert disposition["literalism"] >= 3
@pytest.mark.asyncio
async def test_disposition_inference_for_therapist(self, memory, request_context):
async def test_disposition_inference_for_therapist(self, memory):
"""Test disposition inference for therapist profile (high empathy)."""
import uuid
bank_id = f"test_therapist_{uuid.uuid4().hex[:8]}"
@@ -975,8 +971,7 @@ class TestDispositionInference:
result = await memory.merge_bank_background(
bank_id,
"I am a therapist who deeply understands and connects with people's emotional struggles",
update_disposition=True,
request_context=request_context,
update_disposition=True
)
disposition = result["disposition"]
@@ -985,7 +980,7 @@ class TestDispositionInference:
assert disposition["empathy"] >= 3
@pytest.mark.asyncio
async def test_disposition_updates_in_database(self, memory, request_context):
async def test_disposition_updates_in_database(self, memory):
"""Test that inferred disposition is actually stored in database."""
import uuid
bank_id = f"test_db_update_{uuid.uuid4().hex[:8]}"
@@ -993,13 +988,12 @@ class TestDispositionInference:
result = await memory.merge_bank_background(
bank_id,
"I am an innovative designer",
update_disposition=True,
request_context=request_context,
update_disposition=True
)
inferred_disposition = result["disposition"]
profile = await memory.get_bank_profile(bank_id, request_context=request_context)
profile = await memory.get_bank_profile(bank_id)
db_disposition = profile["disposition"]
# Compare values (db_disposition is a Pydantic model)
@@ -1008,7 +1002,7 @@ class TestDispositionInference:
assert db_disposition.empathy == inferred_disposition["empathy"]
@pytest.mark.asyncio
async def test_multiple_background_merges_update_disposition(self, memory, request_context):
async def test_multiple_background_merges_update_disposition(self, memory):
"""Test that each background merge can update disposition."""
import uuid
bank_id = f"test_multi_merge_{uuid.uuid4().hex[:8]}"
@@ -1016,16 +1010,14 @@ class TestDispositionInference:
result1 = await memory.merge_bank_background(
bank_id,
"I am a software engineer",
update_disposition=True,
request_context=request_context,
update_disposition=True
)
disposition1 = result1["disposition"]
result2 = await memory.merge_bank_background(
bank_id,
"I love creative problem solving and innovation",
update_disposition=True,
request_context=request_context,
update_disposition=True
)
disposition2 = result2["disposition"]
@@ -1033,7 +1025,7 @@ class TestDispositionInference:
assert "creative" in result2["background"].lower() or "innovation" in result2["background"].lower()
@pytest.mark.asyncio
async def test_background_merge_conflict_resolution_with_disposition(self, memory, request_context):
async def test_background_merge_conflict_resolution_with_disposition(self, memory):
"""Test that conflicts are resolved and disposition reflects final background."""
import uuid
bank_id = f"test_conflict_{uuid.uuid4().hex[:8]}"
@@ -1041,15 +1033,13 @@ class TestDispositionInference:
await memory.merge_bank_background(
bank_id,
"I was born in Colorado and prefer stability",
update_disposition=True,
request_context=request_context,
update_disposition=True
)
result = await memory.merge_bank_background(
bank_id,
"You were born in Texas and are very skeptical of people",
update_disposition=True,
request_context=request_context,
update_disposition=True
)
background = result["background"]
@@ -1057,4 +1047,4 @@ class TestDispositionInference:
assert "texas" in background.lower()
# Higher skepticism expected from "very skeptical of people"
assert disposition["skepticism"] >= 3
assert disposition["skepticism"] >= 3
+12 -16
View File
@@ -7,24 +7,24 @@ distinguish between things said earlier vs later.
"""
import pytest
from datetime import datetime, timezone
from hindsight_api import MemoryEngine, RequestContext
from hindsight_api import MemoryEngine
from hindsight_api.engine.memory_engine import Budget
import os
@pytest.mark.asyncio
async def test_fact_ordering_within_conversation(memory, request_context):
async def test_fact_ordering_within_conversation(memory):
bank_id = "test_ordering_agent"
# Get/create agent (auto-creates with defaults)
await memory.get_bank_profile(bank_id, request_context=request_context)
await memory.get_bank_profile(bank_id)
# Update disposition to match Marcus
await memory.update_bank_disposition(bank_id, {
"skepticism": 3,
"literalism": 3,
"empathy": 3
}, request_context=request_context)
})
# A conversation where Marcus changes his position
conversation = """
@@ -43,8 +43,7 @@ Marcus: Yeah, I realized I was being too optimistic about their defense.
content=conversation,
context="podcast discussion about NFL game",
event_date=base_event_date,
document_id="test_conv_1",
request_context=request_context,
document_id="test_conv_1"
)
# Search for all facts about Marcus's predictions
@@ -53,8 +52,7 @@ Marcus: Yeah, I realized I was being too optimistic about their defense.
query="Marcus prediction Rams",
fact_type=['opinion', 'experience', 'world'],
budget=Budget.LOW,
max_tokens=8192,
request_context=request_context,
max_tokens=8192
)
print(f"\n=== Retrieved {len(results.results)} facts ===")
@@ -115,17 +113,17 @@ Marcus: Yeah, I realized I was being too optimistic about their defense.
print(f"\n✅ Temporal ordering preserved: First prediction came before changed prediction")
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
print(f"\n✅ Test passed: Fact ordering within conversation is preserved")
@pytest.mark.asyncio
async def test_multiple_documents_ordering(memory, request_context):
async def test_multiple_documents_ordering(memory):
bank_id = "test_multi_doc_agent"
await memory.get_bank_profile(bank_id, request_context=request_context) # Auto-creates with defaults
await memory.get_bank_profile(bank_id) # Auto-creates with defaults
# Two separate conversations with same base time
base_time = datetime(2024, 11, 14, 10, 0, 0, tzinfo=timezone.utc)
@@ -148,8 +146,7 @@ Alice: I reconsidered the team's experience level.
contents=[
{"content": conv1, "context": "project discussion 1", "event_date": base_time},
{"content": conv2, "context": "project discussion 2", "event_date": base_time}
],
request_context=request_context,
]
)
# Search for Alice's preferences
@@ -158,8 +155,7 @@ Alice: I reconsidered the team's experience level.
query="Alice preference React Vue",
fact_type=['opinion', 'experience'],
budget=Budget.LOW,
max_tokens=8192,
request_context=request_context,
max_tokens=8192
)
print(f"\n=== Retrieved {len(results.results)} agent facts ===")
@@ -179,6 +175,6 @@ Alice: I reconsidered the team's experience level.
print(f"\n✅ Facts from {len(agent_facts)} statements have {len(unique_timestamps)} unique timestamps")
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
print(f"\n✅ Test passed: Multiple documents maintain separate ordering")
@@ -426,185 +426,3 @@ async def test_document_deletion(api_client):
f"/v1/default/banks/{test_bank_id}/documents/sales-report-q1-2024"
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_async_retain(api_client):
"""Test asynchronous retain functionality.
When async=true is passed, the retain endpoint should:
1. Return immediately with success and async_=true
2. Process the content in the background
3. Eventually store the memories
"""
import asyncio
test_bank_id = f"async_retain_test_{datetime.now().timestamp()}"
# Store memory with async=true
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={
"async": True,
"items": [
{
"content": "Alice is a senior engineer at TechCorp. She has been working on the authentication system for 5 years.",
"context": "team introduction"
}
]
}
)
assert response.status_code == 200
result = response.json()
assert result["success"] is True
assert result["async"] is True, "Response should indicate async processing"
assert result["items_count"] == 1
# Check operations endpoint to see the pending operation
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations")
assert response.status_code == 200
ops_result = response.json()
assert "operations" in ops_result
# Wait for async processing to complete (poll with timeout)
max_wait_seconds = 30
poll_interval = 0.5
elapsed = 0
memories_found = False
while elapsed < max_wait_seconds:
# Check if memories are stored
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/memories/list",
params={"limit": 10}
)
assert response.status_code == 200
items = response.json()["items"]
if len(items) > 0:
memories_found = True
break
await asyncio.sleep(poll_interval)
elapsed += poll_interval
assert memories_found, f"Async retain did not complete within {max_wait_seconds} seconds"
# Verify we can recall the stored memory
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories/recall",
json={
"query": "Who works at TechCorp?",
"thinking_budget": 30
}
)
assert response.status_code == 200
search_results = response.json()
assert len(search_results["results"]) > 0, "Should find the asynchronously stored memory"
# Verify Alice is mentioned
found_alice = any("Alice" in r["text"] for r in search_results["results"])
assert found_alice, "Should find Alice in search results"
@pytest.mark.asyncio
async def test_async_retain_parallel(api_client):
"""Test multiple async retain operations running in parallel.
Verifies that:
1. Multiple async operations can be submitted concurrently
2. All operations complete successfully
3. The exact number of documents are processed
"""
import asyncio
test_bank_id = f"async_parallel_test_{datetime.now().timestamp()}"
num_documents = 5
# Prepare multiple documents to retain
documents = [
{
"content": f"Document {i}: This is test content about Person{i} who works at Company{i}.",
"context": f"test document {i}",
"document_id": f"doc_{i}"
}
for i in range(num_documents)
]
# Submit all async retain operations in parallel
async def submit_async_retain(doc):
return await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories",
json={
"async": True,
"items": [doc]
}
)
# Run all submissions concurrently
responses = await asyncio.gather(*[submit_async_retain(doc) for doc in documents])
# Verify all submissions succeeded
for i, response in enumerate(responses):
assert response.status_code == 200, f"Document {i} submission failed"
result = response.json()
assert result["success"] is True
assert result["async"] is True
# Check operations endpoint - should show pending operations
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations")
assert response.status_code == 200
# Wait for all async operations to complete (poll with timeout)
max_wait_seconds = 60
poll_interval = 1.0
elapsed = 0
all_docs_processed = False
while elapsed < max_wait_seconds:
# Check document count
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/documents")
assert response.status_code == 200
docs = response.json()["items"]
if len(docs) >= num_documents:
all_docs_processed = True
break
await asyncio.sleep(poll_interval)
elapsed += poll_interval
assert all_docs_processed, f"Expected {num_documents} documents, but only {len(docs)} were processed within {max_wait_seconds} seconds"
# Verify exact document count
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/documents")
assert response.status_code == 200
final_docs = response.json()["items"]
assert len(final_docs) == num_documents, f"Expected exactly {num_documents} documents, got {len(final_docs)}"
# Verify each document exists
doc_ids = {doc["id"] for doc in final_docs}
for i in range(num_documents):
assert f"doc_{i}" in doc_ids, f"Document doc_{i} not found"
# Verify memories were created for all documents
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/memories/list",
params={"limit": 100}
)
assert response.status_code == 200
memories = response.json()["items"]
assert len(memories) >= num_documents, f"Expected at least {num_documents} memories, got {len(memories)}"
# Verify we can recall content from different documents
for i in [0, num_documents - 1]: # Check first and last
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/memories/recall",
json={
"query": f"Who works at Company{i}?",
"thinking_budget": 30
}
)
assert response.status_code == 200
results = response.json()["results"]
assert len(results) > 0, f"Should find memories for document {i}"
+68 -60
View File
@@ -1,12 +1,9 @@
"""
Test LLM provider with different models using actual memory operations.
Test LLM provider with different models and providers.
"""
import os
from datetime import datetime
import pytest
from hindsight_api.engine.llm_wrapper import LLMProvider
from hindsight_api.engine.utils import extract_facts
from hindsight_api.engine.search.think_utils import reflect
# Model matrix: (provider, model)
@@ -18,17 +15,13 @@ MODEL_MATRIX = [
("openai", "gpt-5-mini"),
("openai", "gpt-5-nano"),
("openai", "gpt-5"),
("openai", "gpt-5.2"),
# Groq models
("groq", "llama-3.3-70b-versatile"),
("groq", "openai/gpt-oss-120b"),
("groq", "openai/gpt-oss-20b"),
# Gemini models
("gemini", "gemini-2.5-flash"),
("gemini", "gemini-2.5-flash-lite"),
("gemini", "gemini-3-pro-preview"),
# Ollama models (local)
("ollama", "gemma3:12b"),
("ollama", "gemma3:1b"),
]
@@ -45,75 +38,90 @@ def get_api_key_for_provider(provider: str) -> str | None:
@pytest.mark.parametrize("provider,model", MODEL_MATRIX)
@pytest.mark.asyncio
async def test_llm_provider_memory_operations(provider: str, model: str):
async def test_llm_provider_call(provider: str, model: str):
"""
Test LLM provider with actual memory operations: fact extraction and reflect.
All models must pass this test.
Test LLM provider can make a basic call with different models.
Skips if the required API key is not available.
"""
api_key = get_api_key_for_provider(provider)
# Skip Ollama tests in CI (no models available)
if provider == "ollama" and os.getenv("CI"):
pytest.skip(f"Skipping {provider}/{model}: Ollama not available in CI")
# Other providers need an API key
if provider != "ollama" and not api_key:
if not api_key:
pytest.skip(f"Skipping {provider}/{model}: no API key available")
llm = LLMProvider(
provider=provider,
api_key=api_key or "",
api_key=api_key,
base_url="",
model=model,
)
# Test 1: Fact extraction (structured output)
test_text = """
User: I just got back from my trip to Paris last week. The Eiffel Tower was amazing!
Assistant: That sounds wonderful! How long were you there?
User: About 5 days. I also visited the Louvre and saw the Mona Lisa.
# Test basic call
response = await llm.call(
messages=[{"role": "user", "content": "Say 'hello' and nothing else."}],
max_completion_tokens=50,
temperature=0.1,
)
print(f"\n{provider}/{model} response: {response}")
assert response is not None, f"{provider}/{model} returned None"
@pytest.mark.parametrize("provider,model", MODEL_MATRIX)
@pytest.mark.asyncio
async def test_llm_provider_verify_connection(provider: str, model: str):
"""
event_date = datetime(2024, 12, 10)
Test LLM provider verify_connection method with different models.
Skips if the required API key is not available.
"""
api_key = get_api_key_for_provider(provider)
if not api_key:
pytest.skip(f"Skipping {provider}/{model}: no API key available")
facts, chunks = await extract_facts(
text=test_text,
event_date=event_date,
context="Travel conversation",
llm_config=llm,
llm = LLMProvider(
provider=provider,
api_key=api_key,
base_url="",
model=model,
)
print(f"\n{provider}/{model} - Fact extraction:")
print(f" Extracted {len(facts)} facts from {len(chunks)} chunks")
for fact in facts:
print(f" - {fact.fact}")
# Test verify_connection
await llm.verify_connection()
print(f"\n{provider}/{model} connection verified")
assert facts is not None, f"{provider}/{model} fact extraction returned None"
assert len(facts) > 0, f"{provider}/{model} should extract at least one fact"
# Verify facts have required fields
for fact in facts:
assert fact.fact, f"{provider}/{model} fact missing text"
assert fact.fact_type in ["world", "experience", "opinion"], f"{provider}/{model} invalid fact_type: {fact.fact_type}"
# Models that support large output (65000+ tokens)
LARGE_OUTPUT_MODELS = [
("openai", "gpt-5-mini"),
("openai", "gpt-5-nano"),
("openai", "gpt-5"),
("gemini", "gemini-2.5-flash"),
("gemini", "gemini-2.5-flash-lite"),
]
# Test 2: Reflect (actual reflect function)
response = await reflect(
llm_config=llm,
query="What was the highlight of my Paris trip?",
experience_facts=[
"I visited Paris in December 2024",
"I saw the Eiffel Tower and it was amazing",
"I visited the Louvre and saw the Mona Lisa",
"The trip lasted 5 days",
],
world_facts=[
"The Eiffel Tower is a famous landmark in Paris",
"The Mona Lisa is displayed at the Louvre museum",
],
name="Traveler",
@pytest.mark.parametrize("provider,model", LARGE_OUTPUT_MODELS)
@pytest.mark.asyncio
async def test_llm_provider_large_output(provider: str, model: str):
"""
Test LLM provider with large max_completion_tokens (65000).
Only tests models that support large outputs.
Skips if the required API key is not available.
"""
api_key = get_api_key_for_provider(provider)
if not api_key:
pytest.skip(f"Skipping {provider}/{model}: no API key available")
llm = LLMProvider(
provider=provider,
api_key=api_key,
base_url="",
model=model,
)
print(f"\n{provider}/{model} - Reflect response:")
print(f" {response[:200]}...")
# Test call with large max_completion_tokens
response = await llm.call(
messages=[{"role": "user", "content": "Say 'ok'"}],
max_completion_tokens=65000,
)
assert response is not None, f"{provider}/{model} reflect returned None"
assert len(response) > 10, f"{provider}/{model} reflect response too short"
print(f"\n{provider}/{model} large output response: {response}")
assert response is not None, f"{provider}/{model} returned None"
-162
View File
@@ -1,162 +0,0 @@
"""Test local MCP server."""
import asyncio
import pytest
from unittest.mock import AsyncMock, MagicMock
@pytest.fixture
def mock_memory():
"""Create a mock MemoryEngine."""
memory = MagicMock()
memory._initialized = True
memory.retain_batch_async = AsyncMock()
memory.recall_async = AsyncMock(return_value=MagicMock(results=[]))
return memory
@pytest.mark.asyncio
async def test_local_mcp_server_retain(mock_memory):
"""Test that retain tool fires async and returns immediately."""
from hindsight_api.mcp_local import create_local_mcp_server
bank_id = "test-bank"
mcp_server = create_local_mcp_server(bank_id, memory=mock_memory)
# Get the tools
tools = mcp_server._tool_manager._tools
assert "retain" in tools
# Call retain
retain_tool = tools["retain"]
result = await retain_tool.fn(content="test content", context="test_context")
# Returns immediately with accepted status
assert result["status"] == "accepted"
# Wait for background task to complete
await asyncio.sleep(0.1)
# Verify the memory was called correctly
mock_memory.retain_batch_async.assert_called_once()
call_kwargs = mock_memory.retain_batch_async.call_args.kwargs
assert call_kwargs["bank_id"] == "test-bank"
assert call_kwargs["contents"] == [{"content": "test content", "context": "test_context"}]
@pytest.mark.asyncio
async def test_local_mcp_server_recall(mock_memory):
"""Test that recall tool calls memory.recall_async with correct params."""
from hindsight_api.mcp_local import create_local_mcp_server
from hindsight_api.engine.memory_engine import Budget
# Mock recall_async to return a proper pydantic model
mock_result = MagicMock()
mock_result.model_dump.return_value = {"results": []}
mock_memory.recall_async = AsyncMock(return_value=mock_result)
bank_id = "test-bank"
mcp_server = create_local_mcp_server(bank_id, memory=mock_memory)
# Get the tools
tools = mcp_server._tool_manager._tools
assert "recall" in tools
# Call recall with new params
recall_tool = tools["recall"]
result = await recall_tool.fn(query="test query", max_tokens=2048, budget="mid")
# Result is a dict
assert isinstance(result, dict)
# Verify the memory was called correctly
mock_memory.recall_async.assert_called_once()
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert call_kwargs["bank_id"] == "test-bank"
assert call_kwargs["query"] == "test query"
assert call_kwargs["max_tokens"] == 2048
assert call_kwargs["budget"] == Budget.MID
@pytest.mark.asyncio
async def test_local_mcp_server_retain_with_default_context(mock_memory):
"""Test that retain uses default context when not provided."""
from hindsight_api.mcp_local import create_local_mcp_server
bank_id = "test-bank"
mcp_server = create_local_mcp_server(bank_id, memory=mock_memory)
tools = mcp_server._tool_manager._tools
retain_tool = tools["retain"]
# Call retain without context
await retain_tool.fn(content="test content")
# Wait for background task
await asyncio.sleep(0.1)
call_kwargs = mock_memory.retain_batch_async.call_args.kwargs
assert call_kwargs["contents"] == [{"content": "test content", "context": "general"}]
@pytest.mark.asyncio
async def test_local_mcp_server_retain_error_handling(mock_memory):
"""Test that retain errors are logged but don't affect response."""
from hindsight_api.mcp_local import create_local_mcp_server
mock_memory.retain_batch_async = AsyncMock(side_effect=Exception("Test error"))
mcp_server = create_local_mcp_server("test-bank", memory=mock_memory)
tools = mcp_server._tool_manager._tools
retain_tool = tools["retain"]
# Retain returns immediately with accepted status (fire and forget)
result = await retain_tool.fn(content="test content")
assert result["status"] == "accepted"
# Wait for background task to complete (and log error)
await asyncio.sleep(0.1)
@pytest.mark.asyncio
async def test_local_mcp_server_recall_error_handling(mock_memory):
"""Test that recall handles errors gracefully."""
from hindsight_api.mcp_local import create_local_mcp_server
mock_memory.recall_async = AsyncMock(side_effect=Exception("Test error"))
mcp_server = create_local_mcp_server("test-bank", memory=mock_memory)
tools = mcp_server._tool_manager._tools
recall_tool = tools["recall"]
result = await recall_tool.fn(query="test query")
# Result is a dict with error
assert isinstance(result, dict)
assert "error" in result
assert result["results"] == []
@pytest.mark.asyncio
async def test_local_mcp_server_recall_with_defaults(mock_memory):
"""Test that recall uses default max_tokens and budget."""
from hindsight_api.mcp_local import create_local_mcp_server
from hindsight_api.engine.memory_engine import Budget
mock_result = MagicMock()
mock_result.model_dump.return_value = {"results": []}
mock_memory.recall_async = AsyncMock(return_value=mock_result)
mcp_server = create_local_mcp_server("test-bank", memory=mock_memory)
tools = mcp_server._tool_manager._tools
recall_tool = tools["recall"]
# Call with defaults
await recall_tool.fn(query="test query")
call_kwargs = mock_memory.recall_async.call_args.kwargs
assert call_kwargs["max_tokens"] == 4096
assert call_kwargs["budget"] == Budget.LOW
+3 -3
View File
@@ -8,7 +8,7 @@ from unittest.mock import AsyncMock, MagicMock
def mock_memory():
"""Create a mock MemoryEngine."""
memory = MagicMock()
memory.retain_batch_async = AsyncMock()
memory.put_batch_async = AsyncMock()
memory.recall_async = AsyncMock(return_value=MagicMock(results=[]))
return memory
@@ -52,8 +52,8 @@ async def test_mcp_tools_use_context_bank_id(mock_memory):
assert "successfully" in result.lower()
# Verify the memory was called with the context bank_id
mock_memory.retain_batch_async.assert_called_once()
call_kwargs = mock_memory.retain_batch_async.call_args.kwargs
mock_memory.put_batch_async.assert_called_once()
call_kwargs = mock_memory.put_batch_async.call_args.kwargs
assert call_kwargs["bank_id"] == "context-bank-id"
finally:
_current_bank_id.reset(token)
+18 -129
View File
@@ -3,12 +3,11 @@ Test observation generation and entity state functionality.
"""
import pytest
from hindsight_api.engine.memory_engine import Budget
from hindsight_api import RequestContext
from datetime import datetime, timezone
@pytest.mark.asyncio
async def test_observation_generation_on_put(memory, request_context):
async def test_observation_generation_on_put(memory):
"""
Test that observations are generated SYNCHRONOUSLY when new facts are added.
@@ -37,8 +36,7 @@ async def test_observation_generation_on_put(memory, request_context):
bank_id=bank_id,
content=content,
context="work info",
event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc),
request_context=request_context,
event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc)
)
# Observations are generated SYNCHRONOUSLY during retain,
@@ -77,7 +75,7 @@ async def test_observation_generation_on_put(memory, request_context):
print(f"Entity: {entity_name} (id: {entity_id})")
# Get observations for the entity - should be available immediately
observations = await memory.get_entity_observations(bank_id, entity_id, limit=10, request_context=request_context)
observations = await memory.get_entity_observations(bank_id, entity_id, limit=10)
print(f"\n=== Observations for {entity_name} ===")
print(f"Total observations: {len(observations)}")
@@ -104,7 +102,7 @@ async def test_observation_generation_on_put(memory, request_context):
@pytest.mark.asyncio
async def test_regenerate_entity_observations(memory, request_context):
async def test_regenerate_entity_observations(memory):
"""
Test explicit regeneration of observations for an entity.
"""
@@ -116,8 +114,7 @@ async def test_regenerate_entity_observations(memory, request_context):
bank_id=bank_id,
content="Sarah is a product manager who loves user research and data analysis.",
context="work info",
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
request_context=request_context,
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc)
)
await memory.wait_for_background_tasks()
@@ -143,15 +140,14 @@ async def test_regenerate_entity_observations(memory, request_context):
created_ids = await memory.regenerate_entity_observations(
bank_id=bank_id,
entity_id=entity_id,
entity_name=entity_name,
request_context=request_context,
entity_name=entity_name
)
print(f"\n=== Regenerated Observations ===")
print(f"Created {len(created_ids)} observations for {entity_name}")
# Get the observations
observations = await memory.get_entity_observations(bank_id, entity_id, limit=10, request_context=request_context)
observations = await memory.get_entity_observations(bank_id, entity_id, limit=10)
for obs in observations:
print(f" - {obs.text}")
@@ -174,108 +170,7 @@ async def test_regenerate_entity_observations(memory, request_context):
@pytest.mark.asyncio
async def test_manual_regenerate_with_few_facts(memory, request_context):
"""
Test that manual regeneration works even with fewer than 5 facts.
This is important because:
- Automatic generation during retain requires MIN_FACTS_THRESHOLD (5)
- But manual regeneration via API should work with any number of facts
- The UI triggers manual regeneration, so it should work regardless of fact count
"""
bank_id = f"test_manual_regen_{datetime.now(timezone.utc).timestamp()}"
try:
# Store only 2 facts - below the automatic threshold
await memory.retain_async(
bank_id=bank_id,
content="Alice works at Google as a senior software engineer.",
context="work info",
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="Alice loves hiking and outdoor photography.",
context="hobbies",
event_date=datetime(2024, 1, 16, tzinfo=timezone.utc),
request_context=request_context,
)
# Find the Alice entity
pool = await memory._get_pool()
async with pool.acquire() as conn:
entity_row = await conn.fetchrow(
"""
SELECT id, canonical_name
FROM entities
WHERE bank_id = $1 AND LOWER(canonical_name) LIKE '%alice%'
LIMIT 1
""",
bank_id
)
assert entity_row is not None, "Alice entity should have been extracted"
entity_id = str(entity_row['id'])
entity_name = entity_row['canonical_name']
# Check fact count - should be < 5
async with pool.acquire() as conn:
fact_count = await conn.fetchval(
"SELECT COUNT(*) FROM unit_entities WHERE entity_id = $1",
entity_row['id']
)
print(f"\n=== Manual Regeneration Test ===")
print(f"Entity: {entity_name} (id: {entity_id})")
print(f"Linked facts: {fact_count}")
# Verify we're testing with fewer than the automatic threshold
assert fact_count < 5, f"Test requires < 5 facts, but entity has {fact_count}"
# Before regeneration - should have no observations (auto threshold not met)
obs_before = await memory.get_entity_observations(bank_id, entity_id, limit=10, request_context=request_context)
print(f"Observations before manual regenerate: {len(obs_before)}")
# Manually regenerate observations - this should work regardless of fact count
created_ids = await memory.regenerate_entity_observations(
bank_id=bank_id,
entity_id=entity_id,
entity_name=entity_name,
request_context=request_context,
)
print(f"Observations created by manual regenerate: {len(created_ids)}")
# Get observations after regeneration
observations = await memory.get_entity_observations(bank_id, entity_id, limit=10, request_context=request_context)
print(f"Observations after manual regenerate: {len(observations)}")
for obs in observations:
print(f" - {obs.text}")
# Manual regeneration should create observations even with < 5 facts
assert len(observations) > 0, \
f"Manual regeneration should create observations even with only {fact_count} facts. " \
f"The LLM should synthesize at least 1 observation from the available facts."
# Verify observations contain relevant content
obs_texts = " ".join([o.text.lower() for o in observations])
assert any(keyword in obs_texts for keyword in ["google", "engineer", "hiking", "photography", "alice"]), \
"Observations should contain relevant information about Alice"
print(f"✓ Manual regeneration works with {fact_count} facts (below automatic threshold of 5)")
finally:
# Cleanup
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_search_with_include_entities(memory, request_context):
async def test_search_with_include_entities(memory):
"""
Test that search with include_entities=True returns entity observations.
@@ -301,8 +196,7 @@ async def test_search_with_include_entities(memory, request_context):
bank_id=bank_id,
content=content,
context="work info",
event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc),
request_context=request_context,
event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc)
)
# Observations are generated synchronously during retain, no need to wait
@@ -315,8 +209,7 @@ async def test_search_with_include_entities(memory, request_context):
budget=Budget.LOW,
max_tokens=2000,
include_entities=True,
max_entity_tokens=500,
request_context=request_context,
max_entity_tokens=500
)
print(f"\n=== Search Results ===")
@@ -370,7 +263,7 @@ async def test_search_with_include_entities(memory, request_context):
@pytest.mark.asyncio
async def test_get_entity_state(memory, request_context):
async def test_get_entity_state(memory):
"""
Test getting the full state of an entity.
"""
@@ -382,8 +275,7 @@ async def test_get_entity_state(memory, request_context):
bank_id=bank_id,
content="Bob is a frontend developer who specializes in React and TypeScript.",
context="work info",
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
request_context=request_context,
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc)
)
await memory.wait_for_background_tasks()
@@ -410,8 +302,7 @@ async def test_get_entity_state(memory, request_context):
bank_id=bank_id,
entity_id=entity_id,
entity_name=entity_name,
limit=10,
request_context=request_context,
limit=10
)
print(f"\n=== Entity State for {entity_name} ===")
@@ -433,7 +324,7 @@ async def test_get_entity_state(memory, request_context):
@pytest.mark.asyncio
async def test_observation_fact_type_in_database(memory, request_context):
async def test_observation_fact_type_in_database(memory):
"""
Test that observations are stored with correct fact_type in database.
"""
@@ -445,8 +336,7 @@ async def test_observation_fact_type_in_database(memory, request_context):
bank_id=bank_id,
content="Charlie is a DevOps engineer who manages the Kubernetes infrastructure.",
context="work info",
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
request_context=request_context,
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc)
)
await memory.wait_for_background_tasks()
@@ -484,7 +374,7 @@ async def test_observation_fact_type_in_database(memory, request_context):
@pytest.mark.asyncio
async def test_user_entity_prioritized_for_observations(memory, request_context):
async def test_user_entity_prioritized_for_observations(memory):
"""
Test that the 'user' entity gets observations even when many other entities exist.
@@ -520,8 +410,7 @@ async def test_user_entity_prioritized_for_observations(memory, request_context)
bank_id=bank_id,
content=content,
context="personal info",
event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc),
request_context=request_context,
event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc)
)
# Observations are generated synchronously during retain
@@ -577,7 +466,7 @@ async def test_user_entity_prioritized_for_observations(memory, request_context)
f"User entity should have at least 5 facts, but has {user_fact_count}"
# Get observations for user entity
observations = await memory.get_entity_observations(bank_id, user_entity_id, limit=10, request_context=request_context)
observations = await memory.get_entity_observations(bank_id, user_entity_id, limit=10)
print(f"\n=== User Entity Observations ===")
print(f"Total observations: {len(observations)}")
+104 -157
View File
@@ -5,13 +5,12 @@ import pytest
import logging
from datetime import datetime, timezone, timedelta
from hindsight_api.engine.memory_engine import Budget
from hindsight_api import RequestContext
logger = logging.getLogger(__name__)
@pytest.mark.asyncio
async def test_retain_with_chunks(memory, request_context):
async def test_retain_with_chunks(memory):
"""
Test that retain function:
1. Stores facts with associated chunks
@@ -42,8 +41,7 @@ async def test_retain_with_chunks(memory, request_context):
content=long_content,
context="team overview",
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
document_id=document_id,
request_context=request_context,
document_id=document_id
)
print(f"\n=== Retained {len(unit_ids)} facts ===")
@@ -58,8 +56,7 @@ async def test_retain_with_chunks(memory, request_context):
fact_type=["world"], # Search for world facts
include_entities=False, # Disable entities for simpler test
include_chunks=True, # Enable chunks
max_chunk_tokens=8192,
request_context=request_context,
max_chunk_tokens=8192
)
print(f"\n=== Recall Results (with chunks) ===")
@@ -91,12 +88,12 @@ async def test_retain_with_chunks(memory, request_context):
finally:
# Cleanup - delete the test bank
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
print(f"\n=== Cleaned up bank: {bank_id} ===")
@pytest.mark.asyncio
async def test_chunks_and_entities_follow_fact_order(memory, request_context):
async def test_chunks_and_entities_follow_fact_order(memory):
"""
Test that chunks and entities in recall results follow the same order as facts.
This is critical because token limits may truncate later items.
@@ -133,8 +130,7 @@ async def test_chunks_and_entities_follow_fact_order(memory, request_context):
content=item["content"],
context=item["context"],
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
document_id=item["document_id"],
request_context=request_context,
document_id=item["document_id"]
)
print("\n=== Stored 3 separate documents ===")
@@ -148,8 +144,7 @@ async def test_chunks_and_entities_follow_fact_order(memory, request_context):
fact_type=["world"],
include_entities=True,
include_chunks=True,
max_chunk_tokens=8192,
request_context=request_context,
max_chunk_tokens=8192
)
print(f"\n=== Recall Results ===")
@@ -219,12 +214,12 @@ async def test_chunks_and_entities_follow_fact_order(memory, request_context):
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
print(f"\n=== Cleaned up bank: {bank_id} ===")
@pytest.mark.asyncio
async def test_event_date_storage(memory, request_context):
async def test_event_date_storage(memory):
"""
Test that event_date is correctly stored as occurred_start.
Verifies that we can track when events actually happened vs when they were stored.
@@ -240,8 +235,7 @@ async def test_event_date_storage(memory, request_context):
bank_id=bank_id,
content="Alice completed the Q2 product launch on June 15th, 2023.",
context="project history",
event_date=past_event_date,
request_context=request_context,
event_date=past_event_date
)
assert len(unit_ids) > 0, "Should have created at least one memory unit"
@@ -252,8 +246,7 @@ async def test_event_date_storage(memory, request_context):
query="When did Alice complete the product launch?",
budget=Budget.LOW,
max_tokens=500,
fact_type=["world"],
request_context=request_context,
fact_type=["world"]
)
assert len(result.results) > 0, "Should recall the stored fact"
@@ -275,11 +268,11 @@ async def test_event_date_storage(memory, request_context):
print(f"\n✓ Event date correctly stored: {occurred_dt}")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_temporal_ordering(memory, request_context):
async def test_temporal_ordering(memory):
"""
Test that facts can be stored and retrieved with correct temporal ordering.
Stores facts with different event_dates and verifies temporal relationships.
@@ -312,8 +305,7 @@ async def test_temporal_ordering(memory, request_context):
bank_id=bank_id,
content=event["content"],
context=event["context"],
event_date=event["event_date"],
request_context=request_context,
event_date=event["event_date"]
)
print("\n=== Stored 3 events with different temporal dates ===")
@@ -324,8 +316,7 @@ async def test_temporal_ordering(memory, request_context):
query="Tell me about Alice's career progression",
budget=Budget.MID,
max_tokens=1000,
fact_type=["world"],
request_context=request_context,
fact_type=["world"]
)
assert len(result.results) >= 3, f"Should recall all 3 events, got {len(result.results)}"
@@ -354,11 +345,11 @@ async def test_temporal_ordering(memory, request_context):
print(f"\n✓ Temporal ordering preserved: {min_date.date()} to {max_date.date()}")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_mentioned_at_vs_occurred(memory, request_context):
async def test_mentioned_at_vs_occurred(memory):
"""
Test distinction between when fact occurred vs when it was mentioned.
@@ -378,8 +369,7 @@ async def test_mentioned_at_vs_occurred(memory, request_context):
bank_id=bank_id,
content="Alice graduated from MIT in March 2020.",
context="education history",
event_date=conversation_date, # When this conversation happened
request_context=request_context,
event_date=conversation_date # When this conversation happened
)
assert len(unit_ids) > 0, "Should create memory unit"
@@ -390,8 +380,7 @@ async def test_mentioned_at_vs_occurred(memory, request_context):
query="Where did Alice go to school?",
budget=Budget.LOW,
max_tokens=500,
fact_type=["world"],
request_context=request_context,
fact_type=["world"]
)
assert len(result.results) > 0, "Should recall the fact"
@@ -426,11 +415,11 @@ async def test_mentioned_at_vs_occurred(memory, request_context):
print(f"✓ Test passed: Historical conversation correctly ingested with event_date=2020")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_occurred_dates_not_defaulted(memory, request_context):
async def test_occurred_dates_not_defaulted(memory):
"""
Test that occurred_start and occurred_end are NOT defaulted to mentioned_at.
@@ -452,8 +441,7 @@ async def test_occurred_dates_not_defaulted(memory, request_context):
bank_id=bank_id,
content="Alice likes coffee. The weather is sunny today.",
context="current observations",
event_date=event_date,
request_context=request_context,
event_date=event_date
)
assert len(unit_ids) > 0, "Should create memory unit"
@@ -464,8 +452,7 @@ async def test_occurred_dates_not_defaulted(memory, request_context):
query="What does Alice like?",
budget=Budget.LOW,
max_tokens=500,
fact_type=["world", "opinion"],
request_context=request_context,
fact_type=["world", "opinion"]
)
assert len(result.results) > 0, "Should recall the fact"
@@ -517,11 +504,11 @@ async def test_occurred_dates_not_defaulted(memory, request_context):
print(f"✓ Test passed: occurred dates are not incorrectly defaulted to mentioned_at")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_mentioned_at_from_context_string(memory, request_context):
async def test_mentioned_at_from_context_string(memory):
"""
Test that mentioned_at is extracted from context string by LLM.
@@ -540,8 +527,7 @@ async def test_mentioned_at_from_context_string(memory, request_context):
bank_id=bank_id,
content="Alice mentioned she loves hiking in the mountains.",
context=f"Session ABC123 - you are the assistant in this conversation - happened on {session_date.strftime('%Y-%m-%d %H:%M:%S')} UTC.",
event_date=None, # Not providing event_date - should default to now() if LLM doesn't extract
request_context=request_context,
event_date=None # Not providing event_date - should default to now() if LLM doesn't extract
)
assert len(unit_ids) > 0, "Should create memory unit"
@@ -552,8 +538,7 @@ async def test_mentioned_at_from_context_string(memory, request_context):
query="What does Alice like?",
budget=Budget.LOW,
max_tokens=500,
fact_type=["world"],
request_context=request_context,
fact_type=["world"]
)
assert len(result.results) > 0, "Should recall the fact"
@@ -589,7 +574,7 @@ async def test_mentioned_at_from_context_string(memory, request_context):
print(f"✓ mentioned_at is always set (never None)")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
# ============================================================
@@ -597,7 +582,7 @@ async def test_mentioned_at_from_context_string(memory, request_context):
# ============================================================
@pytest.mark.asyncio
async def test_context_preservation(memory, request_context):
async def test_context_preservation(memory):
"""
Test that context is preserved and retrievable.
Context helps understand why/how memory was formed.
@@ -612,8 +597,7 @@ async def test_context_preservation(memory, request_context):
bank_id=bank_id,
content="The team decided to prioritize mobile development for next quarter.",
context=specific_context,
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
request_context=request_context,
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc)
)
assert len(unit_ids) > 0, "Should create at least one memory unit"
@@ -624,8 +608,7 @@ async def test_context_preservation(memory, request_context):
query="What did the team decide?",
budget=Budget.LOW,
max_tokens=500,
fact_type=["world"],
request_context=request_context,
fact_type=["world"]
)
assert len(result.results) > 0, "Should recall the stored fact"
@@ -637,11 +620,11 @@ async def test_context_preservation(memory, request_context):
print(f" Retrieved {len(result.results)} facts")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_context_with_batch(memory, request_context):
async def test_context_with_batch(memory):
"""
Test that each item in a batch can have different contexts.
"""
@@ -667,8 +650,7 @@ async def test_context_with_batch(memory, request_context):
"context": "incident response",
"event_date": datetime(2024, 1, 12, tzinfo=timezone.utc)
}
],
request_context=request_context,
]
)
# Should have created facts from all items
@@ -679,7 +661,7 @@ async def test_context_with_batch(memory, request_context):
print(f" Created {total_units} total memory units")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
# ============================================================
@@ -687,7 +669,7 @@ async def test_context_with_batch(memory, request_context):
# ============================================================
@pytest.mark.asyncio
async def test_metadata_storage_and_retrieval(memory, request_context):
async def test_metadata_storage_and_retrieval(memory):
"""
Test that user-defined metadata is preserved.
Metadata allows arbitrary key-value data to be stored with facts.
@@ -710,8 +692,7 @@ async def test_metadata_storage_and_retrieval(memory, request_context):
bank_id=bank_id,
content="The product launch is scheduled for March 1st.",
context="planning meeting",
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
request_context=request_context,
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc)
)
assert len(unit_ids) > 0, "Should create memory units"
@@ -722,8 +703,7 @@ async def test_metadata_storage_and_retrieval(memory, request_context):
query="When is the product launch?",
budget=Budget.LOW,
max_tokens=500,
fact_type=["world"],
request_context=request_context,
fact_type=["world"]
)
assert len(result.results) > 0, "Should recall stored facts"
@@ -732,7 +712,7 @@ async def test_metadata_storage_and_retrieval(memory, request_context):
print(f" (Note: Metadata support depends on API implementation)")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
# ============================================================
@@ -740,7 +720,7 @@ async def test_metadata_storage_and_retrieval(memory, request_context):
# ============================================================
@pytest.mark.asyncio
async def test_empty_batch(memory, request_context):
async def test_empty_batch(memory):
"""
Test that empty batch is handled gracefully without errors.
"""
@@ -750,8 +730,7 @@ async def test_empty_batch(memory, request_context):
# Attempt to store empty batch
unit_ids = await memory.retain_batch_async(
bank_id=bank_id,
contents=[],
request_context=request_context,
contents=[]
)
# Should return empty list or handle gracefully
@@ -762,11 +741,11 @@ async def test_empty_batch(memory, request_context):
finally:
# Clean up (though nothing should be stored)
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_single_item_batch(memory, request_context):
async def test_single_item_batch(memory):
"""
Test that batch with one item works correctly.
"""
@@ -782,8 +761,7 @@ async def test_single_item_batch(memory, request_context):
"context": "deployment log",
"event_date": datetime(2024, 1, 15, tzinfo=timezone.utc)
}
],
request_context=request_context,
]
)
assert len(unit_ids) == 1, "Should return one list of unit IDs"
@@ -792,11 +770,11 @@ async def test_single_item_batch(memory, request_context):
print(f"✓ Single-item batch created {len(unit_ids[0])} units")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_mixed_content_batch(memory, request_context):
async def test_mixed_content_batch(memory):
"""
Test batch with varying content sizes (short and long).
"""
@@ -820,8 +798,7 @@ async def test_mixed_content_batch(memory, request_context):
{"content": short_content, "context": "onboarding"},
{"content": long_content, "context": "performance review"},
{"content": "Charlie is on vacation this week.", "context": "team status"}
],
request_context=request_context,
]
)
# All items should be processed
@@ -836,11 +813,11 @@ async def test_mixed_content_batch(memory, request_context):
print(f" Long content: {long_units} units")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_batch_with_missing_optional_fields(memory, request_context):
async def test_batch_with_missing_optional_fields(memory):
"""
Test that batch handles items with missing optional fields.
"""
@@ -865,8 +842,7 @@ async def test_batch_with_missing_optional_fields(memory, request_context):
"context": "code review",
# No event_date
}
],
request_context=request_context,
]
)
# All items should be processed successfully
@@ -876,7 +852,7 @@ async def test_batch_with_missing_optional_fields(memory, request_context):
print(f"✓ Batch with mixed optional fields created {total_units} total units")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
# ============================================================
@@ -884,7 +860,7 @@ async def test_batch_with_missing_optional_fields(memory, request_context):
# ============================================================
@pytest.mark.asyncio
async def test_single_batch_multiple_documents(memory, request_context):
async def test_single_batch_multiple_documents(memory):
"""
Test storing multiple distinct documents in a single batch call.
Each should be tracked separately.
@@ -900,24 +876,21 @@ async def test_single_batch_multiple_documents(memory, request_context):
bank_id=bank_id,
content="Alice's resume: 10 years Python experience, worked at Google.",
context="resume review",
document_id="resume_alice",
request_context=request_context,
document_id="resume_alice"
)
doc2_units = await memory.retain_async(
bank_id=bank_id,
content="Bob's resume: 5 years JavaScript experience, worked at Meta.",
context="resume review",
document_id="resume_bob",
request_context=request_context,
document_id="resume_bob"
)
doc3_units = await memory.retain_async(
bank_id=bank_id,
content="Charlie's resume: 8 years Go experience, worked at Amazon.",
context="resume review",
document_id="resume_charlie",
request_context=request_context,
document_id="resume_charlie"
)
# All documents should be stored
@@ -934,18 +907,17 @@ async def test_single_batch_multiple_documents(memory, request_context):
query="Who worked at Google?",
budget=Budget.MID,
max_tokens=1000,
fact_type=["world"],
request_context=request_context,
fact_type=["world"]
)
assert len(result.results) > 0, "Should find facts about Alice"
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_document_upsert_behavior(memory, request_context):
async def test_document_upsert_behavior(memory):
"""
Test that upserting a document replaces the old content.
"""
@@ -958,8 +930,7 @@ async def test_document_upsert_behavior(memory, request_context):
bank_id=bank_id,
content="Project is in planning phase. Alice is the lead.",
context="status update v1",
document_id=document_id,
request_context=request_context,
document_id=document_id
)
assert len(v1_units) > 0, "Should create units for v1"
@@ -969,8 +940,7 @@ async def test_document_upsert_behavior(memory, request_context):
bank_id=bank_id,
content="Project is in development phase. Bob has joined as co-lead.",
context="status update v2",
document_id=document_id,
request_context=request_context,
document_id=document_id
)
assert len(v2_units) > 0, "Should create units for v2"
@@ -981,8 +951,7 @@ async def test_document_upsert_behavior(memory, request_context):
query="What is the project status?",
budget=Budget.MID,
max_tokens=1000,
fact_type=["world"],
request_context=request_context,
fact_type=["world"]
)
assert len(result.results) > 0, "Should recall facts"
@@ -990,7 +959,7 @@ async def test_document_upsert_behavior(memory, request_context):
print(f"✓ Document upsert created v1: {len(v1_units)} units, v2: {len(v2_units)} units")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
# ============================================================
@@ -998,7 +967,7 @@ async def test_document_upsert_behavior(memory, request_context):
# ============================================================
@pytest.mark.asyncio
async def test_chunk_fact_mapping(memory, request_context):
async def test_chunk_fact_mapping(memory):
"""
Test that facts correctly reference their source chunks via chunk_id.
"""
@@ -1021,8 +990,7 @@ async def test_chunk_fact_mapping(memory, request_context):
bank_id=bank_id,
content=content,
context="technical documentation",
document_id=document_id,
request_context=request_context,
document_id=document_id
)
assert len(unit_ids) > 0, "Should create memory units"
@@ -1035,8 +1003,7 @@ async def test_chunk_fact_mapping(memory, request_context):
max_tokens=1000,
fact_type=["world"],
include_chunks=True,
max_chunk_tokens=8192,
request_context=request_context,
max_chunk_tokens=8192
)
assert len(result.results) > 0, "Should recall facts"
@@ -1059,11 +1026,11 @@ async def test_chunk_fact_mapping(memory, request_context):
print(f" Returned {len(result.chunks)} chunks matching fact references")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_chunk_ordering_preservation(memory, request_context):
async def test_chunk_ordering_preservation(memory):
"""
Test that chunk_index reflects the correct order within a document.
"""
@@ -1103,8 +1070,7 @@ async def test_chunk_ordering_preservation(memory, request_context):
bank_id=bank_id,
content=content,
context="multi-section document",
document_id=document_id,
request_context=request_context,
document_id=document_id
)
assert len(unit_ids) > 0, "Should create units"
@@ -1117,8 +1083,7 @@ async def test_chunk_ordering_preservation(memory, request_context):
max_tokens=2000,
fact_type=["world"],
include_chunks=True,
max_chunk_tokens=8192,
request_context=request_context,
max_chunk_tokens=8192
)
if result.chunks:
@@ -1138,11 +1103,11 @@ async def test_chunk_ordering_preservation(memory, request_context):
print("✓ Content stored (may have created single chunk or no chunks returned)")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_chunks_truncation_behavior(memory, request_context):
async def test_chunks_truncation_behavior(memory):
"""
Test that when chunks exceed max_chunk_tokens, truncation is indicated.
"""
@@ -1200,8 +1165,7 @@ async def test_chunks_truncation_behavior(memory, request_context):
bank_id=bank_id,
content=large_content,
context="large document test",
document_id=document_id,
request_context=request_context,
document_id=document_id
)
assert len(unit_ids) > 0, "Should create units"
@@ -1214,8 +1178,7 @@ async def test_chunks_truncation_behavior(memory, request_context):
max_tokens=1000,
fact_type=["world"],
include_chunks=True,
max_chunk_tokens=500, # Small limit to test truncation
request_context=request_context,
max_chunk_tokens=500 # Small limit to test truncation
)
if result.chunks:
@@ -1235,7 +1198,7 @@ async def test_chunks_truncation_behavior(memory, request_context):
print("✓ No chunks returned (may be under token limit)")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
# ============================================================
@@ -1243,7 +1206,7 @@ async def test_chunks_truncation_behavior(memory, request_context):
# ============================================================
@pytest.mark.asyncio
async def test_temporal_links_creation(memory, request_context):
async def test_temporal_links_creation(memory):
"""
Test that temporal links are created between facts with nearby event dates.
@@ -1260,8 +1223,7 @@ async def test_temporal_links_creation(memory, request_context):
bank_id=bank_id,
content="Alice started working on the authentication module.",
context="daily standup",
event_date=base_date,
request_context=request_context,
event_date=base_date
)
# Fact 2 at 2:00 PM same day (4 hours later)
@@ -1269,8 +1231,7 @@ async def test_temporal_links_creation(memory, request_context):
bank_id=bank_id,
content="Bob reviewed the API design document.",
context="daily standup",
event_date=base_date.replace(hour=14),
request_context=request_context,
event_date=base_date.replace(hour=14)
)
# Fact 3 at 9:00 AM next day (23 hours later)
@@ -1278,8 +1239,7 @@ async def test_temporal_links_creation(memory, request_context):
bank_id=bank_id,
content="Charlie deployed the new database schema.",
context="daily standup",
event_date=base_date.replace(day=16, hour=9),
request_context=request_context,
event_date=base_date.replace(day=16, hour=9)
)
assert len(unit_ids_1) > 0 and len(unit_ids_2) > 0 and len(unit_ids_3) > 0
@@ -1318,11 +1278,11 @@ async def test_temporal_links_creation(memory, request_context):
logger.info("Temporal links created successfully with proper weights")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_semantic_links_creation(memory, request_context):
async def test_semantic_links_creation(memory):
"""
Test that semantic links are created between facts with similar content.
@@ -1335,24 +1295,21 @@ async def test_semantic_links_creation(memory, request_context):
unit_ids_1 = await memory.retain_async(
bank_id=bank_id,
content="Alice is an expert in Python programming and has built many web applications.",
context="team skills",
request_context=request_context,
context="team skills"
)
# Similar content - should create semantic link
unit_ids_2 = await memory.retain_async(
bank_id=bank_id,
content="Bob is proficient in Python development and specializes in building APIs.",
context="team skills",
request_context=request_context,
context="team skills"
)
# Different content - less likely to create strong semantic link
unit_ids_3 = await memory.retain_async(
bank_id=bank_id,
content="The quarterly sales meeting is scheduled for next Tuesday at 3 PM.",
context="calendar events",
request_context=request_context,
context="calendar events"
)
assert len(unit_ids_1) > 0 and len(unit_ids_2) > 0 and len(unit_ids_3) > 0
@@ -1392,11 +1349,11 @@ async def test_semantic_links_creation(memory, request_context):
logger.info("Semantic links created successfully between similar content")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_entity_links_creation(memory, request_context):
async def test_entity_links_creation(memory):
"""
Test that entity links are created between facts that mention the same entities.
@@ -1410,32 +1367,28 @@ async def test_entity_links_creation(memory, request_context):
unit_ids_1 = await memory.retain_async(
bank_id=bank_id,
content="Alice joined Google as a software engineer in 2020.",
context="career history",
request_context=request_context,
context="career history"
)
# Mentions same entity (Alice) - should create entity link
unit_ids_2 = await memory.retain_async(
bank_id=bank_id,
content="Alice led the development of the new authentication system.",
context="project updates",
request_context=request_context,
context="project updates"
)
# Mentions same entity (Google) - should create entity link
unit_ids_3 = await memory.retain_async(
bank_id=bank_id,
content="Google announced new cloud services at their annual conference.",
context="tech news",
request_context=request_context,
context="tech news"
)
# Different entities - no entity link expected
unit_ids_4 = await memory.retain_async(
bank_id=bank_id,
content="Bob works at Meta on machine learning infrastructure.",
context="career history",
request_context=request_context,
context="career history"
)
assert len(unit_ids_1) > 0 and len(unit_ids_2) > 0 and len(unit_ids_3) > 0 and len(unit_ids_4) > 0
@@ -1492,11 +1445,11 @@ async def test_entity_links_creation(memory, request_context):
logger.info("Entity links are properly bidirectional")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_causal_links_creation(memory, request_context):
async def test_causal_links_creation(memory):
"""
Test that causal links are created between facts with causal relationships.
@@ -1518,8 +1471,7 @@ async def test_causal_links_creation(memory, request_context):
unit_ids = await memory.retain_async(
bank_id=bank_id,
content=content,
context="project timeline",
request_context=request_context,
context="project timeline"
)
assert len(unit_ids) > 0, "Should have created facts"
@@ -1565,11 +1517,11 @@ async def test_causal_links_creation(memory, request_context):
logger.info("Test completed (causal link extraction is LLM-dependent)")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_all_link_types_together(memory, request_context):
async def test_all_link_types_together(memory):
"""
Integration test: Verify all link types can be created in a single retain operation.
@@ -1587,8 +1539,7 @@ async def test_all_link_types_together(memory, request_context):
bank_id=bank_id,
content="Alice completed the Python backend service for the authentication system.",
context="sprint review",
event_date=base_date,
request_context=request_context,
event_date=base_date
)
# Fact 2: Related to Alice, similar topic (Python), close in time
@@ -1596,8 +1547,7 @@ async def test_all_link_types_together(memory, request_context):
bank_id=bank_id,
content="Alice optimized the Python code and improved the authentication performance by 40%.",
context="sprint review",
event_date=base_date.replace(hour=14), # Same day, 4 hours later
request_context=request_context,
event_date=base_date.replace(hour=14) # Same day, 4 hours later
)
# Fact 3: Related to Alice, different topic but same entity
@@ -1605,8 +1555,7 @@ async def test_all_link_types_together(memory, request_context):
bank_id=bank_id,
content="Alice presented the security architecture at the team meeting.",
context="team meeting",
event_date=base_date.replace(day=16), # Next day
request_context=request_context,
event_date=base_date.replace(day=16) # Next day
)
assert len(unit_ids_1) > 0 and len(unit_ids_2) > 0 and len(unit_ids_3) > 0
@@ -1645,11 +1594,11 @@ async def test_all_link_types_together(memory, request_context):
logger.info("All major link types (temporal, semantic, entity) are working correctly")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_semantic_links_within_same_batch(memory, request_context):
async def test_semantic_links_within_same_batch(memory):
"""
Test that semantic links are created between facts retained in the SAME batch.
@@ -1668,8 +1617,7 @@ async def test_semantic_links_within_same_batch(memory, request_context):
result = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
request_context=request_context,
contents=contents
)
# Flatten the list of lists
@@ -1704,11 +1652,11 @@ async def test_semantic_links_within_same_batch(memory, request_context):
logger.info(f" Semantic link: {str(link['from_unit_id'])[:8]}... -> {str(link['to_unit_id'])[:8]}... (weight: {link['weight']:.3f})")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_temporal_links_within_same_batch(memory, request_context):
async def test_temporal_links_within_same_batch(memory):
"""
Test that temporal links are created between facts retained in the SAME batch.
@@ -1741,8 +1689,7 @@ async def test_temporal_links_within_same_batch(memory, request_context):
result = await memory.retain_batch_async(
bank_id=bank_id,
contents=contents,
request_context=request_context,
contents=contents
)
# Flatten the list of lists
@@ -1777,4 +1724,4 @@ async def test_temporal_links_within_same_batch(memory, request_context):
logger.info(f" Temporal link: {str(link['from_unit_id'])[:8]}... -> {str(link['to_unit_id'])[:8]}... (weight: {link['weight']:.3f})")
finally:
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@@ -1,406 +0,0 @@
"""
Tests for multi-tenant schema isolation.
Verifies that concurrent retain operations from different tenants
are properly isolated in their respective PostgreSQL schemas.
"""
import asyncio
import uuid
import pytest
import pytest_asyncio
from hindsight_api.extensions import RequestContext, TenantContext, TenantExtension
from hindsight_api.engine.memory_engine import _current_schema, fq_table
from hindsight_api.migrations import run_migrations
class MultiSchemaTestTenantExtension(TenantExtension):
"""
Test tenant extension that maps API keys to schema names.
API keys are in format: "key-{schema_name}"
Provisions schemas on first access using run_migrations(schema=name).
"""
def __init__(self, config: dict):
super().__init__(config)
self.db_url = config.get("db_url")
# Pre-configured valid schemas for test
self.valid_schemas = config.get("valid_schemas", set())
# Track provisioned schemas
self._provisioned: set[str] = set()
async def authenticate(self, context: RequestContext) -> TenantContext:
if not context.api_key:
from hindsight_api.extensions import AuthenticationError
raise AuthenticationError("API key required")
# Parse schema from API key (format: "key-{schema}")
if context.api_key.startswith("key-"):
schema = context.api_key[4:] # Remove "key-" prefix
if schema in self.valid_schemas:
# Provision schema on first access
if schema not in self._provisioned and self.db_url:
run_migrations(self.db_url, schema=schema)
self._provisioned.add(schema)
return TenantContext(schema_name=schema)
from hindsight_api.extensions import AuthenticationError
raise AuthenticationError(f"Unknown API key: {context.api_key}")
async def drop_schema(conn, schema_name: str) -> None:
"""Drop a schema and all its contents."""
await conn.execute(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE')
async def count_memories_in_schema(conn, schema_name: str, bank_id: str) -> int:
"""Count memory units in a specific schema for a bank."""
result = await conn.fetchval(
f'SELECT COUNT(*) FROM "{schema_name}".memory_units WHERE bank_id = $1',
bank_id,
)
return result or 0
async def get_memory_texts_in_schema(conn, schema_name: str, bank_id: str) -> list[str]:
"""Get all memory texts in a specific schema for a bank."""
rows = await conn.fetch(
f'SELECT text FROM "{schema_name}".memory_units WHERE bank_id = $1 ORDER BY text',
bank_id,
)
return [row["text"] for row in rows]
class TestSchemaIsolation:
"""Tests for multi-tenant schema isolation."""
@pytest.mark.asyncio
async def test_concurrent_inserts_isolated_by_schema(self, memory, pg0_db_url):
"""
Multiple concurrent database operations from different tenants
should store data in their respective schemas without cross-contamination.
Uses run_migrations(schema=x) to provision schemas like a real extension.
"""
import asyncpg
# Test schemas
schemas = ["tenant_alpha", "tenant_beta", "tenant_gamma"]
bank_id = f"test-isolation-{uuid.uuid4().hex[:8]}"
# Clean up any existing schemas
conn = await asyncpg.connect(pg0_db_url)
try:
for schema in schemas:
await drop_schema(conn, schema)
finally:
await conn.close()
# Configure tenant extension that provisions schemas via run_migrations
tenant_ext = MultiSchemaTestTenantExtension({
"db_url": pg0_db_url,
"valid_schemas": set(schemas),
})
memory._tenant_extension = tenant_ext
# Define concurrent insert tasks for each tenant
async def insert_for_tenant(schema_name: str, content_prefix: str):
"""Insert memories for a specific tenant using schema context."""
# Authenticate to set the schema context
tenant_request = RequestContext(api_key=f"key-{schema_name}")
await memory._authenticate_tenant(tenant_request)
# Now fq_table will use the correct schema
pool = await memory._get_pool()
from hindsight_api.engine.db_utils import acquire_with_retry
async with acquire_with_retry(pool) as conn:
# Insert 3 memories for this tenant
for i in range(3):
await conn.execute(
f"""
INSERT INTO {fq_table('memory_units')} (bank_id, text, event_date, fact_type)
VALUES ($1, $2, now(), 'world')
""",
bank_id,
f"MARKER_{content_prefix}_DOC{i}: Memory for {schema_name}",
)
# Run concurrent inserts for all tenants
await asyncio.gather(
insert_for_tenant("tenant_alpha", "ALPHA"),
insert_for_tenant("tenant_beta", "BETA"),
insert_for_tenant("tenant_gamma", "GAMMA"),
)
# Verify isolation - each schema should only have its own data
conn = await asyncpg.connect(pg0_db_url)
try:
for schema in schemas:
texts = await get_memory_texts_in_schema(conn, schema, bank_id)
prefix = schema.replace("tenant_", "").upper()
# Should have exactly 3 memories
assert len(texts) == 3, f"Schema {schema} should have 3 memories, got {len(texts)}"
# All texts should contain the schema's marker
for text in texts:
assert f"MARKER_{prefix}" in text, (
f"Memory in {schema} missing its marker: {text}"
)
# Should NOT contain other tenants' markers
other_prefixes = ["ALPHA", "BETA", "GAMMA"]
other_prefixes.remove(prefix)
for other in other_prefixes:
for text in texts:
assert f"MARKER_{other}" not in text, (
f"Cross-contamination! Schema {schema} has {other}'s marker: {text}"
)
finally:
# Cleanup
for schema in schemas:
await drop_schema(conn, schema)
await conn.close()
# Reset tenant extension
memory._tenant_extension = None
_current_schema.set("public")
@pytest.mark.asyncio
async def test_schema_context_isolation_in_concurrent_tasks(self, pg0_db_url):
"""
Verify that _current_schema contextvar is properly isolated
between concurrent async tasks.
"""
results = {}
errors = []
async def check_schema_context(schema_name: str, delay: float):
"""Set schema context, wait, then verify it's still correct."""
try:
# Set the schema
_current_schema.set(schema_name)
# Small delay to allow interleaving
await asyncio.sleep(delay)
# Verify schema is still correct
current = _current_schema.get()
if current != schema_name:
errors.append(f"Expected {schema_name}, got {current}")
# Verify fq_table uses correct schema
table = fq_table("memory_units")
expected = f"{schema_name}.memory_units"
if table != expected:
errors.append(f"Expected {expected}, got {table}")
results[schema_name] = current
except Exception as e:
errors.append(f"Error in {schema_name}: {e}")
# Run many concurrent tasks with different schemas
tasks = []
for i in range(10):
for schema in ["schema_a", "schema_b", "schema_c"]:
# Vary delays to create interleaving
delay = 0.01 * (i % 3)
tasks.append(check_schema_context(f"{schema}_{i}", delay))
await asyncio.gather(*tasks)
# No errors should have occurred
assert not errors, f"Schema context isolation errors: {errors}"
@pytest.mark.asyncio
async def test_list_memories_respects_schema(self, memory, pg0_db_url):
"""
list_memory_units should only return memories from the current schema.
Uses run_migrations(schema=x) to provision schemas.
"""
import asyncpg
schemas = ["tenant_list_a", "tenant_list_b"]
bank_id = f"test-list-{uuid.uuid4().hex[:8]}"
# Clean up any existing schemas and provision via migrations
conn = await asyncpg.connect(pg0_db_url)
try:
for schema in schemas:
await drop_schema(conn, schema)
finally:
await conn.close()
# Provision schemas using run_migrations
for schema in schemas:
run_migrations(pg0_db_url, schema=schema)
# Insert test data directly into each schema
conn = await asyncpg.connect(pg0_db_url)
try:
for schema in schemas:
await conn.execute(
f"""
INSERT INTO "{schema}".memory_units (bank_id, text, event_date, fact_type)
VALUES ($1, $2, now(), 'world')
""",
bank_id,
f"Direct insert for {schema}",
)
finally:
await conn.close()
# Configure tenant extension
tenant_ext = MultiSchemaTestTenantExtension({
"db_url": pg0_db_url,
"valid_schemas": set(schemas),
})
memory._tenant_extension = tenant_ext
try:
# Query as tenant_list_a - should only see tenant_list_a's data
tenant_a_request = RequestContext(api_key="key-tenant_list_a")
await memory._authenticate_tenant(tenant_a_request)
result_a = await memory.list_memory_units(bank_id=bank_id, request_context=tenant_a_request)
texts_a = [item["text"] for item in result_a.get("items", [])]
assert len(texts_a) == 1, f"Expected 1 memory for tenant_list_a, got {len(texts_a)}"
assert "tenant_list_a" in texts_a[0], f"Wrong content: {texts_a[0]}"
# Query as tenant_list_b - should only see tenant_list_b's data
tenant_b_request = RequestContext(api_key="key-tenant_list_b")
await memory._authenticate_tenant(tenant_b_request)
result_b = await memory.list_memory_units(bank_id=bank_id, request_context=tenant_b_request)
texts_b = [item["text"] for item in result_b.get("items", [])]
assert len(texts_b) == 1, f"Expected 1 memory for tenant_list_b, got {len(texts_b)}"
assert "tenant_list_b" in texts_b[0], f"Wrong content: {texts_b[0]}"
finally:
# Cleanup
conn = await asyncpg.connect(pg0_db_url)
try:
for schema in schemas:
await drop_schema(conn, schema)
finally:
await conn.close()
memory._tenant_extension = None
_current_schema.set("public")
@pytest.mark.asyncio
async def test_high_concurrency_schema_isolation(self, memory, pg0_db_url):
"""
Stress test: Many concurrent operations across multiple schemas
should maintain perfect isolation.
Uses run_migrations(schema=x) to provision schemas like a real extension.
"""
import asyncpg
# Create more schemas for stress test
num_schemas = 5
ops_per_schema = 10
schemas = [f"stress_tenant_{i}" for i in range(num_schemas)]
bank_id = f"test-stress-{uuid.uuid4().hex[:8]}"
# Clean up any existing schemas first
conn = await asyncpg.connect(pg0_db_url)
try:
for schema in schemas:
await drop_schema(conn, schema)
finally:
await conn.close()
# Provision schemas using run_migrations
for schema in schemas:
run_migrations(pg0_db_url, schema=schema)
# Configure tenant extension (schemas already provisioned)
tenant_ext = MultiSchemaTestTenantExtension({
"db_url": pg0_db_url,
"valid_schemas": set(schemas),
})
# Mark schemas as already provisioned so extension doesn't re-run migrations
tenant_ext._provisioned = set(schemas)
memory._tenant_extension = tenant_ext
errors = []
async def insert_one(schema: str, item_id: int):
"""Single insert operation for tracking."""
try:
# Authenticate to set the schema context
tenant_request = RequestContext(api_key=f"key-{schema}")
await memory._authenticate_tenant(tenant_request)
# Insert using fq_table
pool = await memory._get_pool()
from hindsight_api.engine.db_utils import acquire_with_retry
async with acquire_with_retry(pool) as conn:
await conn.execute(
f"""
INSERT INTO {fq_table('memory_units')} (bank_id, text, event_date, fact_type)
VALUES ($1, $2, now(), 'world')
""",
bank_id,
f"STRESS_MARKER_{schema}_ITEM{item_id}: Memory for {schema}",
)
except Exception as e:
errors.append(f"Insert error for {schema}: {e}")
# Run many concurrent operations
tasks = []
for i in range(ops_per_schema):
for schema in schemas:
tasks.append(insert_one(schema, i))
await asyncio.gather(*tasks)
# Check for errors during insert
assert not errors, f"Errors during insert: {errors}"
# Verify no cross-contamination
conn = await asyncpg.connect(pg0_db_url)
try:
for schema in schemas:
texts = await get_memory_texts_in_schema(conn, schema, bank_id)
# Should have exactly ops_per_schema memories
assert len(texts) == ops_per_schema, (
f"Schema {schema} should have {ops_per_schema} memories, got {len(texts)}"
)
# All memories should reference this schema only
for text in texts:
# Check it contains our schema marker
assert f"STRESS_MARKER_{schema}" in text, (
f"Memory in {schema} doesn't contain schema marker: {text}"
)
# Check it doesn't contain other schema markers
for other_schema in schemas:
if other_schema != schema:
assert f"STRESS_MARKER_{other_schema}" not in text, (
f"Cross-contamination! {schema} has {other_schema}'s data: {text}"
)
finally:
# Cleanup
for schema in schemas:
await drop_schema(conn, schema)
await conn.close()
memory._tenant_extension = None
_current_schema.set("public")
+5 -11
View File
@@ -3,12 +3,12 @@ Test search tracing functionality.
"""
import pytest
from hindsight_api.engine.memory_engine import Budget
from hindsight_api import SearchTrace, RequestContext
from hindsight_api import SearchTrace
from datetime import datetime, timezone
@pytest.mark.asyncio
async def test_search_with_trace(memory, request_context):
async def test_search_with_trace(memory):
"""Test that search with enable_trace=True returns a valid SearchTrace."""
# Generate a unique agent ID for this test
bank_id = f"test_trace_{datetime.now(timezone.utc).timestamp()}"
@@ -20,19 +20,16 @@ async def test_search_with_trace(memory, request_context):
bank_id=bank_id,
content="Alice works at Google in Mountain View",
context="test context",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="Bob also works at Google but in New York",
context="test context",
request_context=request_context,
)
await memory.retain_async(
bank_id=bank_id,
content="Charlie founded a startup called TechCorp",
context="test context",
request_context=request_context,
)
# Search with tracing enabled
@@ -43,7 +40,6 @@ async def test_search_with_trace(memory, request_context):
budget=Budget.LOW, # 20,
max_tokens=512,
enable_trace=True,
request_context=request_context,
)
# Verify results
@@ -106,11 +102,11 @@ async def test_search_with_trace(memory, request_context):
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@pytest.mark.asyncio
async def test_search_without_trace(memory, request_context):
async def test_search_without_trace(memory):
"""Test that search with enable_trace=False returns None for trace."""
bank_id = f"test_no_trace_{datetime.now(timezone.utc).timestamp()}"
@@ -121,7 +117,6 @@ async def test_search_without_trace(memory, request_context):
bank_id=bank_id,
content="Test memory without trace",
context="test",
request_context=request_context,
)
# Search without tracing
@@ -132,7 +127,6 @@ async def test_search_without_trace(memory, request_context):
budget=Budget.LOW, # 10,
max_tokens=512,
enable_trace=False,
request_context=request_context,
)
# Verify trace is None
@@ -143,4 +137,4 @@ async def test_search_without_trace(memory, request_context):
finally:
# Cleanup
await memory.delete_bank(bank_id, request_context=request_context)
await memory.delete_bank(bank_id)
@@ -1,136 +0,0 @@
"""
Safety tests to ensure all SQL queries use fully-qualified table names.
This prevents cross-tenant data access by ensuring every table reference
includes the schema prefix (e.g., public.memory_units instead of just memory_units).
"""
import re
from pathlib import Path
import pytest
# All tables that MUST be schema-qualified in SQL queries
TABLES = [
"memory_units",
"memory_links",
"unit_entities",
"entities",
"entity_cooccurrences",
"banks",
"documents",
"chunks",
"async_operations",
]
# Files to scan for SQL queries
SCAN_PATHS = [
"hindsight_api/engine",
"hindsight_api/api",
]
# Files to exclude (e.g., migrations, tests)
EXCLUDE_PATTERNS = [
"alembic",
"__pycache__",
"test_",
]
def get_python_files() -> list[Path]:
"""Get all Python files to scan."""
root = Path(__file__).parent.parent
files = []
for scan_path in SCAN_PATHS:
path = root / scan_path
if path.exists():
for py_file in path.rglob("*.py"):
# Check exclusions
if any(excl in str(py_file) for excl in EXCLUDE_PATTERNS):
continue
files.append(py_file)
return files
def find_unqualified_table_refs(content: str, filename: str) -> list[tuple[int, str, str]]:
"""
Find SQL statements with unqualified table references.
Returns list of (line_number, table_name, line_content).
"""
violations = []
# Patterns that indicate SQL context
sql_keywords = r"(?:FROM|JOIN|INTO|UPDATE|DELETE\s+FROM)\s+"
# Additional SQL indicators to confirm this is actually SQL, not prose
sql_indicators = re.compile(
r"(SELECT|INSERT|DELETE|UPDATE|CREATE|ALTER|DROP|WHERE|SET|VALUES|"
r'f"""|f\'\'\'|""".*SELECT|\'\'\'.*SELECT)',
re.IGNORECASE,
)
lines = content.split("\n")
for line_num, line in enumerate(lines, 1):
# Skip comments and strings that are clearly not SQL
stripped = line.strip()
if stripped.startswith("#"):
continue
for table in TABLES:
# Pattern: SQL keyword followed by unqualified table name
# Should match: FROM memory_units, JOIN memory_units, INTO memory_units
# Should NOT match: FROM public.memory_units, FROM {schema}.memory_units
# Should NOT match: fq_table("memory_units")
# Check for unqualified table after SQL keyword
pattern = rf"{sql_keywords}{table}(?:\s|$|,|\))"
if re.search(pattern, line, re.IGNORECASE):
# Check if it's actually qualified (has schema prefix)
qualified_pattern = rf"\.\s*{table}(?:\s|$|,|\))"
fq_table_pattern = rf'fq_table\s*\(\s*["\']?{table}'
if not re.search(qualified_pattern, line) and not re.search(
fq_table_pattern, line
):
# Additional check: line must have SQL indicators
# This avoids false positives in docstrings like "split into chunks"
if sql_indicators.search(line):
violations.append((line_num, table, stripped))
return violations
class TestSQLSchemaSafety:
"""Ensure all SQL uses schema-qualified table names."""
def test_no_unqualified_table_references(self):
"""All SQL queries must use fq_table() or schema.table format."""
all_violations = []
for py_file in get_python_files():
content = py_file.read_text()
violations = find_unqualified_table_refs(content, py_file.name)
for line_num, table, line in violations:
all_violations.append(
f"{py_file.relative_to(py_file.parent.parent)}:{line_num} - "
f"unqualified '{table}': {line[:80]}..."
)
if all_violations:
msg = (
f"Found {len(all_violations)} unqualified table references!\n"
"These could cause cross-tenant data access.\n"
"Use fq_table('table_name') for all table references.\n\n"
+ "\n".join(all_violations[:20]) # Show first 20
)
if len(all_violations) > 20:
msg += f"\n... and {len(all_violations) - 20} more"
pytest.fail(msg)
def test_tables_list_is_complete(self):
"""Verify we're checking for all tables (sanity check)."""
# This is a sanity check - if you add a new table, add it to TABLES
assert len(TABLES) >= 9, "Update TABLES list if you added new tables"

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