Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a6942fb06 | ||
|
|
b1f8627eed | ||
|
|
8573cc3292 | ||
|
|
eb725002c0 | ||
|
|
2de75062eb | ||
|
|
e599346e59 | ||
|
|
0b352d1bfa | ||
|
|
c882511f10 | ||
|
|
234d426499 | ||
|
|
e6511e7d77 | ||
|
|
904ea4de24 | ||
|
|
6168a77846 | ||
|
|
da44a5e839 | ||
|
|
32bca12c6f | ||
|
|
26850a0156 | ||
|
|
2a0c490c9e | ||
|
|
a831a7b77b | ||
|
|
d405b4feed | ||
|
|
b94b5cf26e | ||
|
|
6d820ef91b | ||
|
|
cf8882a867 | ||
|
|
490fccdc6f | ||
|
|
2948cb62d2 | ||
|
|
9053a51a88 | ||
|
|
f2c28cfd98 | ||
|
|
67fc532c43 | ||
|
|
9474f950f2 | ||
|
|
6a0c034f5d | ||
|
|
b52eb905ad | ||
|
|
1c6acc3ba0 | ||
|
|
8ecb5d3a0c | ||
|
|
ae80876671 | ||
|
|
476a62da47 | ||
|
|
5aaa769ab9 | ||
|
|
04f01ab9ab | ||
|
|
63f51385c4 | ||
|
|
e468a4e19f | ||
|
|
c0a0f447b7 | ||
|
|
84927ccc99 | ||
|
|
a6e8944ff0 | ||
|
|
f6d890f6ed | ||
|
|
1fa8d9150c | ||
|
|
656777c2be | ||
|
|
b36807ad3b | ||
|
|
11ac9cd9a5 | ||
|
|
9394cf92f2 | ||
|
|
47be07f97f | ||
|
|
bb1f9cb221 | ||
|
|
7dd68538bb | ||
|
|
1cef364719 | ||
|
|
dff293ca8c | ||
|
|
f4bc8443b3 | ||
|
|
ae26a8603b | ||
|
|
183b9dacb4 | ||
|
|
8a7c6e4e91 | ||
|
|
dfccbf29f1 | ||
|
|
dfea4dbe15 | ||
|
|
fcea8afa6c | ||
|
|
94c2b85c81 | ||
|
|
160c5581ec | ||
|
|
70983f5817 | ||
|
|
44e9571572 | ||
|
|
7445cef7b7 | ||
|
|
f018cc5677 | ||
|
|
922164e25c | ||
|
|
d6b7b9b398 | ||
|
|
158a6aac9a | ||
|
|
38e73a1414 | ||
|
|
2c1be4cf47 | ||
|
|
f148d3e338 | ||
|
|
99db7b26c3 | ||
|
|
ebc85a5c3d | ||
|
|
ae30882ec9 | ||
|
|
fa554b8980 | ||
|
|
f813a807e7 | ||
|
|
f7e8b1097b | ||
|
|
522a491fc1 | ||
|
|
1056a20e71 | ||
|
|
01ba9744e5 | ||
|
|
44e79feb3e | ||
|
|
94665b2111 | ||
|
|
f42476bf94 | ||
|
|
52826de55d | ||
|
|
0000c54509 | ||
|
|
e677a018d7 | ||
|
|
4191597098 | ||
|
|
e722a48b14 | ||
|
|
f7789f4961 | ||
|
|
edbf88700e | ||
|
|
040eb33ea6 | ||
|
|
cffb14f166 | ||
|
|
3ebe262a13 | ||
|
|
04b2fcf0b5 | ||
|
|
bbfdcd36e4 | ||
|
|
e96cb9694a | ||
|
|
3e8426d87b | ||
|
|
b0c7bba5a1 | ||
|
|
6daa3ad135 | ||
|
|
b5abeb5613 | ||
|
|
eef43f59c2 | ||
|
|
76cfa8f9c4 | ||
|
|
3bb0a58ded | ||
|
|
cf2f739469 | ||
|
|
2b7b26cc79 | ||
|
|
c41490085a | ||
|
|
e2fea8fecc | ||
|
|
841fe65541 | ||
|
|
83dab57211 | ||
|
|
5ad2dfe03e | ||
|
|
ebe468e54f | ||
|
|
f69ea6ee61 | ||
|
|
ab10162d51 | ||
|
|
1053e9f264 | ||
|
|
f7884f5e2f | ||
|
|
06b956a553 | ||
|
|
718b702877 | ||
|
|
b4a2915d89 | ||
|
|
8e575ce619 | ||
|
|
377f5513d4 | ||
|
|
425c6f3fc9 | ||
|
|
91bc3b02bc | ||
|
|
3402bf15ee | ||
|
|
bb434f3f1a | ||
|
|
70f09efb73 | ||
|
|
d72a33909e | ||
|
|
b83fd3a5c3 | ||
|
|
e0cfec1666 | ||
|
|
27d00f3d14 | ||
|
|
6073ac4ffd | ||
|
|
9b69202525 | ||
|
|
bb6bec511c | ||
|
|
4b8fccb5e8 | ||
|
|
1c5981b1f2 | ||
|
|
b0d71e29de | ||
|
|
e57e906284 | ||
|
|
38f35895e3 | ||
|
|
58592d4abc | ||
|
|
a14024775b | ||
|
|
27a7d56168 | ||
|
|
2c0e5d1d2b | ||
|
|
b0961ccf6b | ||
|
|
9e1daa0a31 | ||
|
|
a4a974307a | ||
|
|
902eed711d | ||
|
|
a8c23b1e1e | ||
|
|
850d4036c3 | ||
|
|
b75c796557 | ||
|
|
050a3d2743 | ||
|
|
e1424357c4 | ||
|
|
446fd841a8 | ||
|
|
1bf08c18a6 |
@@ -0,0 +1,32 @@
|
||||
# Node modules (platform-specific native bindings)
|
||||
**/node_modules
|
||||
**/.next
|
||||
|
||||
# Python
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/.venv
|
||||
**/dist
|
||||
**/*.egg-info
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
*.swp
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build artifacts
|
||||
**/target
|
||||
**/*.log
|
||||
|
||||
# Test/Dev
|
||||
**/coverage
|
||||
**/.pytest_cache
|
||||
**/.mypy_cache
|
||||
+18
-1
@@ -2,8 +2,9 @@
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# LLM Configuration (Required)
|
||||
HINDSIGHT_API_LLM_PROVIDER=openai
|
||||
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
|
||||
HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
|
||||
HINDSIGHT_API_LLM_MODEL=o3-mini
|
||||
HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
|
||||
|
||||
# API Configuration (Optional)
|
||||
@@ -13,3 +14,19 @@ HINDSIGHT_API_LOG_LEVEL=info
|
||||
|
||||
# Database (Optional - uses embedded pg0 by default)
|
||||
# HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||
|
||||
# Embeddings Configuration (Optional - uses local by default)
|
||||
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
|
||||
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
|
||||
# For local provider:
|
||||
# HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5
|
||||
# For TEI provider:
|
||||
# HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080
|
||||
|
||||
# Reranker Configuration (Optional - uses local by default)
|
||||
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
|
||||
# HINDSIGHT_API_RERANKER_PROVIDER=local
|
||||
# For local provider:
|
||||
# HINDSIGHT_API_RERANKER_LOCAL_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
|
||||
# For TEI provider:
|
||||
# HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
|
||||
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# Pre-commit hook - runs all scripts in scripts/hooks/
|
||||
|
||||
set -e
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
HOOKS_DIR="$REPO_ROOT/scripts/hooks"
|
||||
|
||||
if [ ! -d "$HOOKS_DIR" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Running pre-commit hooks ==="
|
||||
echo ""
|
||||
|
||||
# Run all executable scripts in hooks directory
|
||||
for hook in "$HOOKS_DIR"/*.sh; do
|
||||
if [ -x "$hook" ]; then
|
||||
echo "[hook] $(basename "$hook")"
|
||||
(cd "$REPO_ROOT" && "$hook")
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Pre-commit hooks completed ==="
|
||||
echo ""
|
||||
@@ -20,18 +20,17 @@ 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: hindsight-docs/package-lock.json
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
cache-dependency-path: package-lock.json
|
||||
- uses: astral-sh/setup-uv@v4
|
||||
- run: npm ci --workspace=hindsight-docs
|
||||
- run: uv run generate-llms-full
|
||||
- run: npm run build --workspace=hindsight-docs
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: hindsight-docs/build
|
||||
|
||||
+223
-240
@@ -1,4 +1,4 @@
|
||||
name: Build Release Artifacts
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -6,17 +6,11 @@ on:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-python-packages:
|
||||
release-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
|
||||
environment: pypi
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -31,19 +25,74 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Build ${{ matrix.name }} package
|
||||
working-directory: ./${{ matrix.path }}
|
||||
run: uv build
|
||||
# Build all packages
|
||||
- name: Build hindsight-client
|
||||
working-directory: ./hindsight-clients/python
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Build hindsight-api
|
||||
working-directory: ./hindsight-api
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Build hindsight-all
|
||||
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
|
||||
with:
|
||||
packages-dir: ./hindsight-clients/python/dist
|
||||
skip-existing: true
|
||||
|
||||
- name: Publish hindsight-api to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-api/dist
|
||||
skip-existing: true
|
||||
|
||||
- name: Publish hindsight-all to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
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
|
||||
with:
|
||||
name: python-${{ matrix.name }}-dist
|
||||
path: ${{ matrix.path }}/dist/*
|
||||
retention-days: 30
|
||||
name: python-packages
|
||||
path: |
|
||||
hindsight-clients/python/dist/*
|
||||
hindsight-api/dist/*
|
||||
hindsight/dist/*
|
||||
hindsight-integrations/litellm/dist/*
|
||||
hindsight-embed/dist/*
|
||||
retention-days: 1
|
||||
|
||||
build-typescript-client:
|
||||
release-typescript-client:
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -53,27 +102,103 @@ jobs:
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-clients/typescript
|
||||
run: npm ci
|
||||
run: npm ci --workspace=hindsight-clients/typescript
|
||||
|
||||
- name: Build TypeScript client
|
||||
working-directory: ./hindsight-clients/typescript
|
||||
run: npm run build
|
||||
- name: Build
|
||||
run: npm run build --workspace=hindsight-clients/typescript
|
||||
|
||||
- name: Pack npm package
|
||||
- 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
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Pack for GitHub release
|
||||
working-directory: ./hindsight-clients/typescript
|
||||
run: npm pack
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: typescript-client-dist
|
||||
name: typescript-client
|
||||
path: hindsight-clients/typescript/*.tgz
|
||||
retention-days: 30
|
||||
retention-days: 1
|
||||
|
||||
build-rust-cli:
|
||||
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:
|
||||
matrix:
|
||||
@@ -99,24 +224,6 @@ jobs:
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cargo/registry
|
||||
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Cache cargo index
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cargo/git
|
||||
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Cache cargo build
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: hindsight-cli/target
|
||||
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Build
|
||||
working-directory: hindsight-cli
|
||||
run: cargo build --release --target ${{ matrix.target }}
|
||||
@@ -132,9 +239,9 @@ jobs:
|
||||
with:
|
||||
name: rust-cli-${{ matrix.asset_name }}
|
||||
path: artifacts/${{ matrix.asset_name }}
|
||||
retention-days: 30
|
||||
retention-days: 1
|
||||
|
||||
build-docker-images:
|
||||
release-docker-images:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -142,7 +249,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
# All images use the same Dockerfile with different --target
|
||||
- target: api-only
|
||||
image_name: hindsight-api
|
||||
- target: cp-only
|
||||
@@ -156,7 +262,7 @@ jobs:
|
||||
- name: Free Disk Space
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: false
|
||||
tool-cache: true
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
@@ -181,7 +287,7 @@ jobs:
|
||||
id: get_version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract metadata
|
||||
- name: Extract metadata for release tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
@@ -192,7 +298,29 @@ jobs:
|
||||
type=semver,pattern={{major}},value=${{ steps.get_version.outputs.VERSION }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
# TODO: Re-enable smoke test when disk space issue is resolved
|
||||
# # Step 1: Build for local testing (single platform, no push)
|
||||
# # This creates an identical image to what will be released, just for one platform
|
||||
# - name: Build image for testing
|
||||
# uses: docker/build-push-action@v6
|
||||
# with:
|
||||
# context: .
|
||||
# file: docker/standalone/Dockerfile
|
||||
# target: ${{ matrix.target }}
|
||||
# push: false
|
||||
# load: true
|
||||
# tags: ${{ matrix.image_name }}:test
|
||||
# cache-from: type=gha
|
||||
# cache-to: type=gha,mode=max
|
||||
|
||||
# # Step 2: Test the image before pushing anything
|
||||
# - name: Smoke test - verify container starts
|
||||
# env:
|
||||
# GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
# run: ./scripts/docker-smoke-test.sh "${{ matrix.image_name }}:test" "${{ matrix.target }}"
|
||||
|
||||
# Build multi-platform and push to release tags
|
||||
- name: Build and push release images
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
@@ -202,11 +330,12 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
package-helm-chart:
|
||||
release-helm-chart:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -216,78 +345,28 @@ jobs:
|
||||
with:
|
||||
version: 'latest'
|
||||
|
||||
- name: Log in to GHCR
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Lint Helm chart
|
||||
run: |
|
||||
helm lint helm/hindsight
|
||||
run: helm lint helm/hindsight
|
||||
|
||||
- name: Package Helm chart
|
||||
run: |
|
||||
helm package helm/hindsight --destination ./helm-packages
|
||||
run: helm package helm/hindsight --destination ./helm-packages
|
||||
|
||||
- name: Upload Helm chart artifact
|
||||
- name: Push to GHCR OCI
|
||||
run: helm push helm-packages/*.tgz oci://ghcr.io/${{ github.repository_owner }}/charts
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: helm-chart
|
||||
path: helm-packages/*.tgz
|
||||
retention-days: 30
|
||||
|
||||
publish-python-packages:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-python-packages]
|
||||
environment: pypi
|
||||
strategy:
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
include:
|
||||
# Order matters: client and api first, then hindsight-all (which depends on them)
|
||||
- name: hindsight-client
|
||||
- name: hindsight-api
|
||||
- name: hindsight-all
|
||||
|
||||
steps:
|
||||
- name: Download ${{ matrix.name }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: python-${{ matrix.name }}-dist
|
||||
path: ./dist
|
||||
|
||||
- name: Publish ${{ matrix.name }} to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./dist
|
||||
skip-existing: true
|
||||
|
||||
publish-npm-package:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-typescript-client]
|
||||
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'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-clients/typescript
|
||||
run: npm ci
|
||||
|
||||
- name: Build TypeScript client
|
||||
working-directory: ./hindsight-clients/typescript
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: ./hindsight-clients/typescript
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
retention-days: 1
|
||||
|
||||
create-github-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-python-packages, build-typescript-client, build-rust-cli, build-docker-images, package-helm-chart]
|
||||
needs: [release-python-packages, release-typescript-client, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -298,47 +377,41 @@ jobs:
|
||||
id: get_version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download hindsight-all
|
||||
- name: Download Python packages
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: python-hindsight-all-dist
|
||||
path: ./artifacts/python-hindsight-all
|
||||
name: python-packages
|
||||
path: ./artifacts/python-packages
|
||||
|
||||
- name: Download hindsight-api
|
||||
- name: Download TypeScript client
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: python-hindsight-api-dist
|
||||
path: ./artifacts/python-hindsight-api
|
||||
|
||||
- name: Download hindsight-client
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: python-hindsight-client-dist
|
||||
path: ./artifacts/python-hindsight-client
|
||||
|
||||
- name: Download TypeScript Client
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: typescript-client-dist
|
||||
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:
|
||||
name: rust-cli-hindsight-linux-amd64
|
||||
path: ./artifacts/rust-cli-hindsight-linux-amd64
|
||||
path: ./artifacts/rust-cli-linux
|
||||
|
||||
- name: Download Rust CLI (macOS Intel)
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rust-cli-hindsight-darwin-amd64
|
||||
path: ./artifacts/rust-cli-hindsight-darwin-amd64
|
||||
path: ./artifacts/rust-cli-darwin-amd64
|
||||
|
||||
- name: Download Rust CLI (macOS ARM)
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: rust-cli-hindsight-darwin-arm64
|
||||
path: ./artifacts/rust-cli-hindsight-darwin-arm64
|
||||
path: ./artifacts/rust-cli-darwin-arm64
|
||||
|
||||
- name: Download Helm chart
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -350,119 +423,29 @@ jobs:
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
# Python packages
|
||||
cp artifacts/python-hindsight-all/* release-assets/
|
||||
cp artifacts/python-hindsight-api/* release-assets/
|
||||
cp artifacts/python-hindsight-client/* release-assets/
|
||||
# TypeScript Client
|
||||
cp artifacts/typescript-client/*.tgz release-assets/
|
||||
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-hindsight-linux-amd64/hindsight-linux-amd64 release-assets/
|
||||
cp artifacts/rust-cli-hindsight-darwin-amd64/hindsight-darwin-amd64 release-assets/
|
||||
cp artifacts/rust-cli-hindsight-darwin-arm64/hindsight-darwin-arm64 release-assets/
|
||||
cp artifacts/rust-cli-linux/hindsight-linux-amd64 release-assets/ || true
|
||||
cp artifacts/rust-cli-darwin-amd64/hindsight-darwin-amd64 release-assets/ || true
|
||||
cp artifacts/rust-cli-darwin-arm64/hindsight-darwin-arm64 release-assets/ || true
|
||||
# Helm chart
|
||||
cp artifacts/helm-chart/*.tgz release-assets/
|
||||
|
||||
- name: Generate release notes
|
||||
id: release_notes
|
||||
run: |
|
||||
cat << 'EOF' > release-notes.md
|
||||
# Hindsight v${{ steps.get_version.outputs.VERSION }}
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
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 }}
|
||||
```
|
||||
|
||||
## 📦 Release Artifacts
|
||||
|
||||
### Docker Images
|
||||
- `ghcr.io/${{ github.repository_owner }}/hindsight:${{ steps.get_version.outputs.VERSION }}` - **Standalone all-in-one** (recommended)
|
||||
- `ghcr.io/${{ github.repository_owner }}/hindsight-api:${{ steps.get_version.outputs.VERSION }}` - API server only
|
||||
- `ghcr.io/${{ github.repository_owner }}/hindsight-control-plane:${{ steps.get_version.outputs.VERSION }}` - Web UI only
|
||||
|
||||
### Python Packages
|
||||
- `hindsight-all` - All-in-one package (includes API + client)
|
||||
- `hindsight-api` - API server
|
||||
- `hindsight-client` - Client library
|
||||
|
||||
### TypeScript/JavaScript
|
||||
- `@hindsight/client` - TypeScript SDK
|
||||
|
||||
### CLI Binaries
|
||||
- `hindsight-linux-amd64` - Linux x86_64
|
||||
- `hindsight-darwin-amd64` - macOS Intel
|
||||
- `hindsight-darwin-arm64` - macOS Apple Silicon
|
||||
|
||||
### Helm Chart
|
||||
- `hindsight-${{ steps.get_version.outputs.VERSION }}.tgz`
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
### Python
|
||||
```bash
|
||||
# All-in-one (recommended)
|
||||
pip install hindsight-all==${{ steps.get_version.outputs.VERSION }}
|
||||
|
||||
# Or install components separately
|
||||
pip install hindsight-api==${{ steps.get_version.outputs.VERSION }}
|
||||
pip install hindsight-client==${{ steps.get_version.outputs.VERSION }}
|
||||
```
|
||||
|
||||
### TypeScript/JavaScript
|
||||
```bash
|
||||
npm install @hindsight/client@${{ steps.get_version.outputs.VERSION }}
|
||||
```
|
||||
|
||||
### CLI
|
||||
```bash
|
||||
# macOS (Apple Silicon)
|
||||
curl -L https://github.com/${{ github.repository }}/releases/download/v${{ steps.get_version.outputs.VERSION }}/hindsight-darwin-arm64 -o hindsight
|
||||
chmod +x hindsight
|
||||
sudo mv hindsight /usr/local/bin/
|
||||
|
||||
# macOS (Intel)
|
||||
curl -L https://github.com/${{ github.repository }}/releases/download/v${{ steps.get_version.outputs.VERSION }}/hindsight-darwin-amd64 -o hindsight
|
||||
chmod +x hindsight
|
||||
sudo mv hindsight /usr/local/bin/
|
||||
|
||||
# Linux
|
||||
curl -L https://github.com/${{ github.repository }}/releases/download/v${{ steps.get_version.outputs.VERSION }}/hindsight-linux-amd64 -o hindsight
|
||||
chmod +x hindsight
|
||||
sudo mv hindsight /usr/local/bin/
|
||||
```
|
||||
|
||||
### Helm (Kubernetes)
|
||||
```bash
|
||||
helm install hindsight oci://ghcr.io/${{ github.repository_owner }}/charts/hindsight --version ${{ steps.get_version.outputs.VERSION }}
|
||||
```
|
||||
EOF
|
||||
cat release-notes.md
|
||||
cp artifacts/helm-chart/*.tgz release-assets/ || true
|
||||
ls -la release-assets/
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release-assets/*
|
||||
body_path: release-notes.md
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create release summary
|
||||
run: |
|
||||
echo "# Release v${{ steps.get_version.outputs.VERSION }} Published Successfully" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## 📦 Components" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- ✅ Python packages (hindsight-all, hindsight-api, hindsight-client)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- ✅ TypeScript Client (@hindsight/client)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- ✅ Rust CLI (Linux amd64, macOS amd64, macOS arm64)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- ✅ Docker images (standalone, API, Control Plane)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- ✅ Helm chart" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "🎉 Release is now available at: https://github.com/${{ github.repository }}/releases/tag/v${{ steps.get_version.outputs.VERSION }}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
+770
-31
@@ -1,35 +1,27 @@
|
||||
name: Run Tests
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
build-python-packages:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: hindsight_test
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
env:
|
||||
HINDSIGHT_API_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/hindsight_test
|
||||
HINDSIGHT_API_LLM_PROVIDER: groq
|
||||
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
|
||||
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
|
||||
@@ -44,13 +36,760 @@ jobs:
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra test
|
||||
- name: Build ${{ matrix.name }}
|
||||
working-directory: ./${{ matrix.path }}
|
||||
run: uv build
|
||||
|
||||
- name: Run migrations
|
||||
working-directory: ./hindsight
|
||||
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: |
|
||||
uv run alembic upgrade head
|
||||
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
|
||||
|
||||
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-docs
|
||||
|
||||
- name: Build docs
|
||||
run: npm run build --workspace=hindsight-docs
|
||||
|
||||
build-rust-cli:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
hindsight-cli/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Build CLI
|
||||
working-directory: hindsight-cli
|
||||
run: cargo build --release
|
||||
|
||||
- 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
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
with:
|
||||
version: 'latest'
|
||||
|
||||
- name: Lint Helm chart
|
||||
run: helm lint helm/hindsight
|
||||
|
||||
build-docker-images:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: api-only
|
||||
name: api
|
||||
- target: cp-only
|
||||
name: control-plane
|
||||
- target: standalone
|
||||
name: standalone
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Free Disk Space
|
||||
uses: jlumbroso/free-disk-space@main
|
||||
with:
|
||||
tool-cache: true
|
||||
android: true
|
||||
dotnet: true
|
||||
haskell: true
|
||||
large-packages: true
|
||||
docker-images: true
|
||||
swap-storage: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build ${{ matrix.name }} image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
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)
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Build API
|
||||
working-directory: ./hindsight-api
|
||||
run: uv build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-api
|
||||
run: uv sync --extra test --no-install-project --index-strategy unsafe-best-match
|
||||
|
||||
- name: Cache HuggingFace models
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/huggingface
|
||||
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-huggingface-
|
||||
|
||||
- name: Pre-download models
|
||||
working-directory: ./hindsight-api
|
||||
run: |
|
||||
uv run python -c "
|
||||
from sentence_transformers import SentenceTransformer, CrossEncoder
|
||||
print('Downloading embedding model...')
|
||||
SentenceTransformer('BAAI/bge-small-en-v1.5')
|
||||
print('Downloading cross-encoder model...')
|
||||
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
|
||||
print('Models downloaded successfully')
|
||||
"
|
||||
|
||||
- name: Run tests
|
||||
run: uv run pytest hindsight/tests -v
|
||||
working-directory: ./hindsight-api
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-python-client:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: groq
|
||||
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
|
||||
HINDSIGHT_API_URL: http://localhost:8888
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Build API
|
||||
working-directory: ./hindsight-api
|
||||
run: uv build
|
||||
|
||||
- name: Build Python client
|
||||
working-directory: ./hindsight-clients/python
|
||||
run: uv build
|
||||
|
||||
- name: Install client test dependencies
|
||||
working-directory: ./hindsight-clients/python
|
||||
run: uv sync --extra test --index-strategy unsafe-best-match
|
||||
|
||||
- name: Install API dependencies
|
||||
working-directory: ./hindsight-api
|
||||
run: uv sync --no-install-project --index-strategy unsafe-best-match
|
||||
|
||||
- name: 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 client tests
|
||||
working-directory: ./hindsight-clients/python
|
||||
run: uv run pytest tests -v
|
||||
|
||||
- name: Show API server logs
|
||||
if: always()
|
||||
run: |
|
||||
echo "=== API Server Logs ==="
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-typescript-client:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: groq
|
||||
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
|
||||
HINDSIGHT_API_URL: http://localhost:8888
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Build API
|
||||
working-directory: ./hindsight-api
|
||||
run: uv build
|
||||
|
||||
- name: Install API dependencies
|
||||
working-directory: ./hindsight-api
|
||||
run: uv sync --no-install-project --index-strategy unsafe-best-match
|
||||
|
||||
- name: Install TypeScript client dependencies
|
||||
working-directory: ./hindsight-clients/typescript
|
||||
run: npm ci
|
||||
|
||||
- name: Build TypeScript client
|
||||
working-directory: ./hindsight-clients/typescript
|
||||
run: npm run build
|
||||
|
||||
- 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 TypeScript client tests
|
||||
working-directory: ./hindsight-clients/typescript
|
||||
run: npm test
|
||||
|
||||
- name: Show API server logs
|
||||
if: always()
|
||||
run: |
|
||||
echo "=== API Server Logs ==="
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-rust-client:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: groq
|
||||
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
|
||||
HINDSIGHT_API_URL: http://localhost:8888
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Prefer CPU-only PyTorch in CI (but keep PyPI for everything else)
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
hindsight-clients/rust/target
|
||||
key: ${{ runner.os }}-cargo-client-${{ hashFiles('hindsight-clients/rust/Cargo.lock') }}
|
||||
|
||||
- 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 Rust client tests
|
||||
working-directory: ./hindsight-clients/rust
|
||||
run: cargo test --lib
|
||||
|
||||
- name: Show API server logs
|
||||
if: always()
|
||||
run: |
|
||||
echo "=== API Server Logs ==="
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-litellm-integration:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
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"
|
||||
+9
-1
@@ -9,6 +9,9 @@ wheels/
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
@@ -29,6 +32,11 @@ logs/
|
||||
|
||||
.DS_Store
|
||||
|
||||
# Generated docs files
|
||||
hindsight-docs/static/llms-full.txt
|
||||
|
||||
|
||||
hindsight-dev/benchmarks/locomo/results/
|
||||
hindsight-dev/benchmarks/longmemeval/results/
|
||||
hindsight-dev/benchmarks/longmemeval/results/
|
||||
hindsight-cli/target
|
||||
hindsight-clients/rust/target
|
||||
@@ -0,0 +1,153 @@
|
||||
# AGENTS.md
|
||||
|
||||
This document captures architectural decisions and coding conventions for the Hindsight project.
|
||||
|
||||
## Documentation
|
||||
|
||||
- **Main documentation**: [hindsight-docs/docs/developer/](./hindsight-docs/docs/developer/)
|
||||
- **Use case patterns**: [hindsight-docs/docs/cookbook/](./hindsight-docs/docs/cookbook/)
|
||||
- **API reference**: Auto-generated from OpenAPI spec
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
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
|
||||
hindsight-integrations/ # Framework integrations (LangChain, etc.)
|
||||
hindsight-clients/ # Generated API clients (Python, TypeScript, Rust)
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Memory Banks
|
||||
- Each bank is an isolated memory store (like a "brain" for one user/agent)
|
||||
- Banks contain: memory units (facts), entities, documents, entity links
|
||||
- Banks have a **disposition** (personality traits) and **background** (context)
|
||||
- Bank isolation is strict - no cross-bank data leakage
|
||||
|
||||
### Memory Types
|
||||
- **World facts**: General knowledge ("The sky is blue")
|
||||
- **Experience facts**: Personal experiences ("I visited Paris in 2023")
|
||||
- **Opinion facts**: Beliefs with confidence scores ("Paris is beautiful" - 0.9 confidence)
|
||||
|
||||
### Operations
|
||||
- **Retain**: Store new memories (extracts facts, entities, relationships)
|
||||
- **Recall**: Retrieve memories (semantic, BM25, graph, temporal search)
|
||||
- **Reflect**: Deep analysis to form new insights/opinions
|
||||
|
||||
## API Design Decisions
|
||||
|
||||
### Single Bank Per Request
|
||||
- All API endpoints (`recall`, `reflect`, `retain`) operate on a single bank
|
||||
- Multi-bank queries are the **client/agent's responsibility** to orchestrate
|
||||
- This keeps the API simple and the isolation model clear
|
||||
|
||||
### Disposition Traits (3-trait system)
|
||||
- **Skepticism** (1-5): How skeptical vs trusting when forming opinions
|
||||
- **Literalism** (1-5): How literally to interpret information
|
||||
- **Empathy** (1-5): How much to consider emotional context
|
||||
- These influence the `reflect` operation, not `recall`
|
||||
- Background info also only affects `reflect` (opinion formation)
|
||||
|
||||
## Multi-Bank Architecture Patterns
|
||||
|
||||
See [hindsight-docs/docs/cookbook/](./hindsight-docs/docs/cookbook/) for detailed guides:
|
||||
|
||||
- **Per-User Memory**: One bank per user, simplest pattern
|
||||
- **Support Agent + Shared Knowledge**: User bank + shared docs bank, client orchestrates
|
||||
|
||||
## Developer Guide
|
||||
|
||||
### Running the API Server
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
./scripts/dev/start-api.sh
|
||||
|
||||
# With options
|
||||
./scripts/dev/start-api.sh --reload --port 8888 --log-level debug
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# API tests
|
||||
cd hindsight-api
|
||||
uv run pytest tests/
|
||||
|
||||
# Specific test
|
||||
uv run pytest tests/test_http_api_integration.py -v
|
||||
```
|
||||
|
||||
### Generating OpenAPI Spec
|
||||
|
||||
After changing API endpoints, regenerate the OpenAPI spec and docs:
|
||||
|
||||
```bash
|
||||
./scripts/generate-openapi.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Generate `openapi.json` at project root
|
||||
2. Copy to `hindsight-docs/openapi.json`
|
||||
3. Regenerate API reference documentation
|
||||
|
||||
### Generating API Clients
|
||||
|
||||
After updating the OpenAPI spec, regenerate all clients:
|
||||
|
||||
```bash
|
||||
./scripts/generate-clients.sh
|
||||
```
|
||||
|
||||
This generates:
|
||||
- **Rust client**: `hindsight-clients/rust/` (via progenitor in build.rs)
|
||||
- **Python client**: `hindsight-clients/python/` (via openapi-generator Docker)
|
||||
- **TypeScript client**: `hindsight-clients/typescript/` (via @hey-api/openapi-ts)
|
||||
|
||||
Note: The maintained wrapper `hindsight_client.py` and `README.md` are preserved during regeneration.
|
||||
|
||||
### Running the Documentation Site
|
||||
|
||||
```bash
|
||||
./scripts/dev/start-docs.sh
|
||||
```
|
||||
|
||||
### Running the Control Plane
|
||||
|
||||
```bash
|
||||
./scripts/dev/start-control-plane.sh
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
### Python (hindsight-api)
|
||||
- Use `uv` for package management
|
||||
- Async throughout (asyncpg, async FastAPI endpoints)
|
||||
- Pydantic models for request/response validation
|
||||
- No py files at project root - maintain clean directory structure
|
||||
|
||||
### TypeScript (control-plane, clients)
|
||||
- Next.js with App Router for control plane
|
||||
- Tailwind CSS with shadcn/ui components
|
||||
|
||||
### Rust (CLI)
|
||||
- Async with tokio
|
||||
- reqwest for HTTP client
|
||||
- progenitor for API client generation
|
||||
|
||||
## Database
|
||||
|
||||
- PostgreSQL with pgvector extension
|
||||
- Schema managed via Alembic migrations in `hindsight-api/alembic/`, db migrations happen during api startup, no manual commands
|
||||
- Key tables: `banks`, `memory_units`, `documents`, `entities`, `entity_links`
|
||||
|
||||
# Branding
|
||||
## Colors
|
||||
- Primary: gradient from #0074d9 to #009296
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
+14
-4
@@ -5,13 +5,23 @@ Thanks for your interest in contributing to Hindsight!
|
||||
## Getting Started
|
||||
|
||||
1. Fork and clone the repository
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
cd hindsight-api && uv sync
|
||||
git clone [email protected]:vectorize-io/hindsight.git
|
||||
cd hindsight
|
||||
```
|
||||
3. Set up your environment:
|
||||
2. Set up your environment:
|
||||
```bash
|
||||
export OPENAI_API_KEY=your-key
|
||||
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
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
@@ -1,965 +0,0 @@
|
||||
# Hindsight: A Unified Memory System for AI Agents with Temporal Retrieval and Personality-Driven Reasoning
|
||||
|
||||
## Abstract
|
||||
|
||||
We present **Hindsight**, a comprehensive memory architecture for conversational AI agents that combines multi-strategy retrieval with personality-driven reasoning to enable both high-recall factual search and consistent, trait-based opinion formation. The system consists of two integrated components: **TEMPR (Temporal Entity Memory Priming Retrieval)** for memory recall, and **CARA (Coherent Adaptive Reasoning Agents)** for personality-aware reflection. TEMPR achieves strong retrieval performance through four parallel search strategies—semantic vector search, BM25 keyword matching, graph-based spreading activation incorporating multiple link types (entity, semantic, temporal, causal), and temporal-aware graph traversal—achieving 73.50% on LoComo and 80.60% on LongMemEval benchmarks, with particularly strong performance on multi-hop reasoning (+15.8% over baseline). CARA builds on TEMPR's four-network architecture (world facts, bank experiences, opinions, and observations) to enable personality-driven reasoning using the Big Five model, allowing agents to form and evolve opinions influenced by configurable traits while maintaining epistemic clarity between objective information and subjective beliefs. A novel observation paradigm automatically synthesizes entity-level summaries from multiple facts, creating structured mental models of people, organizations, and concepts without personality influence. The combination enables AI agents with long-term memory that can both retrieve information accurately and reason consistently with stable character traits.
|
||||
|
||||
---
|
||||
|
||||
# Part I: Recall - TEMPR (Temporal Entity Memory Priming Retrieval)
|
||||
|
||||
## 1. Introduction to Recall
|
||||
|
||||
Conversational AI agents face a fundamental challenge: maintaining coherent, context-aware memories across extended interactions. Traditional search systems are optimized for human users with top-k ranking and relevance feedback, but AI agents have fundamentally different requirements: they need to retrieve variable amounts of information based on reasoning complexity while respecting LLM context windows. Existing approaches rely either on vector similarity search, which captures semantic relationships but misses entity-level connections, or on keyword matching, which provides precision but lacks conceptual understanding. Neither approach adequately handles the temporal aspects of memory or entity-based reasoning that enable multi-hop information discovery.
|
||||
|
||||
We propose TEMPR, a memory retrieval architecture designed specifically for AI agents that combines established information retrieval techniques—semantic vector search, BM25 keyword matching, spreading activation graph traversal (Anderson 1983), and neural reranking—into a unified system optimized for agent workflows. The key architectural choices are:
|
||||
|
||||
1. **Agent-Optimized Interface**: budget and max_tokens parameters instead of traditional top-k ranking
|
||||
2. **Comprehensive Narrative Fact Extraction with Temporal Ranges**: LLM-powered extraction that creates self-contained narrative facts preserving full conversational context, extracting temporal ranges (occurred_start/end) to distinguish point events from periods
|
||||
3. **Entity-Aware Graph Structure with Multiple Link Types**: LLM-based entity resolution and linking that connects memories through shared identities, along with temporal, semantic, and causal link types
|
||||
4. **Four-Way Parallel Retrieval**: Semantic, keyword, graph-based (spreading activation), and temporal range retrieval strategies executed in parallel and fused using RRF (Cormack et al. 2009)
|
||||
5. **Neural Cross-Encoder Reranking**: Learned query-document relevance with temporal awareness and token budget filtering
|
||||
|
||||
This combination of techniques enables agents to discover indirectly related information through graph traversal while maintaining temporal awareness, achieving strong performance on multi-hop reasoning tasks.
|
||||
|
||||
### 1.1 Contributions
|
||||
|
||||
Our key contributions for the recall system are:
|
||||
|
||||
1. **Agent-Optimized Retrieval Interface**: Unlike traditional top-k search optimized for human users, we introduce budget and max_tokens parameters that allow AI agents to dynamically trade off latency for recall based on reasoning complexity and context window constraints
|
||||
|
||||
2. **Four-Way Parallel Retrieval**: We combine semantic vector search, BM25 keyword matching, graph-based spreading activation (Anderson 1983), and temporal-aware graph traversal into a unified parallel retrieval pipeline using Reciprocal Rank Fusion (Cormack et al. 2009) and neural cross-encoder reranking. The graph traversal incorporates multiple link types (entity, semantic, temporal, causal) with configurable weighting during activation spreading.
|
||||
|
||||
3. **LLM-Based Knowledge Graph Construction with Temporal Ranges**: We leverage open-source LLMs for comprehensive narrative fact extraction, entity recognition, and entity disambiguation. The system extracts temporal ranges (occurred_start, occurred_end) to represent both point events and extended periods, distinguishing when facts occurred from when they were mentioned.
|
||||
|
||||
4. **Strong Performance on Multi-Hop Reasoning**: 73.50% on LoComo and 80.60% on LongMemEval, with particularly strong performance on multi-hop queries (+15.8% over Mem0), demonstrating the effectiveness of combining these techniques for discovering indirectly related information in conversational contexts
|
||||
|
||||
## 2. Memory Organization
|
||||
|
||||
### 2.1 Four Memory Networks
|
||||
|
||||
TEMPR organizes memories into four distinct networks for epistemic clarity:
|
||||
|
||||
**World Network** (fact_type='world'): Objective information about the world
|
||||
- Example: "Alice works at Google in Mountain View on the AI team"
|
||||
- Stores facts received from external sources
|
||||
- No confidence scores (facts are information received, not beliefs)
|
||||
|
||||
**Bank Network** (fact_type='bank'): Biographical information about the agent itself
|
||||
- Example: "I recommended Yosemite National Park to Alice for hiking"
|
||||
- Stores the agent's own actions and experiences
|
||||
- Uses first-person perspective ("I recommended..." not "The agent recommended...")
|
||||
|
||||
**Opinion Network** (fact_type='opinion'): Subjective beliefs formed by the agent
|
||||
- Example: "Python is better for data science because of libraries like pandas (confidence: 0.85)"
|
||||
- Stores judgments and opinions with confidence scores
|
||||
- Evolved through opinion reinforcement when new evidence arrives
|
||||
- Influenced by personality traits (see Part II: Reflect)
|
||||
|
||||
**Observation Network** (fact_type='observation'): Synthesized entity summaries
|
||||
- Example: "Alice is a software engineer at Google specializing in machine learning"
|
||||
- Objective syntheses from multiple facts about an entity
|
||||
- Generated WITHOUT personality influence (unlike opinions)
|
||||
- Automatically created and updated in background processes
|
||||
- Provides structured "mental models" of entities
|
||||
|
||||
This separation provides:
|
||||
- **Epistemic Clarity**: Facts represent information encountered; opinions represent personality-driven judgments; observations represent objective syntheses
|
||||
- **Traceability**: Opinion reinforcement traces facts; observations trace entity-related facts
|
||||
- **Debugging**: Developers can separately inspect factual knowledge, formed beliefs, and entity models
|
||||
- **Confidence Semantics**: Facts and observations lack confidence scores; opinions have confidence scores representing conviction strength
|
||||
- **Personality Independence**: Observations remain objective while opinions reflect personality
|
||||
|
||||
### 2.2 Memory Unit Structure
|
||||
|
||||
Each memory is represented as a self-contained node with:
|
||||
|
||||
- id: Unique UUID
|
||||
- bank_id: Identifier for the memory bank this memory belongs to
|
||||
- text: Self-contained comprehensive narrative fact
|
||||
- embedding: 384-dimensional vector (BAAI/bge-small-en-v1.5)
|
||||
- event_date: Timestamp when the fact became true (maintained for backward compatibility)
|
||||
- occurred_start: Timestamp when the fact/event started (temporal range support)
|
||||
- occurred_end: Timestamp when the fact/event ended (temporal range support)
|
||||
- mentioned_at: Timestamp when the fact was mentioned/learned
|
||||
- context: Optional contextual metadata
|
||||
- fact_type: One of 'world', 'bank', 'opinion'
|
||||
- confidence_score: For opinions only, strength of conviction (0.0-1.0)
|
||||
- access_count: Frequency-based importance signal
|
||||
- search_vector: Full-text search tsvector for BM25 ranking
|
||||
|
||||
### 2.3 LLM-Powered Comprehensive Narrative Fact Extraction
|
||||
|
||||
TEMPR employs **LLM-powered comprehensive narrative fact extraction** using open-source models. This approach provides more context-aware extraction compared to traditional rule-based NLP pipelines, though at higher computational cost.
|
||||
|
||||
#### 2.3.1 Extraction Principles
|
||||
|
||||
**Chunking Strategy**: TEMPR uses a coarse-grained chunking approach, extracting 2-5 comprehensive facts per conversation rather than dozens of atomic fragments. This is a deliberate tradeoff: larger chunks preserve more context and narrative flow, at the cost of reduced precision when only a small portion of the chunk is relevant.
|
||||
|
||||
Each fact should:
|
||||
1. **Capture entire conversations or exchanges** - Include the full back-and-forth discussion
|
||||
2. **Be narrative and comprehensive** - Tell the complete story with all context
|
||||
3. **Be self-contained** - Readable without the original text
|
||||
4. **Include all participants** - WHO said/did WHAT, with their reasoning
|
||||
5. **Preserve the flow** - Keep related exchanges together in one fact
|
||||
|
||||
**Example Comparison**:
|
||||
|
||||
❌ **Fragmented Approach** (traditional):
|
||||
- "Bob suggested Summer Vibes"
|
||||
- "Alice wanted something unique"
|
||||
- "They considered Sunset Sessions"
|
||||
- "Alice likes Beach Beats"
|
||||
- "They chose Beach Beats"
|
||||
|
||||
✅ **Comprehensive Approach** (TEMPR):
|
||||
- "Alice and Bob discussed naming their summer party playlist. Bob suggested 'Summer Vibes' because it's catchy and seasonal, but Alice wanted something more unique. Bob then proposed 'Sunset Sessions' and 'Beach Beats', with Alice favoring 'Beach Beats' for its playful and fun tone. They ultimately decided on 'Beach Beats' as the final name."
|
||||
|
||||
#### 2.3.2 Open-Source LLM Extraction Pipeline
|
||||
|
||||
The extraction process leverages open-source LLMs with structured output (Pydantic schemas). This follows the established practice of using LLMs for information extraction, which has been shown to improve context understanding compared to rule-based NLP pipelines, particularly for:
|
||||
- Coreference resolution in conversational text
|
||||
- Domain-specific entity recognition
|
||||
- Maintaining narrative coherence across multi-turn exchanges
|
||||
|
||||
**LLM Extraction Steps**:
|
||||
1. **Pronoun Resolution**: "She loves hiking" → "Alice loves hiking"
|
||||
2. **Temporal Normalization**: "last year" → "in 2023" (absolute dates)
|
||||
3. **Temporal Range Extraction**: Identify when facts occurred vs. when mentioned
|
||||
- Point events: "on July 14" → occurred_start = occurred_end = 2023-07-14
|
||||
- Period events: "in February 2023" → occurred_start = 2023-02-01, occurred_end = 2023-02-28
|
||||
- Vague periods: "lately" → estimated range based on context
|
||||
- mentioned_at = conversation date (when fact was learned)
|
||||
4. **Participant Attribution**: Preserve WHO said/did WHAT
|
||||
5. **Reasoning Preservation**: Include WHY decisions were made
|
||||
6. **Fact Type Classification**: Determine fact categories (world, bank, opinion)
|
||||
7. **Entity Extraction**: Identify all entities (PERSON, ORG, LOCATION, PRODUCT, CONCEPT)
|
||||
|
||||
**Temporal Augmentation**: Before embedding, facts are augmented with readable temporal information:
|
||||
- Original: "Alice started working at Google"
|
||||
- Augmented for embedding: "Alice started working at Google (happened in November 2023)"
|
||||
|
||||
This augmentation helps semantic search understand temporal relevance without modifying the stored fact text.
|
||||
|
||||
### 2.4 Entity Resolution and Linking
|
||||
|
||||
Entity resolution creates strong connections between memories that share common entities, solving the problem where semantically dissimilar facts are related through shared identities.
|
||||
|
||||
#### 2.4.1 LLM-Based Entity Recognition
|
||||
|
||||
TEMPR uses the same open-source LLM that performs fact extraction to also identify and extract entities during the narrative fact creation process. This unified approach eliminates the brittleness of traditional NER pipelines that struggle with domain-specific entities, novel names, and context-dependent disambiguation.
|
||||
|
||||
**Entity Types**:
|
||||
- PERSON: "Alice", "Bob Chen"
|
||||
- ORGANIZATION: "Google", "Stanford University"
|
||||
- LOCATION: "Yosemite National Park", "California"
|
||||
- PRODUCT: "Python", "pandas library"
|
||||
- CONCEPT: "machine learning", "remote work"
|
||||
- OTHER: Miscellaneous proper nouns
|
||||
|
||||
#### 2.4.2 LLM-Based Entity Disambiguation
|
||||
|
||||
Multiple mentions of entities (e.g., "Alice", "Alice Chen", "Alice C.") must be resolved to a single canonical entity. TEMPR uses the LLM to perform entity disambiguation, analyzing the surrounding context to determine if two entity mentions refer to the same entity. This handles complex cases like:
|
||||
- Nicknames and formal names ("Bob" vs. "Robert Chen")
|
||||
- Partial mentions ("Alice" vs. "Alice Chen")
|
||||
- Context-dependent disambiguation ("Apple the company" vs. "apple the fruit")
|
||||
|
||||
The LLM considers multiple signals:
|
||||
- **Name Similarity**: String similarity using Levenshtein distance
|
||||
- **Co-occurrence Patterns**: Entities mentioned together frequently are likely distinct
|
||||
- **Temporal Proximity**: Recent mentions are more likely to refer to the same entity
|
||||
|
||||
#### 2.4.3 Entity Link Structure
|
||||
|
||||
Each entity creates a link_type='entity' edge between all memories mentioning it:
|
||||
|
||||
**Properties**:
|
||||
- weight=1.0 (constant, no temporal decay)
|
||||
- entity_id: Reference to resolved canonical entity
|
||||
- Bidirectional connections between all mentioning memories
|
||||
|
||||
**Impact on Retrieval**: Entity links enable graph traversal to discover indirectly related facts:
|
||||
|
||||
**Example Query**: "What does Alice do?"
|
||||
1. **Semantic Match**: "Alice works at Google in Mountain View..." (direct match)
|
||||
2. **Entity Traversal**: Follow entity links for "Alice" →
|
||||
- "Alice loves hiking in Yosemite..." (different semantic space)
|
||||
- "I recommended technical books to Alice" (Bank Network, via "Alice")
|
||||
3. **Chained Traversal**: Follow "Google" entity →
|
||||
- "Google's office in Mountain View has excellent amenities"
|
||||
|
||||
### 2.5 Link Types and Graph Structure
|
||||
|
||||
The memory graph contains four types of edges connecting memory units:
|
||||
|
||||
#### 2.5.1 Temporal Links
|
||||
|
||||
Temporal links connect memories close in time, enabling temporal reasoning:
|
||||
|
||||
**Creation Logic**:
|
||||
|
||||
**Properties**:
|
||||
- Decays linearly with time distance
|
||||
- Minimum weight 0.3 to maintain some connectivity
|
||||
- Enables "What happened around the same time?" queries
|
||||
|
||||
#### 2.5.2 Semantic Links
|
||||
|
||||
Semantic links connect memories with similar meanings:
|
||||
|
||||
**Creation Logic**:
|
||||
|
||||
**Properties**:
|
||||
- Uses pgvector HNSW index for efficient nearest-neighbor search
|
||||
- Higher threshold (0.7) than retrieval (0.3) to avoid over-connection
|
||||
- Weight equals cosine similarity score
|
||||
|
||||
#### 2.5.3 Entity Links
|
||||
|
||||
Entity links (described in Section 2.4.3) create the strongest connections:
|
||||
|
||||
**Properties**:
|
||||
- weight=1.0 (constant, never decays)
|
||||
- Connects all memories mentioning the same resolved entity
|
||||
- Most reliable traversal path during graph search
|
||||
|
||||
#### 2.5.4 Causal Links
|
||||
|
||||
Causal links represent identified cause-effect relationships between facts. During fact extraction, the LLM attempts to identify causal relationships between facts extracted from the same conversation. These links are incorporated as one component of the graph retrieval system.
|
||||
|
||||
**Causal Relationship Types**:
|
||||
- causes: This fact directly causes the target fact
|
||||
- caused_by: This fact was caused by the target fact (inverse of causes)
|
||||
- enables: This fact enables or allows the target fact to happen
|
||||
- prevents: This fact prevents or blocks the target fact
|
||||
|
||||
**Properties**:
|
||||
- weight: Strength of causal relationship ∈ [0.0, 1.0] (default 1.0)
|
||||
- Directional edges (from cause to effect)
|
||||
- Prioritized during graph traversal with 2x activation boost
|
||||
|
||||
**Role in Retrieval**: Causal links provide an additional signal during graph-based retrieval. When present, they allow the system to traverse explanatory relationships in addition to semantic, temporal, and entity-based connections.
|
||||
|
||||
**Example**: For a query "Why does Alice spend time in the garden?", the system may find both direct semantic matches ("Alice spends time in the garden to find comfort") and traverse causal links to related facts ("Alice lost her friend Karlie in February 2023").
|
||||
|
||||
**Graph Density**: Each memory unit typically has:
|
||||
- 5-10 temporal links (to nearby memories)
|
||||
- 3-5 semantic links (to similar content)
|
||||
- Variable entity links (depending on entity mention frequency)
|
||||
- 0-3 causal links (when causal relationships are identified)
|
||||
|
||||
### 2.6 The Observation Paradigm
|
||||
|
||||
A critical challenge in long-term memory systems is maintaining structured, high-level understanding of entities (people, organizations, places, concepts) without re-reading all individual facts each time. Traditional approaches either retrieve all entity-related facts (expensive, noisy) or maintain no entity-level state (losing structured understanding). Hindsight introduces **observations**—automatically synthesized entity summaries that provide structured "mental models" without personality influence.
|
||||
|
||||
#### 2.6.1 Motivation and Design
|
||||
|
||||
**The Problem**: When a system accumulates dozens of facts about an entity like "Alice," queries about Alice must either:
|
||||
1. Retrieve all 50+ individual facts (expensive, overwhelming)
|
||||
2. Rely only on top-k semantic matches (may miss key attributes)
|
||||
3. Manually maintain entity profiles (doesn't scale, requires human curation)
|
||||
|
||||
**The Solution**: Observations provide a fourth fact type that synthesizes multiple facts into coherent, objective entity summaries, automatically maintained as new information arrives.
|
||||
|
||||
**Key Properties**:
|
||||
- **Objective Synthesis**: Generated WITHOUT personality influence (unlike opinions)
|
||||
- **Entity-Scoped**: Each observation is about a single entity
|
||||
- **Automatic Maintenance**: Generated in background after fact ingestion
|
||||
- **Multi-Fact Fusion**: Combines information scattered across multiple facts
|
||||
- **Response Augmentation**: NOT used for retrieval/search, but returned alongside results when include_entities=True to provide entity context
|
||||
|
||||
#### 2.6.2 Observation Generation
|
||||
|
||||
Observations are generated through an LLM-powered synthesis process:
|
||||
|
||||
**Trigger**: When new facts mentioning an entity are ingested via retain(), a background task is queued to regenerate observations for that entity.
|
||||
|
||||
**Process**:
|
||||
|
||||
**LLM Prompt Structure**:
|
||||
|
||||
**Example Transformation**:
|
||||
|
||||
**Input Facts**:
|
||||
- "Alice works at Google"
|
||||
- "Alice is a software engineer"
|
||||
- "Alice specializes in ML and deep learning"
|
||||
- "Alice joined Google in 2023"
|
||||
- "Alice is detail-oriented and methodical"
|
||||
|
||||
**Generated Observations**:
|
||||
- "Alice is a software engineer at Google specializing in machine learning and deep learning"
|
||||
- "Alice joined Google in 2023"
|
||||
- "Alice is detail-oriented and methodical in her approach"
|
||||
|
||||
#### 2.6.3 Storage and Retrieval
|
||||
|
||||
**Storage**: Observations are stored as regular memory_units with fact_type='observation':
|
||||
|
||||
|
||||
**Entity Links**: Observations are linked to their entity via the entity_links table, enabling efficient lookup of all observations for an entity.
|
||||
|
||||
**Important**: Observations are NOT used during the retrieval/search process itself. They do not participate in the 4-way parallel search (semantic, keyword, graph, temporal). Instead, they are **response augmentations**—additional context returned alongside search results.
|
||||
|
||||
**Response Augmentation**: When calling recall() with include_entities=True:
|
||||
|
||||
|
||||
**Response Structure**:
|
||||
|
||||
#### 2.6.4 Observations vs. Opinions
|
||||
|
||||
A critical distinction separates observations from opinions:
|
||||
|
||||
| Dimension | Observations | Opinions |
|
||||
|-----------|-------------|----------|
|
||||
| **Influence** | No personality influence | Influenced by Big Five traits |
|
||||
| **Purpose** | Objective entity summaries | Subjective beliefs and judgments |
|
||||
| **Confidence** | No confidence score | Confidence score (0.0-1.0) |
|
||||
| **Generation** | Background synthesis from facts | Formed during reflect() reasoning |
|
||||
| **Update Mechanism** | Regenerated when entity facts change | Updated via opinion reinforcement |
|
||||
| **Example** | "Alice is a software engineer at Google" | "Alice is an excellent engineer" |
|
||||
|
||||
**Why Both?**: Observations provide factual entity understanding for retrieval contexts, while opinions represent the memory bank's personality-driven beliefs for reasoning contexts. A memory bank can have objective observations about Alice (she works at Google, specializes in ML) AND personality-influenced opinions about Alice (she's a talented engineer, she'd be great for project X).
|
||||
|
||||
#### 2.6.5 Background Processing
|
||||
|
||||
Observation generation is asynchronous to avoid blocking retain() operations:
|
||||
|
||||
**Flow**:
|
||||
|
||||
This design ensures low-latency writes while maintaining fresh entity summaries.
|
||||
|
||||
#### 2.6.6 Benefits and Use Cases
|
||||
|
||||
**Benefits**:
|
||||
|
||||
1. **Contextual Entity Summaries**: After retrieving facts that mention entities, observations provide synthesized context about those entities without requiring separate queries
|
||||
2. **Structured Entity Understanding**: Provides coherent mental models of entities as response augmentation
|
||||
3. **Token Efficiency**: 3-5 observations provide more structured context than retrieving all entity-related facts
|
||||
4. **Objective Grounding**: When reflecting with personality, observations provide objective entity context
|
||||
5. **Scalability**: Automatically maintained as facts accumulate, always fresh when needed
|
||||
6. **Separation of Concerns**: Search focuses on relevant facts through semantic similarity, keyword matching, and graph traversal; observations provide entity context post-retrieval
|
||||
|
||||
**Note on Observation Stability**: While observations are regenerated when entity facts change, the core retrieval mechanism remains grounded in the original facts. The four-way parallel search (semantic, keyword, graph, temporal) retrieves facts based on query relevance, semantic co-occurrence, and entity relationships—not based on observations. This ensures that the most relevant factual information is surfaced regardless of how observations may evolve over time.
|
||||
|
||||
**Use Cases**:
|
||||
|
||||
**Multi-Agent Conversations**: When retrieving facts that mention people, observations provide shared, objective entity context:
|
||||
|
||||
**Entity-Centric Queries**: "Tell me about Alice" retrieves facts about Alice, and observations provide synthesized entity summary in the response.
|
||||
|
||||
**Contextual Reasoning**: When forming opinions during reflect(), observations provide factual entity grounding alongside retrieved facts.
|
||||
|
||||
**Knowledge Graph Interfaces**: Observations can be exposed as structured entity profiles in UIs or APIs via dedicated entity endpoints.
|
||||
|
||||
## 3. Retrieval Architecture
|
||||
|
||||
Our retrieval pipeline addresses the fundamental challenge of long-term memory: achieving both **high recall** (finding all relevant information) and **high precision** (ranking the most relevant items first).
|
||||
|
||||
### 3.1 Four-Way Parallel Retrieval
|
||||
|
||||
We execute four complementary retrieval strategies in parallel, each capturing different aspects of relevance:
|
||||
|
||||
#### 3.1.1 Semantic Retrieval (Vector Similarity)
|
||||
|
||||
**Method**: Cosine similarity between query embedding and memory embeddings
|
||||
**Index**: pgvector HNSW (Hierarchical Navigable Small World)
|
||||
**Threshold**: ≥ 0.3 similarity
|
||||
|
||||
**Implementation**:
|
||||
|
||||
**Advantages**:
|
||||
- Captures conceptual similarity
|
||||
- Handles synonyms and paraphrasing
|
||||
- Language-model understanding of meaning
|
||||
|
||||
**Limitations**:
|
||||
- Misses exact proper nouns if not in training data
|
||||
- Cannot reason about temporal relationships
|
||||
- Weak at entity disambiguation
|
||||
|
||||
#### 3.1.2 Keyword Retrieval (BM25 Full-Text Search)
|
||||
|
||||
**Method**: PostgreSQL full-text search with BM25 ranking (ts_rank_cd)
|
||||
**Index**: GIN index on to_tsvector('english', text)
|
||||
|
||||
**Advantages**:
|
||||
- High precision for proper nouns and technical terms
|
||||
- Exact phrase matching
|
||||
- Fast execution with GIN index
|
||||
|
||||
**Limitations**:
|
||||
- No semantic understanding
|
||||
- Requires exact or stemmed matches
|
||||
|
||||
**Complementarity**: Semantic + Keyword achieves >90% recall: vector search catches concepts, BM25 catches exact names.
|
||||
|
||||
#### 3.1.3 Graph Retrieval (Spreading Activation)
|
||||
|
||||
**Method**: Activation spreading from semantic entry points through the memory graph, following the spreading activation model of memory (Anderson 1983).
|
||||
|
||||
**Algorithm**:
|
||||
|
||||
**Decay Mechanism**: Activation decays by 0.8 per hop, limiting spread to ~4-5 hops.
|
||||
|
||||
**Link Weighting with Causal Boosting**:
|
||||
- **Causal links**: Base weight × 2.0 boost (causes/caused_by) or × 1.5 boost (enables/prevents)
|
||||
- **Entity links**: weight 1.0 (no boost, already strong signal)
|
||||
- **Semantic links**: weight ∈ [0.7, 1.0] (cosine similarity, no boost)
|
||||
- **Temporal links**: weight ∈ [0.3, 1.0] (time-based decay, no boost)
|
||||
|
||||
**Advantages**:
|
||||
- Discovers indirectly related facts through graph connectivity
|
||||
- Leverages entity links to traverse knowledge graph
|
||||
- Finds context-adjacent memories via temporal links
|
||||
- Prioritizes explanatory relationships through causal boosting
|
||||
|
||||
#### 3.1.4 Temporal Graph Retrieval (Time-Constrained + Spreading)
|
||||
|
||||
**Activation Condition**: Only triggered when temporal constraint detected in query
|
||||
|
||||
**Temporal Parsing**: Uses google/flan-t5-small (80M parameters) to extract temporal constraints from natural language queries:
|
||||
- "last spring" → 2024-03-01 to 2024-05-31
|
||||
- "in June" → 2024-06-01 to 2024-06-30
|
||||
- "last year" → 2024-01-01 to 2024-12-31
|
||||
- "between March and May" → 2025-03-01 to 2025-05-31
|
||||
|
||||
**Temporal Range Matching**: Facts are matched against time constraints using their temporal range (occurred_start, occurred_end):
|
||||
|
||||
|
||||
**Algorithm**:
|
||||
|
||||
### 3.2 Reciprocal Rank Fusion (RRF)
|
||||
|
||||
After parallel retrieval, we merge 3-4 ranked lists using Reciprocal Rank Fusion (Cormack et al. 2009):
|
||||
|
||||
**Algorithm**:
|
||||
|
||||
**Advantages over Score-Based Fusion**:
|
||||
- **Rank-based**: Position matters more than absolute scores
|
||||
- **Robust to missing items**: Missing from a list contributes 0, not a penalty
|
||||
- **Multi-evidence weighting**: Items appearing in multiple lists rank higher
|
||||
|
||||
### 3.3 Neural Cross-Encoder Reranking
|
||||
|
||||
After RRF fusion, TEMPR applies neural cross-encoder reranking to refine precision:
|
||||
|
||||
**Model**: cross-encoder/ms-marco-MiniLM-L-6-v2 (pretrained on MS MARCO passage ranking)
|
||||
|
||||
**Algorithm**:
|
||||
|
||||
**Advantages**:
|
||||
- Learns query-document relevance patterns from supervised data
|
||||
- Considers full query-document interaction
|
||||
- Temporal awareness through formatted date context
|
||||
|
||||
### 3.4 Token Budget Filtering
|
||||
|
||||
Final stage applies token budget filtering to limit context window usage:
|
||||
|
||||
**Algorithm**:
|
||||
|
||||
**Purpose**: Ensures retrieved facts fit within LLM context windows while maximizing information density.
|
||||
|
||||
### 3.5 Complete Retrieval Pipeline
|
||||
|
||||
**End-to-End Flow**:
|
||||
|
||||
## 4. Evaluation
|
||||
|
||||
We evaluate TEMPR on two established long-term memory benchmarks: LoComo (Long-term Conversation Memory) and LongMemEval.
|
||||
|
||||
### 4.1 LoComo Benchmark
|
||||
|
||||
LoComo evaluates conversational memory systems across four dimensions: single-hop queries, multi-hop queries, open-domain queries, and temporal queries.
|
||||
|
||||
**Results**:
|
||||
|
||||
| Method | Single Hop J ↑ | Multi-Hop J ↑ | Open Domain J ↑ | Temporal J ↑ | Overall |
|
||||
|--------|---------------|---------------|-----------------|--------------|---------|
|
||||
| A-Mem* | 39.79 | 18.85 | 54.05 | 31.08 | 48.38 |
|
||||
| LangMem | 62.23 | 47.92 | 71.12 | 23.43 | 58.10 |
|
||||
| Zep (Mem0 paper) | 61.70 | 41.35 | 76.60 | 49.31 | 65.99 |
|
||||
| OpenAI | 63.79 | 42.92 | 62.29 | 21.71 | 52.90 |
|
||||
| Mem0 | 67.13 | 51.15 | 72.93 | 55.51 | 66.88 |
|
||||
| Mem0 w/ Graph | 65.71 | 47.19 | 75.71 | 58.13 | 68.44 |
|
||||
| **TEMPR** | **73.20** | **66.90** | **78.60** | **56.30** | **73.50** |
|
||||
|
||||
**Analysis**: TEMPR achieves strong performance across all query types:
|
||||
- **Single-Hop (+6.1% vs Mem0)**: Superior performance due to comprehensive narrative facts and BM25 keyword matching
|
||||
- **Multi-Hop (+15.8% vs Mem0)**: Largest improvement, demonstrating effectiveness of graph-based spreading activation
|
||||
- **Open Domain (+2.9% vs Mem0)**: Strong performance through multi-strategy parallel retrieval
|
||||
- **Temporal (-1.8% vs Mem0 w/ Graph)**: Competitive temporal reasoning
|
||||
|
||||
### 4.2 LongMemEval Benchmark
|
||||
|
||||
LongMemEval assesses memory systems across six dimensions:
|
||||
|
||||
**Results**:
|
||||
|
||||
| Method | Single-Session Preference | Single-Session Assistant | Temporal Reasoning | Multi-Session | Knowledge Update | Single-Session User | Overall |
|
||||
|--------|--------------------------|-------------------------|-------------------|---------------|-----------------|-------------------|---------|
|
||||
| Zep gpt-4o-mini | 53.30% | 75.00% | 54.10% | 47.40% | 74.40% | 92.90% | 63.80% |
|
||||
| Zep gpt-4o | 56.70% | 80.40% | 62.40% | 57.90% | 83.30% | 92.90% | 71.00% |
|
||||
| **TEMPR** | **83.30%** | **80.40%** | **75.90%** | **75.20%** | **85.90%** | **92.90%** | **80.60%** |
|
||||
| Mastra gpt-4o | 46.70% | 100.00% | 75.20% | 76.70% | 84.60% | 97.10% | 80.05% |
|
||||
|
||||
**Analysis**: TEMPR achieves competitive performance:
|
||||
- **Single-Session Preference (+26.6% vs Zep gpt-4o)**: Dramatic improvement enabled by comprehensive narrative facts
|
||||
- **Temporal Reasoning (+13.5% vs Zep gpt-4o)**: Strong performance through dedicated temporal graph retrieval
|
||||
- **Multi-Session (+17.3% vs Zep gpt-4o)**: Entity-aware graph linking maintains consistency
|
||||
|
||||
The 80.60% overall score represents a 9.6 percentage point improvement over Zep gpt-4o (71.00%).
|
||||
|
||||
---
|
||||
|
||||
# Part II: Reflect - CARA (Coherent Adaptive Reasoning Agents)
|
||||
|
||||
## 5. Introduction to Reflect
|
||||
|
||||
Conversational AI agents increasingly need to maintain consistent perspectives and form judgments that reflect stable character traits. Current systems either provide purely objective information retrieval without perspective, or generate responses that lack consistency across interactions. Human conversation partners expect agents to have stable viewpoints, preferences, and reasoning styles—characteristics that emerge from personality.
|
||||
|
||||
We propose CARA (Coherent Adaptive Reasoning Agents), a personality framework that addresses these limitations through:
|
||||
|
||||
1. **Big Five Personality Integration**: Configurable traits (OCEAN model) that influence how agents interpret facts and form opinions
|
||||
2. **TEMPR Memory Integration**: Leverages TEMPR's three-network architecture (world facts, bank experiences, opinions) for sophisticated memory access
|
||||
3. **Opinion Reinforcement**: Dynamic belief updating when new evidence reinforces, weakens, or contradicts existing opinions
|
||||
4. **Personality Bias Control**: Adjustable influence strength allowing agents to range from objective to strongly personality-driven
|
||||
5. **Background Merging**: LLM-powered integration of biographical information with intelligent conflict resolution
|
||||
|
||||
This architecture enables agents to maintain consistent identities while allowing beliefs to evolve naturally with new information.
|
||||
|
||||
### 5.1 Motivation
|
||||
|
||||
Consider an agent discussing remote work. With high openness (0.9) and low conscientiousness (0.2), the agent might form the opinion: "Remote work enables creative flexibility and spontaneous innovation." The same facts presented to an agent with low openness (0.2) and high conscientiousness (0.9) might yield: "Remote work lacks the structure and accountability needed for consistent performance."
|
||||
|
||||
Both agents access identical factual information, but personality traits bias how they weight different aspects (flexibility vs. structure) and what conclusions they draw. This mirrors human reasoning—our personalities influence what we attend to and how we integrate information into our worldview.
|
||||
|
||||
### 5.2 Contributions
|
||||
|
||||
Our key contributions for the reflect system are:
|
||||
|
||||
1. **Personality-Aware Reasoning**: A prompt engineering framework that injects Big Five traits into LLM reasoning, demonstrating how personality consistently biases opinion formation
|
||||
|
||||
2. **TEMPR-Based Three-Network Architecture**: Integration with TEMPR to manage three distinct networks (world facts, bank experiences, opinions), enabling architectural separation between objective information and subjective beliefs with epistemic clarity and traceability
|
||||
|
||||
3. **Opinion Reinforcement Mechanism**: An automatic belief update system that adjusts confidence scores when new evidence arrives, creating dynamic belief systems that evolve with information
|
||||
|
||||
4. **Background Merging with Conflict Resolution**: An LLM-powered method for maintaining coherent agent identities when new biographical information contradicts existing background
|
||||
|
||||
5. **Bias Strength Control**: A meta-parameter that allows tuning personality influence from objective (0.0) to strongly subjective (1.0), enabling task-appropriate personality expression
|
||||
|
||||
## 6. Personality Model
|
||||
|
||||
### 6.1 Big Five Framework
|
||||
|
||||
We adopt the **Big Five** personality model (OCEAN), which is empirically validated across cultures and provides continuous trait dimensions:
|
||||
|
||||
**Trait Dimensions** (each 0.0-1.0):
|
||||
|
||||
1. **Openness (O)**: Receptiveness to new ideas, creativity, abstract thinking
|
||||
- High: "I embrace novel approaches", "innovation over tradition"
|
||||
- Low: "I prefer proven methods", "tradition over experimentation"
|
||||
|
||||
2. **Conscientiousness (C)**: Organization, goal-directed behavior, dependability
|
||||
- High: "I plan systematically", "evidence-based decisions"
|
||||
- Low: "I work flexibly", "intuition-based decisions"
|
||||
|
||||
3. **Extraversion (E)**: Sociability, assertiveness, energy from interaction
|
||||
- High: "I seek collaboration", "enthusiastic communication"
|
||||
- Low: "I prefer solitude", "measured communication"
|
||||
|
||||
4. **Agreeableness (A)**: Cooperation, empathy, conflict avoidance
|
||||
- High: "I seek consensus", "consider social harmony"
|
||||
- Low: "I express dissent", "prioritize accuracy over harmony"
|
||||
|
||||
5. **Neuroticism (N)**: Emotional sensitivity, anxiety, stress response
|
||||
- High: "I consider risks carefully", "emotionally engaged"
|
||||
- Low: "I remain calm under uncertainty", "emotionally detached"
|
||||
|
||||
**Bias Strength** (0.0-1.0): Meta-parameter controlling how much personality influences opinions
|
||||
- 0.0: Neutral, fact-based reasoning (no personality bias)
|
||||
- 0.5: Moderate personality influence, balanced with objective analysis
|
||||
- 1.0: Strong personality influence, facts filtered through trait lens
|
||||
|
||||
### 6.2 Psychological Basis
|
||||
|
||||
The Big Five model has several advantages for AI agents:
|
||||
|
||||
1. **Empirical Validation**: Decades of psychological research demonstrate cross-cultural stability and predictive validity
|
||||
2. **Continuous Dimensions**: Unlike categorical types, continuous scales allow fine-grained personality tuning
|
||||
3. **Behavioral Prediction**: Traits predict information processing styles, decision-making approaches, and communication preferences
|
||||
4. **Interpretability**: Well-understood trait meanings enable users to anticipate agent behavior
|
||||
|
||||
**Trait Influence on Reasoning**:
|
||||
- **High Openness**: Favors novel solutions, abstract thinking, considers unconventional perspectives
|
||||
- **High Conscientiousness**: Emphasizes systematic analysis, evidence quality, long-term consequences
|
||||
- **High Extraversion**: Considers social aspects, collaborative solutions, enthusiastic expression
|
||||
- **High Agreeableness**: Weights harmony, considers multiple viewpoints, seeks consensus
|
||||
- **High Neuroticism**: Attends to risks, emotional implications, uncertainty
|
||||
|
||||
## 7. Bank Profile Structure
|
||||
|
||||
### 7.1 Profile Schema
|
||||
|
||||
Each memory bank has an associated profile containing identity information:
|
||||
|
||||
|
||||
**Name Field**: Memory bank's name used in prompts and self-reference ("Your name: Marcus")
|
||||
|
||||
**Personality Field**: JSONB containing six continuous values (five traits + bias strength)
|
||||
|
||||
**Background Field**: First-person narrative describing the agent's biographical context:
|
||||
- "I am a software engineer with 10 years of startup experience"
|
||||
- "I was born in Texas and value innovation over tradition"
|
||||
- "I am a creative artist interested in digital media"
|
||||
|
||||
### 7.2 Trait Description Generation
|
||||
|
||||
Personality traits are translated into natural language descriptions for LLM prompts:
|
||||
|
||||
|
||||
**Example Output** (openness=0.9, conscientiousness=0.2, extraversion=0.7, agreeableness=0.3, neuroticism=0.5):
|
||||
|
||||
This verbalization makes traits interpretable to the LLM, enabling personality-biased reasoning.
|
||||
|
||||
## 8. Opinion Network and Opinion Formation
|
||||
|
||||
### 8.1 Opinion Structure
|
||||
|
||||
Opinions are stored as memory units in the dedicated opinion network (fact_type='opinion'):
|
||||
|
||||
**Core Attributes**:
|
||||
- text: The opinion statement with explicit reasoning
|
||||
- confidence_score: Opinion strength and resistance to change (0.0-1.0)
|
||||
- event_date: When the opinion was formed
|
||||
- bank_id: Which memory bank holds this opinion
|
||||
- entities: Mentioned entities (for reinforcement triggering)
|
||||
|
||||
**Example Opinion**:
|
||||
|
||||
**Fact vs. Opinion Separation**:
|
||||
|
||||
A critical architectural distinction separates **facts** (objective information stored in world/bank networks) from **opinions** (subjective beliefs stored in the opinion network). This separation provides:
|
||||
|
||||
1. **Epistemic Clarity**: Facts represent information encountered; opinions represent judgments formed
|
||||
2. **Traceability**: Opinion reinforcement can trace which facts influenced belief updates
|
||||
3. **Debugging**: Developers can separately inspect factual knowledge vs. formed beliefs
|
||||
4. **Confidence Semantics**: Facts lack confidence scores; opinions have confidence scores
|
||||
|
||||
### 8.2 Opinion Formation
|
||||
|
||||
Opinions are generated during "reflect" operations—when the agent is asked to reason about a topic and form a judgment.
|
||||
|
||||
**Formation Process**:
|
||||
1. Retrieve relevant facts from all memory networks (world, bank, existing opinions) using TEMPR
|
||||
2. Inject bank profile (name, personality, background) into LLM prompt
|
||||
3. Generate reasoning with personality bias applied
|
||||
4. Extract new opinions from response using structured output
|
||||
5. Store opinions with confidence scores in opinion network
|
||||
|
||||
**Prompt Structure** (bias_strength=0.8):
|
||||
|
||||
### 8.3 System Message Adaptation
|
||||
|
||||
The system message adjusts based on bias strength to control personality influence:
|
||||
|
||||
**High bias (≥0.7)**:
|
||||
|
||||
**Moderate bias (0.4-0.7)**:
|
||||
|
||||
**Low bias (<0.4)**:
|
||||
|
||||
### 8.4 Confidence Score Semantics
|
||||
|
||||
Confidence scores represent opinion strength—how firmly the agent holds the belief:
|
||||
|
||||
- **0.9-1.0**: Very strong conviction, deeply held belief
|
||||
- **0.7-0.9**: Strong conviction, firmly held opinion
|
||||
- **0.5-0.7**: Moderate conviction, open to revision
|
||||
- **0.3-0.5**: Weak conviction, easily influenced
|
||||
- **0.0-0.3**: Very weak conviction, highly malleable
|
||||
|
||||
**LLM Generation**: Confidence scores are extracted using structured output (Pydantic schema):
|
||||
|
||||
|
||||
## 9. Opinion Reinforcement
|
||||
|
||||
### 9.1 Motivation
|
||||
|
||||
Human beliefs evolve as we encounter new information. Supporting evidence strengthens beliefs, contradictory evidence weakens them, and sufficient contradiction causes belief revision. Opinion reinforcement implements this dynamic belief updating.
|
||||
|
||||
### 9.2 Reinforcement Mechanism
|
||||
|
||||
When new facts are ingested (via retain), the system:
|
||||
|
||||
1. **Identify Related Opinions**: Find existing opinions that mention entities in the new facts
|
||||
2. **Evaluate Evidence Relationship**: Use LLM to determine if new facts:
|
||||
- **Reinforce**: Support the existing opinion (increase confidence)
|
||||
- **Weaken**: Contradict the existing opinion (decrease confidence)
|
||||
- **Contradict**: Strongly contradict, requiring opinion revision
|
||||
- **Neutral**: Unrelated or no clear relationship
|
||||
3. **Update Opinions**: Adjust confidence scores or revise opinion text based on evaluation
|
||||
|
||||
**Example Reinforcement**:
|
||||
|
||||
**Existing Opinion** (confidence: 0.7):
|
||||
|
||||
**New Fact**:
|
||||
|
||||
**LLM Evaluation**: "This evidence REINFORCES the opinion with strong quantitative support."
|
||||
|
||||
**Updated Opinion** (confidence: 0.85):
|
||||
|
||||
### 9.3 Reinforcement Algorithm
|
||||
|
||||
|
||||
### 9.4 Reinforcement Guarantees
|
||||
|
||||
**Consistency**: Opinions are only updated when new facts genuinely relate to existing beliefs
|
||||
|
||||
**Personality Coherence**: Reinforcement evaluation incorporates bank personality, ensuring updates align with trait-driven reasoning
|
||||
|
||||
**Transparency**: Each update records the triggering facts and reasoning, providing an audit trail
|
||||
|
||||
**Bounded Updates**: Confidence changes are bounded (±0.1-0.15 per update) to prevent extreme swings
|
||||
|
||||
## 10. Background Merging
|
||||
|
||||
### 10.1 Challenge
|
||||
|
||||
Memory bank backgrounds accumulate biographical information over time. New information may:
|
||||
- **Complement**: Add new facts without contradiction
|
||||
- **Conflict**: Contradict existing facts ("born in Texas" vs. "born in Colorado")
|
||||
- **Refine**: Provide more specific versions of existing facts
|
||||
|
||||
Naive concatenation creates incoherent backgrounds with contradictions. We need intelligent merging.
|
||||
|
||||
### 10.2 LLM-Powered Merging
|
||||
|
||||
We use an LLM to merge backgrounds with conflict resolution:
|
||||
|
||||
**Merge Rules**:
|
||||
1. **New overwrites old** when contradictory
|
||||
2. **Add non-conflicting** information
|
||||
3. **Maintain first-person** perspective ("I..." not "You...")
|
||||
4. **Keep concise** (under 500 characters)
|
||||
|
||||
**Prompt Template**:
|
||||
|
||||
**Example Merges**:
|
||||
|
||||
**Conflict Resolution**:
|
||||
- Current: "I was born in Colorado"
|
||||
- New: "You were born in Texas"
|
||||
- Result: "I was born in Texas"
|
||||
|
||||
**Addition**:
|
||||
- Current: "I was born in Texas"
|
||||
- New: "I have 10 years of startup experience"
|
||||
- Result: "I was born in Texas. I have 10 years of startup experience."
|
||||
|
||||
### 10.3 First-Person Normalization
|
||||
|
||||
Users may provide background in second person ("You are..."), but internal storage maintains first person for consistency in prompts.
|
||||
|
||||
**Normalization**: LLM automatically converts:
|
||||
- "You are a creative engineer" → "I am a creative engineer"
|
||||
- "You were born in 1990" → "I was born in 1990"
|
||||
- "You value innovation" → "I value innovation"
|
||||
|
||||
## 11. Personality-Driven Reasoning Examples
|
||||
|
||||
### 11.1 Example: Remote Work Discussion
|
||||
|
||||
**Scenario**: Two memory banks with opposite personalities discuss remote work given identical facts.
|
||||
|
||||
**Facts** (both banks receive):
|
||||
- "Remote work eliminates commute time (average 1 hour/day saved)"
|
||||
- "Office work provides spontaneous collaboration and mentorship"
|
||||
- "Studies show 65% of remote workers report higher productivity"
|
||||
- "Some managers report difficulty monitoring remote employee performance"
|
||||
|
||||
**Bank A** (High Openness=0.9, Low Conscientiousness=0.2, bias=0.8):
|
||||
|
||||
**Bank B** (Low Openness=0.2, High Conscientiousness=0.9, bias=0.8):
|
||||
|
||||
**Analysis**: Both banks accessed identical facts but formed opposite conclusions based on personality:
|
||||
- Bank A (high openness) weighted autonomy, flexibility, innovation
|
||||
- Bank B (high conscientiousness) weighted structure, monitoring, discipline
|
||||
|
||||
### 11.2 Example: Opinion Evolution
|
||||
|
||||
**Scenario**: Bank forms initial opinion, then encounters reinforcing and contradictory evidence.
|
||||
|
||||
**Initial State** (t=0):
|
||||
|
||||
**Reinforcement** (t=1):
|
||||
- New Fact: "Python dominates AI/ML with 75% market share; TensorFlow and PyTorch are Python-first"
|
||||
- Update: Confidence → 0.85, text adds "Python's dominance in AI/ML frameworks..."
|
||||
|
||||
**Partial Contradiction** (t=2):
|
||||
- New Fact: "Julia offers 10x faster numerical computation; increasingly adopted in research"
|
||||
- Update: Confidence → 0.75, text revised to include nuance about specialized languages
|
||||
|
||||
**Strong Contradiction** (t=3):
|
||||
- New Fact: "Major tech companies migrating data pipelines to Rust for performance"
|
||||
- Update: Confidence → 0.55, text revised to acknowledge Python's shifting role
|
||||
|
||||
**Trajectory**: The opinion evolved from strong conviction (0.7 → 0.85) to weaker, more malleable belief (0.55) as evidence accumulated.
|
||||
|
||||
## 12. Use Cases and Real-World Deployment
|
||||
|
||||
### 12.1 Multi-Persona Sports Commentary (Production Deployment)
|
||||
|
||||
**Application**: AI-generated sports analysis and entertainment content with multiple agent personalities
|
||||
|
||||
**Real-World System**: A production sports content platform where AI agents with distinct personalities co-host episodic shows discussing team performance, game analysis, and sports debates.
|
||||
|
||||
**System Architecture**:
|
||||
- **Multiple Banks**: Each bank has unique personality traits and sports background
|
||||
- **Continuous Memory**: Banks maintain persistent team/player assessments across episodes spanning months
|
||||
- **Opinion Evolution**: As games occur and statistics accumulate, banks automatically update beliefs through reinforcement
|
||||
- **Personality-Driven Commentary**: The same game results generate different perspectives based on bank traits
|
||||
|
||||
**Key Benefits Observed**:
|
||||
1. **Viewer Engagement**: Improved audience retention with "personality diversity" as primary appeal
|
||||
2. **Content Consistency**: Banks maintain recognizable voices across episodes without manual tuning
|
||||
3. **Scalability**: New banks can be added with distinct personalities without retraining
|
||||
4. **Opinion Richness**: Opinion networks capture nuanced, evolving assessments
|
||||
|
||||
This deployment validates that personality-driven opinion systems can operate at production scale for content generation requiring consistent yet adaptive perspectives.
|
||||
|
||||
### 12.2 Additional Use Cases
|
||||
|
||||
**Customer Support**: Multi-agent systems with specialized personas (empathetic, analytical, creative)
|
||||
|
||||
**Consistent Character AI**: Conversational AI characters for entertainment or education with stable personality
|
||||
|
||||
**Explainable AI**: Systems requiring transparent decision-making where personality traits explain reasoning style
|
||||
|
||||
---
|
||||
|
||||
# Part III: Unified Hindsight Architecture
|
||||
|
||||
## 13. Integration: TEMPR + CARA
|
||||
|
||||
The Hindsight system integrates TEMPR (recall) and CARA (reflect) into a unified architecture:
|
||||
|
||||
### 13.1 Three Core Operations
|
||||
|
||||
**1. Retain** (retain()): Store information into memory banks
|
||||
- LLM-powered fact extraction with temporal ranges
|
||||
- Entity recognition and resolution
|
||||
- Graph link construction (temporal, semantic, entity, causal)
|
||||
- Automatic opinion reinforcement for existing beliefs
|
||||
|
||||
**2. Recall** (recall()): Retrieve memories using multi-strategy search
|
||||
- Four-way parallel retrieval (semantic, keyword, graph, temporal)
|
||||
- Reciprocal Rank Fusion
|
||||
- Neural cross-encoder reranking
|
||||
- Token budget filtering
|
||||
|
||||
**3. Reflect** (reflect()): Generate personality-aware responses
|
||||
- Retrieves relevant memories from all networks using TEMPR
|
||||
- Loads bank personality and background
|
||||
- Generates response influenced by Big Five traits
|
||||
- Forms new opinions with confidence scores
|
||||
- Stores opinions for future retrieval
|
||||
|
||||
### 13.2 Unified Data Flow
|
||||
|
||||
|
||||
### 13.3 PostgreSQL Schema
|
||||
|
||||
The system uses PostgreSQL with pgvector for storage:
|
||||
|
||||
|
||||
## 14. System Properties
|
||||
|
||||
### 14.1 Epistemic Clarity
|
||||
|
||||
The three-network architecture provides clear separation:
|
||||
- **World**: What the bank knows about the world
|
||||
- **Bank**: What the bank has done
|
||||
- **Opinion**: What the bank believes
|
||||
|
||||
This enables:
|
||||
- Transparent reasoning (trace opinions back to facts)
|
||||
- Debugging (identify missing facts vs. flawed reasoning)
|
||||
- Confidence calibration (opinions have confidence, facts don't)
|
||||
|
||||
### 14.2 Temporal Awareness
|
||||
|
||||
Multi-dimensional temporal representation:
|
||||
- occurred_start / occurred_end: When events actually happened
|
||||
- mentioned_at: When the bank learned about it
|
||||
- event_date: Backward compatibility
|
||||
|
||||
Enables:
|
||||
- Precise historical queries ("What happened in June?")
|
||||
- Recency-aware ranking (newer mentions prioritized)
|
||||
- Period matching (events spanning weeks or months)
|
||||
|
||||
### 14.3 Entity-Aware Reasoning
|
||||
|
||||
LLM-based entity resolution creates knowledge graph:
|
||||
- Connects semantically distant facts through shared entities
|
||||
- Enables multi-hop discovery ("Alice's manager's team")
|
||||
- Disambiguates mentions ("Alice" vs. "Alice Chen")
|
||||
|
||||
### 14.4 Multiple Link Types
|
||||
|
||||
The graph incorporates multiple relationship types:
|
||||
- Entity links connect memories mentioning the same entities
|
||||
- Semantic links connect conceptually similar memories
|
||||
- Temporal links connect temporally proximate memories
|
||||
- Causal links represent identified cause-effect relationships
|
||||
- Links are weighted differently during graph traversal
|
||||
|
||||
### 14.5 Personality Consistency
|
||||
|
||||
Big Five traits ensure stable reasoning style:
|
||||
- Configurable bias strength (objective to subjective)
|
||||
- Trait-appropriate opinion formation
|
||||
- Consistent voice across interactions
|
||||
|
||||
### 14.6 Dynamic Belief Systems
|
||||
|
||||
Opinion reinforcement enables belief evolution:
|
||||
- Confidence increases with supporting evidence
|
||||
- Confidence decreases with contradictory evidence
|
||||
- Opinion text revised when strongly contradicted
|
||||
- Audit trail of belief changes
|
||||
|
||||
## 15. Conclusion
|
||||
|
||||
We present Hindsight, a unified memory architecture for AI agents that combines TEMPR's multi-strategy retrieval with CARA's personality-driven reasoning. The system achieves strong performance on established benchmarks (73.50% on LoComo, 80.60% on LongMemEval) while enabling personality-consistent opinion formation through the Big Five model.
|
||||
|
||||
The integration of four parallel search strategies (semantic, keyword, graph with multiple link types, temporal) with three-network architecture (world, bank, opinion) and opinion reinforcement creates a comprehensive memory system that:
|
||||
- Retrieves information with high recall and precision
|
||||
- Maintains epistemic clarity between facts and beliefs
|
||||
- Enables personality-driven reasoning with stable traits
|
||||
- Supports dynamic belief evolution with evidence
|
||||
|
||||
Real-world deployment in sports content generation demonstrates the system's ability to maintain consistent yet adaptive perspectives across extended interactions. Future work will explore personality evolution, multi-agent belief systems, and richer personality models incorporating values and cultural factors.
|
||||
|
||||
By combining temporal-aware retrieval with personality-driven reasoning, Hindsight moves toward conversational agents that exhibit not just memory and intelligence, but character—stable traits and evolving beliefs that enable more natural, trustworthy human-AI interaction.
|
||||
|
||||
## References
|
||||
|
||||
1. Anderson, J. R. (1983). A spreading activation theory of memory. *Journal of Verbal Learning and Verbal Behavior*, 22(3), 261-295.
|
||||
|
||||
2. Cormack, G. V., Clarke, C. L., & Buettcher, S. (2009). Reciprocal rank fusion outperforms condorcet and individual rank learning methods. In *SIGIR'09* (pp. 758-759).
|
||||
|
||||
3. McCrae, R. R., & Costa, P. T. (1997). Personality trait structure as a human universal. *American Psychologist*, 52(5), 509.
|
||||
|
||||
4. Goldberg, L. R. (1993). The structure of phenotypic personality traits. *American Psychologist*, 48(1), 26.
|
||||
|
||||
5. Malkov, Y. A., & Yashunin, D. A. (2018). Efficient and robust approximate nearest neighbor search using hierarchical navigable small world graphs. *IEEE Transactions on Pattern Analysis and Machine Intelligence*, 42(4), 824-836.
|
||||
|
||||
6. Robertson, S., & Zaragoza, H. (2009). The probabilistic relevance framework: BM25 and beyond. *Foundations and Trends in Information Retrieval*, 3(4), 333-489.
|
||||
|
||||
7. Brown, T. B., Mann, B., Ryder, N., Subbiah, M., Kaplan, J., Dhariwal, P., ... & Amodei, D. (2020). Language models are few-shot learners. *Advances in Neural Information Processing Systems*, 33, 1877-1901.
|
||||
|
||||
8. Petroni, F., Rocktäschel, T., Riedel, S., Lewis, P., Bakhtin, A., Wu, Y., & Miller, A. (2019). Language models as knowledge bases?. In *Proceedings of EMNLP-IJCNLP* (pp. 2463-2473).
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Vectorize AI, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,98 +1,262 @@
|
||||
# Hindsight
|
||||
<div align="center">
|
||||
|
||||
**Long-term memory for AI agents.**
|
||||

|
||||
|
||||
## Why Hindsight?
|
||||
[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)
|
||||
|
||||
AI assistants forget everything between sessions. Every conversation starts from zero—no context about who you are, what you've discussed, or what the memory bank has learned. This isn't just inconvenient; it fundamentally limits what AI memory banks can do.
|
||||
[](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
|
||||
[](https://join.slack.com/t/hindsight-space/shared_invite/zt-3klo21kua-VUCC_zHP5rIcXFB1_5yw6A)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||

|
||||

|
||||
|
||||
**The problem is harder than it looks:**
|
||||
|
||||
- **Simple vector search isn't enough** — "What did Alice do last spring?" requires temporal reasoning, not just semantic similarity
|
||||
- **Facts get disconnected** — Knowing "Alice works at Google" and "Google is in Mountain View" should let you answer "Where does Alice work?" even if you never stored that directly
|
||||
- **Memory banks need opinions** — A coding assistant that remembers "the user prefers functional programming" should weigh that when making recommendations
|
||||
- **Context matters** — The same information means different things to different memory banks with different personalities
|
||||
</div>
|
||||
|
||||
Hindsight solves these problems with a memory system designed specifically for AI memory banks.
|
||||
---
|
||||
|
||||
## 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 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.
|
||||
|
||||
- **Inconsistency:** Agents complete tasks successfully one time, then fail when asked to complete the same task again. Memory gives the agent a mechanism to remember what worked and what didn't and to use that information to reduce errors and improve consistency.
|
||||
- **Hallucinations:** Long term memory can be seeded with external knowledge to ground agent behavior in reliable sources to augment training data.
|
||||
- **Cognitive Overload:** As workflows get complex, retrievals, tool calls, user messages and agent responses can grow to fill the context window leading to context rot. Short term memory optimization allows agents to reduce tokens and focus context by removing irrelevant details.
|
||||
|
||||
## How is Hindsight Different From Other Memory Systems?
|
||||
|
||||

|
||||
|
||||
Most agent memory implementation rely on basic vector search or sometimes use a knowledge graph. Hindsight uses biomimetic data structures to organize agent memories in a way that is more like how human memory works:
|
||||
|
||||
- **World:** Facts about the world ("The stove gets hot")
|
||||
- **Experiences:** Agent's own experiences ("I touched the stove and it really hurt")
|
||||
- **Opinion:** Beliefs with confidence scores ("I shouldn't touch the stove again" - .99 confidence)
|
||||
- **Observation:** Complex mental models derived by reflecting on facts and experiences ("Curling irons, ovens, and fire are also hot. I shouldn't touch those either.")
|
||||
|
||||
Memories in Hindsight are stored in banks (i.e. memory banks). When memories are added to Hindsight, they are pushed into either the world facts or experiences memory pathway. They are then represented as a combination of entities, relationships, and time series with sparse/dense vector representations to aid in later recall.
|
||||
|
||||
Hindsight provides three simple methods to interact with the system:
|
||||
|
||||
- **Retain:** Provide information to Hindsight that you want it to remember
|
||||
- **Recall:** Retrieve memories from Hindsight
|
||||
- **Reflect:** Reflect on memories and experiences to generate new observations and insights from existing memories.
|
||||
|
||||
### Agent Memory That Learns
|
||||
|
||||
A key goal of Hindsight is to build agent memory that enables agents to learn and improve over time. This is the role of the `reflect` operation which provides the agent to form broader opinions and observations over time.
|
||||
|
||||
For example, imagine a product support agent that is helping a user troubleshoot a problem. It uses a `search-documentation` tool it found on an MCP server. Later in the conversation, the agent discovers that the documentation returned from the tool wasn't for the product the user was asking about. The agent now has an experience in its memory bank. And just like humans, we want that agent to learn from its experience.
|
||||
|
||||
As the agent gains more experiences, `reflect` allows the agent to form observations about what worked, what didn't, and what to do differently the next time it encounters a similar task.
|
||||
|
||||
---
|
||||
|
||||
## Memory Performance & Accuracy
|
||||
|
||||
Hindsight 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:
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
### Option 1: Docker (recommended)
|
||||
|
||||
Get the full experience with the API and Control Plane UI:
|
||||
### Docker (recommended)
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=your-key
|
||||
docker run -p 8888:8888 -p 9999:9999 \
|
||||
-e HINDSIGHT_API_LLM_PROVIDER=openai \
|
||||
|
||||
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
|
||||
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
|
||||
-e HINDSIGHT_API_LLM_MODEL=gpt-4o-mini \
|
||||
vectorize/hindsight
|
||||
-e HINDSIGHT_API_LLM_MODEL=o3-mini \
|
||||
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
|
||||
ghcr.io/vectorize-io/hindsight:latest
|
||||
```
|
||||
|
||||
- **API**: http://localhost:8888
|
||||
- **Control Plane UI**: http://localhost:9999
|
||||
API: http://localhost:8888
|
||||
UI: http://localhost:9999
|
||||
|
||||
Then use the Python client:
|
||||
Install client:
|
||||
|
||||
```bash
|
||||
pip install hindsight-client
|
||||
pip install hindsight-client -U
|
||||
# or
|
||||
npm install @vectorize-io/hindsight-client
|
||||
```
|
||||
|
||||
Python example:
|
||||
|
||||
```python
|
||||
from hindsight import HindsightClient
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
client = HindsightClient(base_url="http://localhost:8888")
|
||||
client = Hindsight(base_url="http://localhost:8888")
|
||||
|
||||
# Store memories
|
||||
client.retain(bank_id="my-agent", content="Alice works at Google as a software engineer")
|
||||
client.retain(bank_id="my-agent", content="Alice mentioned she loves hiking in the mountains")
|
||||
# Retain: Store information
|
||||
client.retain(bank_id="my-bank", content="Alice works at Google as a software engineer")
|
||||
|
||||
# Query with temporal reasoning
|
||||
results = client.recall(bank_id="my-agent", query="What does Alice do for work?")
|
||||
# Recall: Search memories
|
||||
client.recall(bank_id="my-bank", query="What does Alice do?")
|
||||
|
||||
# Get a synthesized perspective
|
||||
response = client.reflect(bank_id="my-agent", query="Tell me about Alice")
|
||||
print(response.text)
|
||||
# Reflect: Generate disposition-aware response
|
||||
client.reflect(bank_id="my-bank", query="Tell me about Alice")
|
||||
```
|
||||
|
||||
### Option 2: Embedded (no docker/server required)
|
||||
|
||||
For quick prototyping, run everything in-process:
|
||||
### Python (embedded, no Docker)
|
||||
|
||||
```bash
|
||||
pip install hindsight-all
|
||||
export OPENAI_API_KEY=your-key
|
||||
pip install hindsight-all -U
|
||||
```
|
||||
|
||||
```python
|
||||
import os
|
||||
from hindsight import HindsightServer, HindsightClient
|
||||
|
||||
with HindsightServer(llm_provider="openai", llm_model="gpt-4o-mini", llm_api_key=os.environ["OPENAI_API_KEY"]) as server:
|
||||
with HindsightServer(
|
||||
llm_provider="openai",
|
||||
llm_model="gpt-5-mini",
|
||||
llm_api_key=os.environ["OPENAI_API_KEY"]
|
||||
) as server:
|
||||
client = HindsightClient(base_url=server.url)
|
||||
|
||||
client.retain(bank_id="my-user", content="User prefers functional programming")
|
||||
response = client.reflect(bank_id="my-user", query="What coding style should I use?")
|
||||
print(response.text)
|
||||
client.retain(bank_id="my-bank", content="Alice works at Google")
|
||||
results = client.recall(bank_id="my-bank", query="Where does Alice work?")
|
||||
```
|
||||
|
||||
### Node.js / TypeScript
|
||||
|
||||
```bash
|
||||
npm install @vectorize-io/hindsight-client
|
||||
```
|
||||
|
||||
## Documentation
|
||||
```javascript
|
||||
const { HindsightClient } = require('@vectorize-io/hindsight-client');
|
||||
|
||||
Full documentation: [vectorize-io.github.io/hindsight](https://vectorize-io.github.io/hindsight)
|
||||
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
|
||||
|
||||
- [Architecture](https://vectorize-io.github.io/hindsight/developer/architecture) — How ingestion, storage, and retrieval work
|
||||
- [Python Client](https://vectorize-io.github.io/hindsight/sdks/python) — Full API reference
|
||||
- [API Reference](https://vectorize-io.github.io/hindsight/api-reference) — REST API endpoints
|
||||
- [Personality](https://vectorize-io.github.io/hindsight/developer/personality) — Big Five traits and opinion formation
|
||||
await client.retain('my-bank', 'Alice loves hiking in Yosemite');
|
||||
await client.recall('my-bank', 'What does Alice like?');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture & Operations
|
||||
|
||||
### Retain
|
||||
|
||||
The `retain` operation is used to push new memories into Hindsight. It tells Hindsight to _retain_ the information you pass in as an input.
|
||||
|
||||
```python
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
client = Hindsight(base_url="http://localhost:8888")
|
||||
|
||||
# Simple
|
||||
client.retain(
|
||||
bank_id="my-bank",
|
||||
content="Alice works at Google as a software engineer"
|
||||
)
|
||||
|
||||
# With context and timestamp
|
||||
client.retain(
|
||||
bank_id="my-bank",
|
||||
content="Alice got promoted to senior engineer",
|
||||
context="career update",
|
||||
timestamp="2025-06-15T10:00:00Z"
|
||||
)
|
||||
```
|
||||
|
||||
Behind the scenes, the retain operation uses an LLM to extract key facts, temporal data, entities, and relationships. It passes these through a normalization process to transform extracted data into canonical entities, time series, and search indexes along with metadata. These representations create the pathways for accurate memory retrieval in the recall and reflect operations.
|
||||
|
||||

|
||||
|
||||
### Recall
|
||||
|
||||
The recall operation is used to retrieve memories. These memories can come from any of the memory types (world, experiences, etc.)
|
||||
|
||||
```python
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
client = Hindsight(base_url="http://localhost:8888")
|
||||
|
||||
# Simple
|
||||
client.recall(bank_id="my-bank", query="What does Alice do?")
|
||||
|
||||
# Temporal
|
||||
client.recall(bank_id="my-bank", query="What happened in June?")
|
||||
```
|
||||
|
||||
Recall performs 4 retrieval strategies in parallel:
|
||||
- Semantic: Vector similarity
|
||||
- Keyword: BM25 exact matching
|
||||
- Graph: Entity/temporal/causal links
|
||||
- Temporal: Time range filtering
|
||||
|
||||

|
||||
|
||||
The individual results from the retrievals are merged, then ordered by relevance using reciprocal rank fusion and a cross-encoder reranking model.
|
||||
|
||||
The final output is trimmed as needed to fit within the token limit.
|
||||
|
||||
### Reflect
|
||||
|
||||
The reflect operation is used to perform a more thorough analysis of existing memories. This allows the agent to form new connections between memories which are then persisted as opinions and/or observations. When building agents, the reflect operation is a key capability to enable the agent to learn from its experiences.
|
||||
|
||||
For example, the `reflect` operation can be used to support use cases such as:
|
||||
|
||||
- An **AI Project Manager** reflecting on what risks need to be mitigated on a project.
|
||||
- A **Sales Agent** reflecting on why certain outreach messages have gotten responses while others haven't.
|
||||
- A **Support Agent** reflecting on opportunities where customers have questions not answered by current product documentation.
|
||||
|
||||
The `reflect` operation can also be used to handle on-demand question answering or analysis which require more deep thinking.
|
||||
|
||||
```python
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
client = Hindsight(base_url="http://localhost:8888")
|
||||
|
||||
client.reflect(bank_id="my-bank", query="What should I know about Alice?")
|
||||
```
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
**Documentation:**
|
||||
- [https://hindsight.vectorize.io](https://hindsight.vectorize.io)
|
||||
|
||||
**Clients:**
|
||||
- [Python](http://hindsight.vectorize.io/sdks/python)
|
||||
- [Node.js](http://hindsight.vectorize.io/sdks/nodejs)
|
||||
- [REST API](https://hindsight.vectorize.io/api-reference)
|
||||
- [CLI](https://hindsight.vectorize.io/sdks/cli)
|
||||
|
||||
**Community:**
|
||||
- [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
|
||||
|
||||
[](https://www.star-history.com/#vectorize-io/hindsight&type=date&legend=top-left)
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
MIT — see [LICENSE](./LICENSE)
|
||||
|
||||
---
|
||||
|
||||
Built by [Vectorize.io](https://vectorize.io)
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We release patches for security vulnerabilities. Which versions are eligible for
|
||||
receiving such patches depends on the CVSS v3.0 Rating:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| latest | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report (suspected) security vulnerabilities to the maintainers privately.
|
||||
You can do this by opening a [GitHub Security Advisory](https://github.com/vectorize-io/hindsight/security/advisories/new).
|
||||
|
||||
You will receive a response from us within 48 hours. If the issue is confirmed,
|
||||
we will release a patch as soon as possible depending on complexity but
|
||||
typically within a few days.
|
||||
|
||||
Please include the following information in your report:
|
||||
|
||||
- Type of issue (e.g., buffer overflow, SQL injection, cross-site scripting, etc.)
|
||||
- Full paths of source file(s) related to the manifestation of the issue
|
||||
- The location of the affected source code (tag/branch/commit or direct URL)
|
||||
- Any special configuration required to reproduce the issue
|
||||
- Step-by-step instructions to reproduce the issue
|
||||
- Proof-of-concept or exploit code (if possible)
|
||||
- Impact of the issue, including how an attacker might exploit the issue
|
||||
|
||||
This information will help us triage your report more quickly.
|
||||
|
||||
## Preferred Languages
|
||||
|
||||
We prefer all communications to be in English.
|
||||
|
||||
## Policy
|
||||
|
||||
We follow the principle of [Coordinated Vulnerability Disclosure](https://www.cisa.gov/resources-tools/programs/coordinated-vulnerability-disclosure-program).
|
||||
@@ -1,155 +0,0 @@
|
||||
# Hindsight Docker
|
||||
|
||||
Run Hindsight with Docker in standalone or distributed mode.
|
||||
|
||||
## Quick Start (Standalone)
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
./start.sh
|
||||
```
|
||||
|
||||
**Force rebuild after code changes:**
|
||||
```bash
|
||||
./start.sh --build # Quick: rebuild and start
|
||||
# or
|
||||
./rebuild.sh # Complete: rebuild from scratch (no cache)
|
||||
```
|
||||
|
||||
Access:
|
||||
- **Control Plane**: http://localhost:3000
|
||||
- **API**: http://localhost:8888
|
||||
|
||||
Press `Ctrl+C` to stop.
|
||||
|
||||
## What You Get
|
||||
|
||||
**Standalone** (default, simple):
|
||||
- One container with API + Control Plane + embedded database
|
||||
- Perfect for local development and simple deployments
|
||||
|
||||
**Distributed** (advanced):
|
||||
- Separate containers for API and Control Plane
|
||||
- Better for production, scaling, or custom configurations
|
||||
|
||||
## Deployment Modes
|
||||
|
||||
### 1. Standalone (Recommended)
|
||||
|
||||
All-in-one container with embedded pg0 database.
|
||||
|
||||
```bash
|
||||
./start.sh
|
||||
# or
|
||||
cd standalone
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
**Data storage:** `/app/data` volume
|
||||
|
||||
### 2. Distributed (Advanced)
|
||||
|
||||
Separate API and Control Plane containers.
|
||||
|
||||
```bash
|
||||
cd services
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
**Data storage:** `api_data` volume
|
||||
|
||||
See `services/README.md` for details.
|
||||
|
||||
## Data Management
|
||||
|
||||
**Reset data:**
|
||||
```bash
|
||||
# Standalone
|
||||
cd standalone && docker-compose down -v
|
||||
|
||||
# Distributed
|
||||
cd services && docker-compose down -v
|
||||
```
|
||||
|
||||
## Building Images
|
||||
|
||||
```bash
|
||||
# Standalone
|
||||
cd standalone
|
||||
docker build -f Dockerfile -t hindsight:latest ../..
|
||||
|
||||
# Services
|
||||
cd services
|
||||
./build-all.sh
|
||||
```
|
||||
|
||||
## Using External Database
|
||||
|
||||
Both modes use embedded pg0 by default. To use external PostgreSQL:
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
docker/
|
||||
├── start.sh # Quick start (standalone)
|
||||
├── README.md # This file
|
||||
├── standalone/ # All-in-one deployment
|
||||
│ ├── Dockerfile
|
||||
│ ├── docker-compose.yml
|
||||
│ └── start-all.sh
|
||||
└── services/ # Distributed deployment
|
||||
├── docker-compose.yml
|
||||
├── api.Dockerfile
|
||||
├── control-plane.Dockerfile
|
||||
├── build-all.sh
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
**Background mode:**
|
||||
```bash
|
||||
cd standalone
|
||||
docker-compose up -d
|
||||
docker-compose logs -f
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
**Custom configuration:**
|
||||
Edit `standalone/docker-compose.yml` or `services/docker-compose.yml`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Hindsight requires configuration through environment variables (all prefixed with `HINDSIGHT_`).
|
||||
|
||||
### Required:
|
||||
- `HINDSIGHT_API_LLM_API_KEY` - Your LLM API key (OpenAI, Anthropic, etc.)
|
||||
|
||||
### Optional:
|
||||
- `HINDSIGHT_API_LLM_MODEL` - Model name (default: gpt-4o-mini)
|
||||
- `HINDSIGHT_API_LLM_BASE_URL` - API base URL (default: https://api.openai.com/v1)
|
||||
- `HINDSIGHT_API_LOG_LEVEL` - Logging level: debug, info, warning, error
|
||||
- `HINDSIGHT_API_DATABASE_URL` - External PostgreSQL connection (uses embedded pg0 by default)
|
||||
|
||||
### Setup Options:
|
||||
|
||||
**Option 1: .env file (recommended)**
|
||||
```bash
|
||||
# Copy example file
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env and add your API key
|
||||
HINDSIGHT_API_LLM_API_KEY=sk-...
|
||||
```
|
||||
|
||||
**Option 2: Export in shell**
|
||||
```bash
|
||||
export HINDSIGHT_API_LLM_API_KEY=sk-...
|
||||
export HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
|
||||
```
|
||||
|
||||
The `start.sh` script automatically loads `.env` if it exists and validates the API key is set.
|
||||
+109
-91
@@ -2,16 +2,19 @@
|
||||
# Supports building API-only, Control Plane-only, or both
|
||||
#
|
||||
# Build args:
|
||||
# INCLUDE_API=true/false - Include API (default: true)
|
||||
# INCLUDE_CP=true/false - Include Control Plane (default: true)
|
||||
# INCLUDE_API=true/false - Include API (default: true)
|
||||
# INCLUDE_CP=true/false - Include Control Plane (default: true)
|
||||
# PRELOAD_ML_MODELS=true/false - Pre-download ML models during build (default: true)
|
||||
#
|
||||
# Examples:
|
||||
# docker build -t hindsight . # Both (standalone)
|
||||
# docker build -t hindsight-api --build-arg INCLUDE_CP=false . # API only
|
||||
# docker build -t hindsight-cp --build-arg INCLUDE_API=false . # Control Plane only
|
||||
# docker build -t hindsight . # Both (standalone)
|
||||
# docker build -t hindsight-api --build-arg INCLUDE_CP=false . # API only
|
||||
# docker build -t hindsight-cp --build-arg INCLUDE_API=false . # Control Plane only
|
||||
# docker build -t hindsight --build-arg PRELOAD_ML_MODELS=false . # Skip ML model preload
|
||||
|
||||
ARG INCLUDE_API=true
|
||||
ARG INCLUDE_CP=true
|
||||
ARG PRELOAD_ML_MODELS=true
|
||||
|
||||
# =============================================================================
|
||||
# Stage: API Builder
|
||||
@@ -40,54 +43,80 @@ WORKDIR /app/api
|
||||
# Sync dependencies (will create lock file if needed)
|
||||
RUN uv sync
|
||||
|
||||
# Copy source code and alembic migrations
|
||||
# Copy source code (alembic migrations are inside hindsight_api/)
|
||||
COPY hindsight-api/hindsight_api ./hindsight_api
|
||||
COPY hindsight-api/alembic ./alembic
|
||||
|
||||
# Install the local package (uv sync only installed dependencies, not the package itself)
|
||||
RUN uv pip install -e .
|
||||
|
||||
# =============================================================================
|
||||
# Stage: SDK Builder (needed for Control Plane)
|
||||
# =============================================================================
|
||||
FROM node:20-alpine AS sdk-builder
|
||||
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/sdk
|
||||
WORKDIR /app
|
||||
|
||||
COPY hindsight-clients/typescript/package*.json ./
|
||||
RUN npm ci
|
||||
# Copy root package files for npm workspaces
|
||||
COPY package.json package-lock.json ./
|
||||
COPY hindsight-clients/typescript/ ./hindsight-clients/typescript/
|
||||
|
||||
COPY hindsight-clients/typescript/ ./
|
||||
RUN npm run build
|
||||
# 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
|
||||
|
||||
# =============================================================================
|
||||
# Stage: Control Plane Builder
|
||||
# =============================================================================
|
||||
FROM node:20-alpine AS cp-builder
|
||||
FROM node:20-slim AS cp-builder
|
||||
|
||||
ARG INCLUDE_CP
|
||||
RUN if [ "$INCLUDE_CP" != "true" ]; then echo "Skipping CP build" && exit 0; fi
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built SDK
|
||||
COPY --from=sdk-builder /app/sdk /app/sdk
|
||||
# Create directory structure matching the monorepo layout
|
||||
# This is required because build:standalone script expects .next/standalone/memory-poc/hindsight-control-plane
|
||||
WORKDIR /app/memory-poc/hindsight-control-plane
|
||||
|
||||
# Install Control Plane dependencies
|
||||
COPY hindsight-control-plane/package*.json ./
|
||||
RUN npm ci
|
||||
# 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
|
||||
# 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
|
||||
|
||||
# Link SDK (temporary for build)
|
||||
RUN cd /app/sdk && npm link && cd /app && npm link @hindsight/client
|
||||
# Copy built SDK directly into node_modules (more reliable than npm link in Docker)
|
||||
COPY --from=sdk-builder /app/hindsight-clients/typescript ./node_modules/@vectorize-io/hindsight-client
|
||||
|
||||
# Build Control Plane
|
||||
RUN npm run build
|
||||
# Build Control Plane - run next build first, then custom standalone copy
|
||||
# (The build:standalone script expects a specific path structure that differs in Docker)
|
||||
RUN npm exec -- next build
|
||||
|
||||
# Create public directory if it doesn't exist
|
||||
RUN mkdir -p public
|
||||
# Create standalone directory structure manually
|
||||
# Note: Must exclude node_modules from find to avoid wrong server.js from next/dist/experimental/testmode/
|
||||
# Note: Must explicitly copy .next since glob * doesn't match hidden directories
|
||||
RUN STANDALONE_ROOT=$(find .next/standalone -path '*/node_modules' -prune -o -name 'server.js' -print | head -1 | xargs dirname) && \
|
||||
mkdir -p standalone && \
|
||||
cp -r "$STANDALONE_ROOT"/* standalone/ && \
|
||||
cp -r "$STANDALONE_ROOT"/.next standalone/.next && \
|
||||
# Copy node_modules if separate from app dir (monorepo structure)
|
||||
if [ -d ".next/standalone/node_modules" ] && [ "$STANDALONE_ROOT" != ".next/standalone" ]; then \
|
||||
cp -r .next/standalone/node_modules standalone/node_modules; \
|
||||
fi && \
|
||||
cp -r .next/static standalone/.next/static && \
|
||||
mkdir -p standalone/public && \
|
||||
cp -r public/* standalone/public/ 2>/dev/null || true && \
|
||||
# Verify required files exist
|
||||
test -f standalone/server.js || (echo "ERROR: server.js missing!" && exit 1) && \
|
||||
test -f standalone/.next/BUILD_ID || (echo "ERROR: BUILD_ID missing!" && exit 1)
|
||||
|
||||
# =============================================================================
|
||||
# Stage: Final Image - API Only
|
||||
@@ -96,14 +125,16 @@ FROM python:3.11-slim AS api-only
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pg0 dependencies
|
||||
# Install pg0 dependencies (procps provides 'kill' command needed by pg0)
|
||||
# Note: libicu version varies by Debian version - try common versions in order
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
procps \
|
||||
libxml2 \
|
||||
libssl3 \
|
||||
libgssapi-krb5-2 \
|
||||
libossp-uuid16 \
|
||||
&& apt-get install -y libicu72 || apt-get install -y libicu74 || apt-get install -y libicu* \
|
||||
&& (apt-get install -y libicu72 2>/dev/null || apt-get install -y libicu74 2>/dev/null || apt-get install -y libicu76 2>/dev/null || true) \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& pip install --no-cache-dir uv
|
||||
|
||||
@@ -124,37 +155,25 @@ RUN mkdir -p /app/data && chown -R hindsight:hindsight /app
|
||||
USER hindsight
|
||||
|
||||
# Set PATH for hindsight user
|
||||
ENV PATH="/home/hindsight/.hindsight/bin:/app/api/.venv/bin:${PATH}"
|
||||
ENV PATH="/app/api/.venv/bin:${PATH}"
|
||||
|
||||
# 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 && \
|
||||
break || (echo "Retry $i failed, waiting..." && sleep 10); \
|
||||
done && \
|
||||
/home/hindsight/.hindsight/bin/pg0 --version
|
||||
|
||||
# Pre-download PostgreSQL binaries
|
||||
# Pre-cache PostgreSQL binaries by starting/stopping pg0-embedded
|
||||
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 "\
|
||||
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
|
||||
|
||||
EXPOSE 8888
|
||||
|
||||
ENV HINDSIGHT_API_HOST=0.0.0.0
|
||||
@@ -162,6 +181,7 @@ ENV HINDSIGHT_API_PORT=8888
|
||||
ENV HINDSIGHT_API_LOG_LEVEL=info
|
||||
ENV HINDSIGHT_ENABLE_API=true
|
||||
ENV HINDSIGHT_ENABLE_CP=false
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
CMD ["/app/start-all.sh"]
|
||||
|
||||
@@ -173,13 +193,13 @@ FROM node:20-alpine AS cp-only
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built SDK
|
||||
COPY --from=sdk-builder /app/sdk /app/sdk
|
||||
COPY --from=sdk-builder /app/hindsight-clients/typescript /app/sdk
|
||||
|
||||
# Copy Control Plane standalone build
|
||||
WORKDIR /app/control-plane
|
||||
COPY --from=cp-builder /app/.next/standalone ./
|
||||
COPY --from=cp-builder /app/.next/static ./.next/static
|
||||
COPY --from=cp-builder /app/public ./public
|
||||
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/standalone ./
|
||||
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/.next/static ./.next/static
|
||||
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/public ./public
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -206,14 +226,16 @@ FROM python:3.11-slim AS standalone
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Node.js, curl, uv, and pg0 dependencies
|
||||
# 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
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
procps \
|
||||
libxml2 \
|
||||
libssl3 \
|
||||
libgssapi-krb5-2 \
|
||||
libossp-uuid16 \
|
||||
&& apt-get install -y libicu72 || apt-get install -y libicu74 || apt-get install -y libicu* \
|
||||
&& (apt-get install -y libicu72 2>/dev/null || apt-get install -y libicu74 2>/dev/null || apt-get install -y libicu76 2>/dev/null || true) \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
@@ -226,13 +248,13 @@ RUN useradd -m -s /bin/bash hindsight
|
||||
COPY --from=api-builder /app/api /app/api
|
||||
|
||||
# Copy built SDK
|
||||
COPY --from=sdk-builder /app/sdk /app/sdk
|
||||
COPY --from=sdk-builder /app/hindsight-clients/typescript /app/sdk
|
||||
|
||||
# Copy Control Plane standalone build
|
||||
WORKDIR /app/control-plane
|
||||
COPY --from=cp-builder /app/.next/standalone ./
|
||||
COPY --from=cp-builder /app/.next/static ./.next/static
|
||||
COPY --from=cp-builder /app/public ./public
|
||||
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/standalone ./
|
||||
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/.next/static ./.next/static
|
||||
COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/public ./public
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -247,37 +269,32 @@ RUN mkdir -p /app/data && chown -R hindsight:hindsight /app
|
||||
USER hindsight
|
||||
|
||||
# Set PATH for hindsight user
|
||||
ENV PATH="/home/hindsight/.hindsight/bin:/app/api/.venv/bin:${PATH}"
|
||||
ENV PATH="/app/api/.venv/bin:${PATH}"
|
||||
|
||||
# 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 && \
|
||||
break || (echo "Retry $i failed, waiting..." && sleep 10); \
|
||||
done && \
|
||||
/home/hindsight/.hindsight/bin/pg0 --version
|
||||
|
||||
# Pre-download PostgreSQL binaries
|
||||
# Pre-cache PostgreSQL binaries by starting/stopping pg0-embedded
|
||||
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"
|
||||
RUN /app/api/.venv/bin/python -c "\
|
||||
from pg0 import Pg0; \
|
||||
print('Pre-caching PostgreSQL binaries...'); \
|
||||
pg = Pg0(name='hindsight', port=5555, username='hindsight', password='hindsight', database='hindsight'); \
|
||||
pg.start(); \
|
||||
pg.stop(); \
|
||||
print('PostgreSQL pre-cached to PG0_HOME')" || echo "Pre-download skipped"
|
||||
|
||||
ENV PG0_HOME=/home/hindsight/.pg0
|
||||
|
||||
# Pre-download ML models to avoid runtime download (conditional)
|
||||
ARG PRELOAD_ML_MODELS
|
||||
RUN if [ "$PRELOAD_ML_MODELS" = "true" ]; then \
|
||||
/app/api/.venv/bin/python -c "\
|
||||
from sentence_transformers import SentenceTransformer, CrossEncoder; \
|
||||
print('Downloading embedding model...'); \
|
||||
SentenceTransformer('BAAI/bge-small-en-v1.5'); \
|
||||
print('Downloading cross-encoder model...'); \
|
||||
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); \
|
||||
print('Models cached successfully')"; \
|
||||
else echo "Skipping ML model preload"; fi
|
||||
|
||||
EXPOSE 8888 9999
|
||||
|
||||
ENV HINDSIGHT_API_HOST=0.0.0.0
|
||||
@@ -287,6 +304,7 @@ ENV NODE_ENV=production
|
||||
ENV HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888
|
||||
ENV HINDSIGHT_ENABLE_API=true
|
||||
ENV HINDSIGHT_ENABLE_CP=true
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
CMD ["/app/start-all.sh"]
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
services:
|
||||
hindsight:
|
||||
image: hindsight
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/standalone/Dockerfile
|
||||
env_file:
|
||||
- ../../.env
|
||||
ports:
|
||||
- "9999:9999"
|
||||
- "8888:8888"
|
||||
environment:
|
||||
# These override env_file values only when set in host shell
|
||||
# Default values are applied only when not set in env_file or host
|
||||
HINDSIGHT_API_HOST: ${HINDSIGHT_API_HOST:-0.0.0.0}
|
||||
HINDSIGHT_API_PORT: ${HINDSIGHT_API_PORT:-8888}
|
||||
HINDSIGHT_API_LOG_LEVEL: ${HINDSIGHT_API_LOG_LEVEL:-info}
|
||||
# HINDSIGHT_API_DATABASE_URL can be set if you want to use an external database
|
||||
# If not set, embedded pg0 will be used automatically
|
||||
volumes:
|
||||
- hindsight_data:/home/hindsight/.pg0
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
hindsight_data:
|
||||
@@ -1,9 +1,6 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting Hindsight..."
|
||||
echo ""
|
||||
|
||||
# Service flags (default to true if not set)
|
||||
ENABLE_API="${HINDSIGHT_ENABLE_API:-true}"
|
||||
ENABLE_CP="${HINDSIGHT_ENABLE_CP:-true}"
|
||||
@@ -26,32 +23,31 @@ PIDS=()
|
||||
# Start API if enabled
|
||||
if [ "$ENABLE_API" = "true" ]; then
|
||||
cd /app/api
|
||||
python -m hindsight_api.web.server 2>&1 | sed -u 's/^/[api] /' &
|
||||
# Run API directly - Python's PYTHONUNBUFFERED=1 handles output buffering
|
||||
hindsight-api &
|
||||
API_PID=$!
|
||||
PIDS+=($API_PID)
|
||||
|
||||
# Wait for API to be ready
|
||||
echo "⏳ Waiting for API..."
|
||||
for i in {1..60}; do
|
||||
if curl -sf http://localhost:8888/health &>/dev/null; then
|
||||
echo "✅ API is ready"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
else
|
||||
echo "⏭️ API disabled (HINDSIGHT_ENABLE_API=false)"
|
||||
echo "API disabled (HINDSIGHT_ENABLE_API=false)"
|
||||
fi
|
||||
|
||||
# Start Control Plane if enabled
|
||||
if [ "$ENABLE_CP" = "true" ]; then
|
||||
echo "🎛️ Starting Control Plane..."
|
||||
cd /app/control-plane
|
||||
PORT=9999 node server.js 2>&1 | grep -v -E "^[[:space:]]*(▲|✓|-|$)" | sed -u 's/^/[control-plane] /' &
|
||||
PORT=9999 node server.js &
|
||||
CP_PID=$!
|
||||
PIDS+=($CP_PID)
|
||||
else
|
||||
echo "⏭️ Control Plane disabled (HINDSIGHT_ENABLE_CP=false)"
|
||||
echo "Control Plane disabled (HINDSIGHT_ENABLE_CP=false)"
|
||||
fi
|
||||
|
||||
# Print status
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Start Hindsight (standalone all-in-one)
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Check for --build flag
|
||||
BUILD_FLAG=""
|
||||
if [[ "$1" == "--build" ]] || [[ "$1" == "-b" ]]; then
|
||||
BUILD_FLAG="--build"
|
||||
echo "🔨 Forcing rebuild of images..."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "🚀 Starting Hindsight..."
|
||||
echo ""
|
||||
|
||||
# Load .env file from project root if it exists
|
||||
if [ -f ../.env ]; then
|
||||
echo "📝 Loading environment variables from .env file..."
|
||||
export $(grep -v '^#' ../.env | grep -v '^$' | xargs)
|
||||
fi
|
||||
|
||||
# Check for required HINDSIGHT_API_LLM_API_KEY
|
||||
if [ -z "$HINDSIGHT_API_LLM_API_KEY" ]; then
|
||||
echo "⚠️ Warning: HINDSIGHT_API_LLM_API_KEY is not set"
|
||||
echo ""
|
||||
echo "Set it by either:"
|
||||
echo " 1. Creating a .env file in the project root with: HINDSIGHT_API_LLM_API_KEY=your-key"
|
||||
echo " 2. Exporting: export HINDSIGHT_API_LLM_API_KEY=your-key"
|
||||
echo ""
|
||||
read -p "Continue anyway? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
cd standalone
|
||||
|
||||
# Run docker-compose with optional --build flag
|
||||
docker-compose up $BUILD_FLAG
|
||||
@@ -1,135 +0,0 @@
|
||||
HINDSIGHT HELM CHART INSTALLATION GUIDE
|
||||
=====================================
|
||||
|
||||
PREREQUISITES
|
||||
-------------
|
||||
- Kubernetes cluster (1.19+)
|
||||
- kubectl configured
|
||||
- Helm 3.x installed
|
||||
- PostgreSQL database with pgvector extension (if not using bundled PostgreSQL)
|
||||
|
||||
BASIC INSTALLATION
|
||||
------------------
|
||||
|
||||
1. Install with default values (requires external PostgreSQL):
|
||||
|
||||
helm install hindsight ./hindsight \
|
||||
--set postgresql.external.host=your-postgres-host \
|
||||
--set postgresql.external.password=your-password \
|
||||
--set api.secrets.MEMORY_LLM_API_KEY=your-api-key
|
||||
|
||||
2. Install with custom values file:
|
||||
|
||||
helm install hindsight ./hindsight -f hindsight/values-production.yaml
|
||||
|
||||
3. Install in a specific namespace:
|
||||
|
||||
kubectl create namespace hindsight
|
||||
helm install hindsight ./hindsight -n hindsight
|
||||
|
||||
CONFIGURATION OPTIONS
|
||||
---------------------
|
||||
|
||||
Development setup (using values-development.yaml):
|
||||
helm install hindsight ./hindsight -f hindsight/values-development.yaml
|
||||
|
||||
Production setup (using values-production.yaml):
|
||||
helm install hindsight ./hindsight -f hindsight/values-production.yaml
|
||||
|
||||
Custom LLM provider:
|
||||
helm install hindsight ./hindsight \
|
||||
--set api.env.MEMORY_LLM_PROVIDER=openai \
|
||||
--set api.env.MEMORY_LLM_MODEL=gpt-4 \
|
||||
--set api.secrets.MEMORY_LLM_API_KEY=sk-your-key
|
||||
|
||||
Enable ingress:
|
||||
helm install hindsight ./hindsight \
|
||||
--set ingress.enabled=true \
|
||||
--set ingress.hosts[0].host=hindsight.example.com
|
||||
|
||||
Enable autoscaling:
|
||||
helm install hindsight ./hindsight \
|
||||
--set autoscaling.enabled=true \
|
||||
--set autoscaling.minReplicas=2 \
|
||||
--set autoscaling.maxReplicas=10
|
||||
|
||||
UPGRADE
|
||||
-------
|
||||
|
||||
Upgrade existing installation:
|
||||
helm upgrade hindsight ./hindsight
|
||||
|
||||
Upgrade with new values:
|
||||
helm upgrade hindsight ./hindsight -f hindsight/values-production.yaml
|
||||
|
||||
UNINSTALL
|
||||
---------
|
||||
|
||||
Remove the Helm release:
|
||||
helm uninstall hindsight
|
||||
|
||||
Remove with namespace:
|
||||
helm uninstall hindsight -n hindsight
|
||||
|
||||
TESTING
|
||||
-------
|
||||
|
||||
Test the installation with dry-run:
|
||||
helm install hindsight ./hindsight --dry-run --debug
|
||||
|
||||
Validate templates:
|
||||
helm template hindsight ./hindsight
|
||||
|
||||
Lint the chart:
|
||||
helm lint ./hindsight
|
||||
|
||||
ACCESSING THE SERVICES
|
||||
----------------------
|
||||
|
||||
Port-forward control plane:
|
||||
kubectl port-forward svc/hindsight-control-plane 3000:3000
|
||||
|
||||
Port-forward API:
|
||||
kubectl port-forward svc/hindsight-api 8888:8888
|
||||
|
||||
Get service URLs:
|
||||
helm status hindsight
|
||||
|
||||
DATABASE INITIALIZATION
|
||||
-----------------------
|
||||
|
||||
NOTE: Database migrations now run automatically when the API service starts.
|
||||
You typically don't need to run migrations manually.
|
||||
|
||||
If you want to pre-initialize the database before deploying (optional):
|
||||
kubectl run hindsight-init --rm -it --restart=Never \
|
||||
--image=hindsight/api:latest \
|
||||
--env="DATABASE_URL=postgresql://user:pass@host:5432/hindsight" \
|
||||
-- python -c "from hindsight.migrations import run_migrations; run_migrations()"
|
||||
|
||||
TROUBLESHOOTING
|
||||
---------------
|
||||
|
||||
Check pod status:
|
||||
kubectl get pods -l app.kubernetes.io/name=hindsight
|
||||
|
||||
View logs for API:
|
||||
kubectl logs -l app.kubernetes.io/component=api
|
||||
|
||||
View logs for control plane:
|
||||
kubectl logs -l app.kubernetes.io/component=control-plane
|
||||
|
||||
Describe a pod:
|
||||
kubectl describe pod <pod-name>
|
||||
|
||||
Check configuration:
|
||||
kubectl get configmap hindsight-config -o yaml
|
||||
kubectl get secret hindsight-secret -o yaml
|
||||
|
||||
NOTES
|
||||
-----
|
||||
- Make sure PostgreSQL has pgvector extension enabled
|
||||
- Run database migrations before first use
|
||||
- Configure proper resource limits for production
|
||||
- Use external secrets management for production
|
||||
- Enable TLS/SSL for production deployments
|
||||
@@ -0,0 +1,6 @@
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
repository: https://charts.bitnami.com/bitnami
|
||||
version: 15.5.38
|
||||
digest: sha256:f67c7612736803ece8a669f8ca6b0555f3b78557bc0ecb732aa2e43f0df7750d
|
||||
generated: "2025-12-10T17:20:57.058794+01:00"
|
||||
@@ -1,9 +1,9 @@
|
||||
apiVersion: v2
|
||||
name: hindsight
|
||||
description: A Helm chart for Hindsight - temporal-semantic-entity memory system for AI agents
|
||||
description: Hindsight helm chart
|
||||
type: application
|
||||
version: 0.0.8
|
||||
appVersion: "0.0.8"
|
||||
version: 0.1.14
|
||||
appVersion: "0.1.14"
|
||||
keywords:
|
||||
- ai
|
||||
- memory
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# Hindsight Helm Chart
|
||||
|
||||
Helm chart for deploying Hindsight - a temporal-semantic-entity memory system for AI agents.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Kubernetes 1.19+
|
||||
- Helm 3.0+
|
||||
- PostgreSQL database (external or bundled)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Update dependencies first
|
||||
helm dependency update ./helm/hindsight
|
||||
|
||||
# Install (PostgreSQL included by default)
|
||||
export OPENAI_API_KEY="sk-your-openai-key"
|
||||
helm upgrade hindsight --install ./helm/hindsight -n hindsight --create-namespace \
|
||||
--set api.secrets.HINDSIGHT_API_LLM_API_KEY="$OPENAI_API_KEY"
|
||||
```
|
||||
|
||||
To use an external database instead:
|
||||
|
||||
```bash
|
||||
helm install hindsight ./helm/hindsight -n hindsight --create-namespace \
|
||||
--set api.secrets.HINDSIGHT_API_LLM_API_KEY="sk-your-openai-key" \
|
||||
--set postgresql.enabled=false \
|
||||
--set postgresql.external.host=my-postgres.example.com \
|
||||
--set postgresql.external.password=mypassword
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Add the repository (if published)
|
||||
|
||||
```bash
|
||||
helm repo add hindsight https://your-helm-repo.com
|
||||
helm repo update
|
||||
```
|
||||
|
||||
### Install with custom values file
|
||||
|
||||
Create a `values-override.yaml`:
|
||||
|
||||
```yaml
|
||||
api:
|
||||
secrets:
|
||||
HINDSIGHT_API_LLM_API_KEY: "sk-your-openai-key"
|
||||
|
||||
postgresql:
|
||||
external:
|
||||
host: "my-postgres.example.com"
|
||||
password: "mypassword"
|
||||
```
|
||||
|
||||
Then install:
|
||||
|
||||
```bash
|
||||
helm install hindsight ./helm/hindsight -n hindsight --create-namespace -f values-override.yaml
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Key Values
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `version` | Default image tag for all components | `0.1.0` |
|
||||
| `api.enabled` | Enable the API component | `true` |
|
||||
| `api.image.repository` | API image repository | `hindsight/api` |
|
||||
| `api.image.tag` | API image tag (defaults to `version`) | - |
|
||||
| `api.service.port` | API service port | `8888` |
|
||||
| `controlPlane.enabled` | Enable the control plane | `true` |
|
||||
| `controlPlane.image.repository` | Control plane image repository | `hindsight/control-plane` |
|
||||
| `controlPlane.image.tag` | Control plane image tag (defaults to `version`) | - |
|
||||
| `controlPlane.service.port` | Control plane service port | `3000` |
|
||||
| `postgresql.enabled` | Deploy PostgreSQL as subchart | `true` |
|
||||
| `postgresql.external.host` | External PostgreSQL host | `postgresql` |
|
||||
| `postgresql.external.port` | External PostgreSQL port | `5432` |
|
||||
| `postgresql.external.database` | Database name | `hindsight` |
|
||||
| `postgresql.external.username` | Database username | `hindsight` |
|
||||
| `ingress.enabled` | Enable ingress | `false` |
|
||||
| `autoscaling.enabled` | Enable HPA | `false` |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
All environment variables in `api.env` and `controlPlane.env` are automatically added to the respective pods. Sensitive values should go in `api.secrets` or `controlPlane.secrets`.
|
||||
|
||||
```yaml
|
||||
api:
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: "openai"
|
||||
HINDSIGHT_API_LLM_MODEL: "gpt-4"
|
||||
secrets:
|
||||
HINDSIGHT_API_LLM_API_KEY: "your-api-key"
|
||||
HINDSIGHT_API_LLM_BASE_URL: "https://api.openai.com/v1"
|
||||
|
||||
controlPlane:
|
||||
env:
|
||||
NODE_ENV: "production"
|
||||
secrets: {}
|
||||
```
|
||||
|
||||
### External Database
|
||||
|
||||
To connect to an external PostgreSQL database:
|
||||
|
||||
```yaml
|
||||
postgresql:
|
||||
enabled: false
|
||||
external:
|
||||
host: "my-postgres.example.com"
|
||||
port: 5432
|
||||
database: "hindsight"
|
||||
username: "hindsight"
|
||||
password: "your-password"
|
||||
```
|
||||
|
||||
### Ingress
|
||||
|
||||
To expose the services via ingress:
|
||||
|
||||
```yaml
|
||||
ingress:
|
||||
enabled: true
|
||||
className: "nginx"
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
hosts:
|
||||
- host: hindsight.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
service: controlPlane
|
||||
- path: /api
|
||||
pathType: Prefix
|
||||
service: api
|
||||
tls:
|
||||
- secretName: hindsight-tls
|
||||
hosts:
|
||||
- hindsight.example.com
|
||||
```
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
helm upgrade hindsight ./helm/hindsight -n hindsight
|
||||
```
|
||||
|
||||
## Uninstalling
|
||||
|
||||
```bash
|
||||
helm uninstall hindsight -n hindsight
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
The chart deploys:
|
||||
|
||||
- **API**: The main Hindsight API server for memory operations
|
||||
- **Control Plane**: Web UI for managing agents and viewing memories
|
||||
|
||||
## Development
|
||||
|
||||
### Lint the chart
|
||||
|
||||
```bash
|
||||
helm lint ./helm/hindsight
|
||||
```
|
||||
|
||||
### Template locally
|
||||
|
||||
```bash
|
||||
helm template hindsight ./helm/hindsight --debug
|
||||
```
|
||||
|
||||
### Dry run installation
|
||||
|
||||
```bash
|
||||
helm install hindsight ./helm/hindsight --dry-run --debug
|
||||
```
|
||||
@@ -1,71 +1,2 @@
|
||||
Thank you for installing {{ .Chart.Name }}!
|
||||
|
||||
Your release is named {{ .Release.Name }}.
|
||||
|
||||
To learn more about the release, try:
|
||||
|
||||
$ helm status {{ .Release.Name }}
|
||||
$ helm get all {{ .Release.Name }}
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
|
||||
The application is accessible via the following URL(s):
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- http{{ if $.Values.ingress.tls }}s{{ end }}://{{ .host }}
|
||||
{{- end }}
|
||||
|
||||
{{- else }}
|
||||
|
||||
1. Get the Control Plane URL by running these commands:
|
||||
{{- if contains "NodePort" .Values.controlPlane.service.type }}
|
||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "hindsight.fullname" . }}-control-plane)
|
||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||
echo "Control Plane URL: http://$NODE_IP:$NODE_PORT"
|
||||
{{- else if contains "LoadBalancer" .Values.controlPlane.service.type }}
|
||||
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||
You can watch the status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "hindsight.fullname" . }}-control-plane'
|
||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "hindsight.fullname" . }}-control-plane --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||
echo "Control Plane URL: http://$SERVICE_IP:{{ .Values.controlPlane.service.port }}"
|
||||
{{- else if contains "ClusterIP" .Values.controlPlane.service.type }}
|
||||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/component=control-plane,app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||
echo "Control Plane URL: http://127.0.0.1:3000"
|
||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 3000:$CONTAINER_PORT
|
||||
{{- end }}
|
||||
|
||||
2. Get the API URL by running these commands:
|
||||
{{- if contains "NodePort" .Values.api.service.type }}
|
||||
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "hindsight.fullname" . }}-api)
|
||||
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
|
||||
echo "API URL: http://$NODE_IP:$NODE_PORT"
|
||||
{{- else if contains "LoadBalancer" .Values.api.service.type }}
|
||||
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
|
||||
You can watch the status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "hindsight.fullname" . }}-api'
|
||||
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "hindsight.fullname" . }}-api --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
|
||||
echo "API URL: http://$SERVICE_IP:{{ .Values.api.service.port }}"
|
||||
{{- else if contains "ClusterIP" .Values.api.service.type }}
|
||||
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/component=api,app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
|
||||
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
|
||||
echo "API URL: http://127.0.0.1:8888"
|
||||
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8888:$CONTAINER_PORT
|
||||
{{- end }}
|
||||
|
||||
{{- end }}
|
||||
|
||||
{{- if not .Values.postgresql.enabled }}
|
||||
|
||||
NOTE: You are using an external PostgreSQL database.
|
||||
Please ensure that:
|
||||
1. The database is accessible from the cluster
|
||||
2. The pgvector extension is enabled
|
||||
|
||||
Database migrations run automatically when the API service starts.
|
||||
|
||||
If you want to pre-initialize the database before deploying (optional):
|
||||
kubectl run --namespace {{ .Release.Namespace }} hindsight-init --rm -it --restart=Never \
|
||||
--image={{ .Values.api.image.repository }}:{{ .Values.api.image.tag }} \
|
||||
--env="DATABASE_URL={{ include "hindsight.databaseUrl" . }}" \
|
||||
-- python -c "from hindsight.migrations import run_migrations; run_migrations()"
|
||||
{{- end }}
|
||||
|
||||
For more information, visit: https://github.com/yourusername/hindsight
|
||||
Hindsight installed. Access the control plane:
|
||||
kubectl port-forward -n {{ .Release.Namespace }} svc/{{ include "hindsight.fullname" . }}-control-plane 3000:3000
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "memora.name" -}}
|
||||
{{- define "hindsight.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
*/}}
|
||||
{{- define "memora.fullname" -}}
|
||||
{{- define "hindsight.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
@@ -24,16 +24,16 @@ Create a default fully qualified app name.
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "memora.chart" -}}
|
||||
{{- define "hindsight.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "memora.labels" -}}
|
||||
helm.sh/chart: {{ include "memora.chart" . }}
|
||||
{{ include "memora.selectorLabels" . }}
|
||||
{{- define "hindsight.labels" -}}
|
||||
helm.sh/chart: {{ include "hindsight.chart" . }}
|
||||
{{ include "hindsight.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
@@ -43,49 +43,49 @@ app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{/*
|
||||
Selector labels
|
||||
*/}}
|
||||
{{- define "memora.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "memora.name" . }}
|
||||
{{- define "hindsight.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "hindsight.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
API labels
|
||||
*/}}
|
||||
{{- define "memora.api.labels" -}}
|
||||
{{ include "memora.labels" . }}
|
||||
{{- define "hindsight.api.labels" -}}
|
||||
{{ include "hindsight.labels" . }}
|
||||
app.kubernetes.io/component: api
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
API selector labels
|
||||
*/}}
|
||||
{{- define "memora.api.selectorLabels" -}}
|
||||
{{ include "memora.selectorLabels" . }}
|
||||
{{- define "hindsight.api.selectorLabels" -}}
|
||||
{{ include "hindsight.selectorLabels" . }}
|
||||
app.kubernetes.io/component: api
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Control plane labels
|
||||
*/}}
|
||||
{{- define "memora.controlPlane.labels" -}}
|
||||
{{ include "memora.labels" . }}
|
||||
{{- define "hindsight.controlPlane.labels" -}}
|
||||
{{ include "hindsight.labels" . }}
|
||||
app.kubernetes.io/component: control-plane
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Control plane selector labels
|
||||
*/}}
|
||||
{{- define "memora.controlPlane.selectorLabels" -}}
|
||||
{{ include "memora.selectorLabels" . }}
|
||||
{{- define "hindsight.controlPlane.selectorLabels" -}}
|
||||
{{ include "hindsight.selectorLabels" . }}
|
||||
app.kubernetes.io/component: control-plane
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
{{- define "memora.serviceAccountName" -}}
|
||||
{{- define "hindsight.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "memora.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- default (include "hindsight.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
@@ -94,11 +94,11 @@ Create the name of the service account to use
|
||||
{{/*
|
||||
Generate database URL
|
||||
*/}}
|
||||
{{- define "memora.databaseUrl" -}}
|
||||
{{- define "hindsight.databaseUrl" -}}
|
||||
{{- if .Values.databaseUrl }}
|
||||
{{- .Values.databaseUrl }}
|
||||
{{- else if .Values.postgresql.enabled }}
|
||||
{{- printf "postgresql://%s:%s@%s-postgresql:%d/%s" .Values.postgresql.auth.username .Values.postgresql.auth.password (include "memora.fullname" .) (.Values.postgresql.primary.service.port | int) .Values.postgresql.auth.database }}
|
||||
{{- printf "postgresql://%s:%s@%s-postgresql:%d/%s" .Values.postgresql.auth.username .Values.postgresql.auth.password (include "hindsight.fullname" .) (.Values.postgresql.service.port | int) .Values.postgresql.auth.database }}
|
||||
{{- else }}
|
||||
{{- printf "postgresql://%s:$(POSTGRES_PASSWORD)@%s:%d/%s" .Values.postgresql.external.username .Values.postgresql.external.host (.Values.postgresql.external.port | int) .Values.postgresql.external.database }}
|
||||
{{- end }}
|
||||
@@ -107,6 +107,6 @@ Generate database URL
|
||||
{{/*
|
||||
API URL for control plane
|
||||
*/}}
|
||||
{{- define "memora.apiUrl" -}}
|
||||
{{- printf "http://%s-api:%d" (include "memora.fullname" .) (.Values.api.service.port | int) }}
|
||||
{{- define "hindsight.apiUrl" -}}
|
||||
{{- printf "http://%s-api:%d" (include "hindsight.fullname" .) (.Values.api.service.port | int) }}
|
||||
{{- end }}
|
||||
|
||||
@@ -15,7 +15,6 @@ spec:
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
@@ -32,7 +31,7 @@ spec:
|
||||
- name: api
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 10 }}
|
||||
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}"
|
||||
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag | default .Values.version }}"
|
||||
imagePullPolicy: {{ .Values.api.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
@@ -48,29 +47,16 @@ spec:
|
||||
name: {{ include "hindsight.fullname" . }}-secret
|
||||
key: postgres-password
|
||||
{{- end }}
|
||||
- name: HINDSIGHT_API_LLM_PROVIDER
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "hindsight.fullname" . }}-config
|
||||
key: llm-provider
|
||||
- name: HINDSIGHT_API_LLM_MODEL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "hindsight.fullname" . }}-config
|
||||
key: llm-model
|
||||
{{- if and .Values.api.secrets (hasKey .Values.api.secrets "HINDSIGHT_API_LLM_API_KEY") }}
|
||||
- name: HINDSIGHT_API_LLM_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "hindsight.fullname" . }}-secret
|
||||
key: llm-api-key
|
||||
{{- range $key, $value := .Values.api.env }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- if and .Values.api.secrets (hasKey .Values.api.secrets "HINDSIGHT_API_LLM_BASE_URL") }}
|
||||
- name: HINDSIGHT_API_LLM_BASE_URL
|
||||
{{- range $key, $value := .Values.api.secrets }}
|
||||
- name: {{ $key }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "hindsight.fullname" . }}-secret
|
||||
key: llm-base-url
|
||||
name: {{ include "hindsight.fullname" $ }}-secret
|
||||
key: {{ $key }}
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
{{- toYaml .Values.api.livenessProbe | nindent 10 }}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "hindsight.fullname" . }}-config
|
||||
labels:
|
||||
{{- include "hindsight.labels" . | nindent 4 }}
|
||||
data:
|
||||
# API configuration
|
||||
llm-provider: {{ .Values.api.env.HINDSIGHT_API_LLM_PROVIDER | quote }}
|
||||
llm-model: {{ .Values.api.env.HINDSIGHT_API_LLM_MODEL | quote }}
|
||||
|
||||
# Control plane configuration
|
||||
node-env: {{ .Values.controlPlane.env.NODE_ENV | quote }}
|
||||
hostname: {{ .Values.controlPlane.env.HINDSIGHT_CP_HOSTNAME | quote }}
|
||||
control-plane-port: {{ .Values.controlPlane.env.HINDSIGHT_CP_PORT | quote }}
|
||||
@@ -15,7 +15,7 @@ spec:
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
|
||||
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -31,30 +31,26 @@ spec:
|
||||
- name: control-plane
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 10 }}
|
||||
image: "{{ .Values.controlPlane.image.repository }}:{{ .Values.controlPlane.image.tag }}"
|
||||
image: "{{ .Values.controlPlane.image.repository }}:{{ .Values.controlPlane.image.tag | default .Values.version }}"
|
||||
imagePullPolicy: {{ .Values.controlPlane.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.controlPlane.service.targetPort }}
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "hindsight.fullname" . }}-config
|
||||
key: node-env
|
||||
- name: HINDSIGHT_CP_HOSTNAME
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "hindsight.fullname" . }}-config
|
||||
key: hostname
|
||||
- name: HINDSIGHT_CP_PORT
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: {{ include "hindsight.fullname" . }}-config
|
||||
key: control-plane-port
|
||||
- name: HINDSIGHT_CP_DATAPLANE_API_URL
|
||||
value: {{ include "hindsight.apiUrl" . | quote }}
|
||||
{{- range $key, $value := .Values.controlPlane.env }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- range $key, $value := .Values.controlPlane.secrets }}
|
||||
- name: {{ $key }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "hindsight.fullname" $ }}-secret
|
||||
key: {{ $key }}
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
{{- toYaml .Values.controlPlane.livenessProbe | nindent 10 }}
|
||||
readinessProbe:
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "hindsight.fullname" . }}-postgresql
|
||||
labels:
|
||||
{{- include "hindsight.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: postgresql
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: {{ .Values.postgresql.service.port }}
|
||||
targetPort: postgresql
|
||||
protocol: TCP
|
||||
name: postgresql
|
||||
selector:
|
||||
{{- include "hindsight.selectorLabels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: postgresql
|
||||
{{- end }}
|
||||
@@ -0,0 +1,85 @@
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ include "hindsight.fullname" . }}-postgresql
|
||||
labels:
|
||||
{{- include "hindsight.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: postgresql
|
||||
spec:
|
||||
serviceName: {{ include "hindsight.fullname" . }}-postgresql
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "hindsight.selectorLabels" . | nindent 6 }}
|
||||
app.kubernetes.io/component: postgresql
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "hindsight.selectorLabels" . | nindent 8 }}
|
||||
app.kubernetes.io/component: postgresql
|
||||
spec:
|
||||
containers:
|
||||
- name: postgresql
|
||||
image: "{{ .Values.postgresql.image.repository }}:{{ .Values.postgresql.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.postgresql.image.pullPolicy }}
|
||||
ports:
|
||||
- name: postgresql
|
||||
containerPort: 5432
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
value: {{ .Values.postgresql.auth.username | quote }}
|
||||
- name: POSTGRES_PASSWORD
|
||||
value: {{ .Values.postgresql.auth.password | quote }}
|
||||
- name: POSTGRES_DB
|
||||
value: {{ .Values.postgresql.auth.database | quote }}
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/data/pgdata
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- {{ .Values.postgresql.auth.username }}
|
||||
- -d
|
||||
- {{ .Values.postgresql.auth.database }}
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- pg_isready
|
||||
- -U
|
||||
- {{ .Values.postgresql.auth.username }}
|
||||
- -d
|
||||
- {{ .Values.postgresql.auth.database }}
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
resources:
|
||||
{{- toYaml .Values.postgresql.resources | nindent 10 }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
{{- if .Values.postgresql.persistence.enabled }}
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
{{- if .Values.postgresql.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.postgresql.persistence.storageClass | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.postgresql.persistence.size }}
|
||||
{{- else }}
|
||||
volumes:
|
||||
- name: data
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -6,14 +6,12 @@ metadata:
|
||||
{{- include "hindsight.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
data:
|
||||
{{- if and .Values.api.secrets (hasKey .Values.api.secrets "MEMORY_LLM_API_KEY") }}
|
||||
llm-api-key: {{ .Values.api.secrets.MEMORY_LLM_API_KEY | b64enc | quote }}
|
||||
{{- range $key, $value := .Values.api.secrets }}
|
||||
{{ $key }}: {{ $value | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if and .Values.api.secrets (hasKey .Values.api.secrets "MEMORY_LLM_BASE_URL") }}
|
||||
llm-base-url: {{ .Values.api.secrets.MEMORY_LLM_BASE_URL | b64enc | quote }}
|
||||
{{- range $key, $value := .Values.controlPlane.secrets }}
|
||||
{{ $key }}: {{ $value | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if not .Values.postgresql.enabled }}
|
||||
{{- if .Values.postgresql.external.password }}
|
||||
{{- if and (not .Values.postgresql.enabled) .Values.postgresql.external.password }}
|
||||
postgres-password: {{ .Values.postgresql.external.password | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
+41
-18
@@ -1,5 +1,8 @@
|
||||
# Default values for hindsight
|
||||
|
||||
# Chart version - use this to set a consistent image tag across all components
|
||||
version: "0.1.1"
|
||||
|
||||
# Global settings
|
||||
replicaCount: 1
|
||||
|
||||
@@ -8,9 +11,9 @@ api:
|
||||
enabled: true
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: hindsight/api
|
||||
repository: ghcr.io/vectorize-io/hindsight-api
|
||||
pullPolicy: IfNotPresent
|
||||
tag: "latest"
|
||||
# tag defaults to .Values.version if not specified
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
@@ -29,7 +32,7 @@ api:
|
||||
# Liveness and readiness probes
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
path: /health
|
||||
port: 8888
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
@@ -38,7 +41,7 @@ api:
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
path: /health
|
||||
port: 8888
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
@@ -47,7 +50,7 @@ api:
|
||||
|
||||
# Environment variables
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: "groq"
|
||||
#HINDSIGHT_API_LLM_PROVIDER: "groq"
|
||||
HINDSIGHT_API_LLM_MODEL: "openai/gpt-oss-120b"
|
||||
|
||||
# Secret environment variables
|
||||
@@ -60,9 +63,9 @@ controlPlane:
|
||||
enabled: true
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: hindsight/hindsight-control-plane
|
||||
repository: ghcr.io/vectorize-io/hindsight-control-plane
|
||||
pullPolicy: IfNotPresent
|
||||
tag: "latest"
|
||||
# tag defaults to .Values.version if not specified
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
@@ -78,10 +81,9 @@ controlPlane:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
|
||||
# Liveness and readiness probes
|
||||
# Liveness and readiness probes (TCP check)
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
tcpSocket:
|
||||
port: 3000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
@@ -89,8 +91,7 @@ controlPlane:
|
||||
failureThreshold: 3
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /
|
||||
tcpSocket:
|
||||
port: 3000
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
@@ -106,21 +107,43 @@ controlPlane:
|
||||
# PostgreSQL configuration
|
||||
postgresql:
|
||||
# Set to true to deploy PostgreSQL as part of this chart
|
||||
enabled: false
|
||||
enabled: true
|
||||
|
||||
image:
|
||||
repository: ankane/pgvector
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
auth:
|
||||
username: "hindsight"
|
||||
password: "hindsight"
|
||||
database: "hindsight"
|
||||
|
||||
service:
|
||||
port: 5432
|
||||
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 8Gi
|
||||
# storageClass: ""
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1Gi
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 256Mi
|
||||
|
||||
# External PostgreSQL connection details
|
||||
# If postgresql.enabled is false, provide external database details
|
||||
# Only used if postgresql.enabled is false
|
||||
external:
|
||||
host: "postgresql"
|
||||
port: 5432
|
||||
database: "hindsight"
|
||||
username: "hindsight"
|
||||
# Password should be provided via secret
|
||||
# password: ""
|
||||
|
||||
# Database URL (auto-generated from postgresql config if not provided)
|
||||
# databaseUrl: "postgresql://user:pass@host:5432/database"
|
||||
|
||||
# Ingress configuration
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
+137
-1
@@ -1 +1,137 @@
|
||||
# Memory
|
||||
# Hindsight API
|
||||
|
||||
**Memory System for AI Agents** — Temporal + Semantic + Entity Memory Architecture using PostgreSQL with pgvector.
|
||||
|
||||
Hindsight gives AI agents persistent memory that works like human memory: it stores facts, tracks entities and relationships, handles temporal reasoning ("what happened last spring?"), and forms opinions based on configurable disposition traits.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install hindsight-api
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Run the Server
|
||||
|
||||
```bash
|
||||
# Set your LLM provider
|
||||
export HINDSIGHT_API_LLM_PROVIDER=openai
|
||||
export HINDSIGHT_API_LLM_API_KEY=sk-xxxxxxxxxxxx
|
||||
|
||||
# Start the server (uses embedded PostgreSQL by default)
|
||||
hindsight-api
|
||||
```
|
||||
|
||||
The server starts at http://localhost:8888 with:
|
||||
- REST API for memory operations
|
||||
- MCP server at `/mcp` for tool-use integration
|
||||
|
||||
### Use the Python API
|
||||
|
||||
```python
|
||||
from hindsight_api import MemoryEngine
|
||||
|
||||
# Create and initialize the memory engine
|
||||
memory = MemoryEngine()
|
||||
await memory.initialize()
|
||||
|
||||
# Create a memory bank for your agent
|
||||
bank = await memory.create_memory_bank(
|
||||
name="my-assistant",
|
||||
background="A helpful coding assistant"
|
||||
)
|
||||
|
||||
# Store a memory
|
||||
await memory.retain(
|
||||
memory_bank_id=bank.id,
|
||||
content="The user prefers Python for data science projects"
|
||||
)
|
||||
|
||||
# Recall memories
|
||||
results = await memory.recall(
|
||||
memory_bank_id=bank.id,
|
||||
query="What programming language does the user prefer?"
|
||||
)
|
||||
|
||||
# Reflect with reasoning
|
||||
response = await memory.reflect(
|
||||
memory_bank_id=bank.id,
|
||||
query="Should I recommend Python or R for this ML project?"
|
||||
)
|
||||
```
|
||||
|
||||
## CLI Options
|
||||
|
||||
```bash
|
||||
hindsight-api --help
|
||||
|
||||
# Common options
|
||||
hindsight-api --port 9000 # Custom port (default: 8888)
|
||||
hindsight-api --host 127.0.0.1 # Bind to localhost only
|
||||
hindsight-api --workers 4 # Multiple worker processes
|
||||
hindsight-api --log-level debug # Verbose logging
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure via environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_DATABASE_URL` | PostgreSQL connection string | `pg0` (embedded) |
|
||||
| `HINDSIGHT_API_LLM_PROVIDER` | `openai`, `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
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
"""initial_schema
|
||||
|
||||
Revision ID: 5a366d414dce
|
||||
Revises:
|
||||
Create Date: 2025-11-27 11:54:19.228030
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from pgvector.sqlalchemy import Vector
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '5a366d414dce'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema - create all tables from scratch."""
|
||||
|
||||
# Enable required extensions
|
||||
op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"')
|
||||
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'))
|
||||
)
|
||||
|
||||
# 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'))
|
||||
)
|
||||
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('uuid_generate_v4()'), 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'])
|
||||
|
||||
# Create entities table
|
||||
op.create_table(
|
||||
'entities',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('uuid_generate_v4()'), 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'])
|
||||
# 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))')
|
||||
|
||||
# Create memory_units table
|
||||
op.create_table(
|
||||
'memory_units',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('uuid_generate_v4()'), 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'
|
||||
)
|
||||
)
|
||||
|
||||
# Add search_vector column for full-text search
|
||||
op.execute("""
|
||||
ALTER TABLE memory_units
|
||||
ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('english', COALESCE(text, '') || ' ' || COALESCE(context, ''))) STORED
|
||||
""")
|
||||
|
||||
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("""
|
||||
CREATE INDEX idx_memory_units_text_search ON memory_units
|
||||
USING gin(search_vector)
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE MATERIALIZED VIEW memory_units_bm25 AS
|
||||
SELECT
|
||||
id,
|
||||
bank_id,
|
||||
text,
|
||||
to_tsvector('english', text) AS text_vector,
|
||||
log(1.0 + length(text)::float / (SELECT avg(length(text)) FROM memory_units)) AS doc_length_factor
|
||||
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')
|
||||
|
||||
# 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')
|
||||
)
|
||||
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')
|
||||
)
|
||||
# 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'])
|
||||
|
||||
# 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'))
|
||||
)
|
||||
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_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')
|
||||
|
||||
# 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_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.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_table('banks')
|
||||
|
||||
# Drop extensions (optional - comment out if you want to keep them)
|
||||
# op.execute('DROP EXTENSION IF EXISTS vector')
|
||||
# op.execute('DROP EXTENSION IF EXISTS "uuid-ossp"')
|
||||
@@ -1,70 +0,0 @@
|
||||
"""add_chunks_table
|
||||
|
||||
Revision ID: b7c4d8e9f1a2
|
||||
Revises: 5a366d414dce
|
||||
Create Date: 2025-11-28 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b7c4d8e9f1a2'
|
||||
down_revision: Union[str, Sequence[str], None] = '5a366d414dce'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add chunks table and link memory_units to chunks."""
|
||||
|
||||
# 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'))
|
||||
)
|
||||
|
||||
# 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'])
|
||||
|
||||
# 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))
|
||||
|
||||
# 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'
|
||||
)
|
||||
|
||||
# Add index on chunk_id for efficient lookups
|
||||
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')
|
||||
|
||||
# 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')
|
||||
@@ -3,24 +3,31 @@ Memory System for AI Agents.
|
||||
|
||||
Temporal + Semantic Memory Architecture using PostgreSQL with pgvector.
|
||||
"""
|
||||
|
||||
from .config import HindsightConfig, get_config
|
||||
from .engine.cross_encoder import CrossEncoderModel, LocalSTCrossEncoder, RemoteTEICrossEncoder
|
||||
from .engine.embeddings import Embeddings, LocalSTEmbeddings, RemoteTEIEmbeddings
|
||||
from .engine.llm_wrapper import LLMConfig
|
||||
from .engine.memory_engine import MemoryEngine
|
||||
from .engine.search.trace import (
|
||||
SearchTrace,
|
||||
QueryInfo,
|
||||
EntryPoint,
|
||||
NodeVisit,
|
||||
WeightComponents,
|
||||
LinkInfo,
|
||||
NodeVisit,
|
||||
PruningDecision,
|
||||
SearchSummary,
|
||||
QueryInfo,
|
||||
SearchPhaseMetrics,
|
||||
SearchSummary,
|
||||
SearchTrace,
|
||||
WeightComponents,
|
||||
)
|
||||
from .engine.search.tracer import SearchTracer
|
||||
from .engine.embeddings import Embeddings, SentenceTransformersEmbeddings
|
||||
from .engine.llm_wrapper import LLMConfig
|
||||
from .models import RequestContext
|
||||
|
||||
__all__ = [
|
||||
"MemoryEngine",
|
||||
"RequestContext",
|
||||
"HindsightConfig",
|
||||
"get_config",
|
||||
"SearchTrace",
|
||||
"SearchTracer",
|
||||
"QueryInfo",
|
||||
@@ -32,7 +39,11 @@ __all__ = [
|
||||
"SearchSummary",
|
||||
"SearchPhaseMetrics",
|
||||
"Embeddings",
|
||||
"SentenceTransformersEmbeddings",
|
||||
"LocalSTEmbeddings",
|
||||
"RemoteTEIEmbeddings",
|
||||
"CrossEncoderModel",
|
||||
"LocalSTCrossEncoder",
|
||||
"RemoteTEICrossEncoder",
|
||||
"LLMConfig",
|
||||
]
|
||||
__version__ = "0.1.0"
|
||||
|
||||
@@ -2,20 +2,19 @@
|
||||
Alembic environment configuration for SQLAlchemy with pgvector.
|
||||
Uses synchronous psycopg2 driver for migrations to avoid pgbouncer issues.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import pool, engine_from_config
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from alembic import context
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
# Import your models here
|
||||
from hindsight_api.models import Base
|
||||
|
||||
|
||||
# Load environment variables based on HINDSIGHT_API_DATABASE_URL env var or default to local
|
||||
def load_env():
|
||||
"""Load environment variables from .env"""
|
||||
@@ -30,6 +29,7 @@ def load_env():
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
|
||||
|
||||
load_env()
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
@@ -105,23 +105,60 @@ def run_migrations_offline() -> None:
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode with synchronous engine."""
|
||||
from sqlalchemy import event, text
|
||||
|
||||
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.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
# Add event listener to ensure connection is in read-write mode
|
||||
# This is needed for Supabase which may start connections in read-only mode
|
||||
@event.listens_for(connectable, "connect")
|
||||
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:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata
|
||||
)
|
||||
# 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)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
# Explicit commit to ensure changes are persisted (especially for Supabase)
|
||||
connection.commit()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
@@ -0,0 +1,360 @@
|
||||
"""initial_schema
|
||||
|
||||
Revision ID: 5a366d414dce
|
||||
Revises:
|
||||
Create Date: 2025-11-27 11:54:19.228030
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema - create all tables from scratch."""
|
||||
|
||||
# Enable required extensions
|
||||
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")),
|
||||
)
|
||||
|
||||
# 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")),
|
||||
)
|
||||
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"
|
||||
),
|
||||
)
|
||||
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")),
|
||||
)
|
||||
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))")
|
||||
|
||||
# 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",
|
||||
),
|
||||
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",
|
||||
),
|
||||
)
|
||||
|
||||
# Add search_vector column for full-text search
|
||||
op.execute("""
|
||||
ALTER TABLE memory_units
|
||||
ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('english', COALESCE(text, '') || ' ' || COALESCE(context, ''))) STORED
|
||||
""")
|
||||
|
||||
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("""
|
||||
CREATE INDEX idx_memory_units_text_search ON memory_units
|
||||
USING gin(search_vector)
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE MATERIALIZED VIEW memory_units_bm25 AS
|
||||
SELECT
|
||||
id,
|
||||
bank_id,
|
||||
text,
|
||||
to_tsvector('english', text) AS text_vector,
|
||||
log(1.0 + length(text)::float / (SELECT avg(length(text)) FROM memory_units)) AS doc_length_factor
|
||||
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")
|
||||
|
||||
# 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"),
|
||||
)
|
||||
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"),
|
||||
)
|
||||
# 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"])
|
||||
|
||||
# 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")),
|
||||
)
|
||||
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_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")
|
||||
|
||||
# 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_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.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_table("banks")
|
||||
|
||||
# Drop extensions (optional - comment out if you want to keep them)
|
||||
# op.execute('DROP EXTENSION IF EXISTS vector')
|
||||
# op.execute('DROP EXTENSION IF EXISTS "uuid-ossp"')
|
||||
@@ -0,0 +1,70 @@
|
||||
"""add_chunks_table
|
||||
|
||||
Revision ID: b7c4d8e9f1a2
|
||||
Revises: 5a366d414dce
|
||||
Create Date: 2025-11-28 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b7c4d8e9f1a2"
|
||||
down_revision: str | Sequence[str] | None = "5a366d414dce"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add chunks table and link memory_units to chunks."""
|
||||
|
||||
# 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")),
|
||||
)
|
||||
|
||||
# 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"])
|
||||
|
||||
# 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))
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
# Add index on chunk_id for efficient lookups
|
||||
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")
|
||||
|
||||
# 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")
|
||||
+11
-11
@@ -5,35 +5,35 @@ Revises: b7c4d8e9f1a2
|
||||
Create Date: 2025-12-02 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c8e5f2a3b4d1'
|
||||
down_revision: Union[str, Sequence[str], None] = 'b7c4d8e9f1a2'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
revision: str = "c8e5f2a3b4d1"
|
||||
down_revision: str | Sequence[str] | None = "b7c4d8e9f1a2"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add retain_params JSONB column to documents table."""
|
||||
|
||||
# Add retain_params column to store parameters passed during retain
|
||||
op.add_column('documents', sa.Column('retain_params', postgresql.JSONB(), nullable=True))
|
||||
op.add_column("documents", sa.Column("retain_params", postgresql.JSONB(), nullable=True))
|
||||
|
||||
# Add index for efficient queries on retain_params
|
||||
op.create_index('idx_documents_retain_params', 'documents', ['retain_params'], postgresql_using='gin')
|
||||
op.create_index("idx_documents_retain_params", "documents", ["retain_params"], postgresql_using="gin")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove retain_params column from documents table."""
|
||||
|
||||
# Drop index
|
||||
op.drop_index('idx_documents_retain_params', table_name='documents')
|
||||
op.drop_index("idx_documents_retain_params", table_name="documents")
|
||||
|
||||
# Drop column
|
||||
op.drop_column('documents', 'retain_params')
|
||||
op.drop_column("documents", "retain_params")
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
"""Rename fact_type 'bank' to 'experience'
|
||||
|
||||
Revision ID: d9f6a3b4c5e2
|
||||
Revises: c8e5f2a3b4d1
|
||||
Create Date: 2024-12-04 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
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")
|
||||
|
||||
# Update existing 'bank' values to 'experience'
|
||||
op.execute(f"UPDATE {schema}memory_units SET fact_type = 'experience' WHERE fact_type = 'bank'")
|
||||
# Also update any 'interactions' values (in case of partial migration)
|
||||
op.execute(f"UPDATE {schema}memory_units SET fact_type = 'experience' WHERE fact_type = 'interactions'")
|
||||
|
||||
# Create new check constraint with 'experience' instead of 'bank'
|
||||
op.create_check_constraint(
|
||||
"memory_units_fact_type_check", "memory_units", "fact_type IN ('world', 'experience', 'opinion', 'observation')"
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Drop new check constraint FIRST
|
||||
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'")
|
||||
|
||||
# Recreate old check constraint
|
||||
op.create_check_constraint(
|
||||
"memory_units_fact_type_check", "memory_units", "fact_type IN ('world', 'bank', 'opinion', 'observation')"
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""disposition_to_3_traits
|
||||
|
||||
Revision ID: e0a1b2c3d4e5
|
||||
Revises: rename_personality
|
||||
Create Date: 2024-12-08
|
||||
|
||||
Migrate disposition traits from Big Five (openness, conscientiousness, extraversion,
|
||||
agreeableness, neuroticism, bias_strength with 0-1 float values) to the new 3-trait
|
||||
system (skepticism, literalism, empathy with 1-5 integer values).
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
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"
|
||||
|
||||
|
||||
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:
|
||||
# - skepticism: derived from inverse of agreeableness (skeptical people are less agreeable)
|
||||
# - 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
|
||||
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
|
||||
""")
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
""")
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""rename_personality_to_disposition
|
||||
|
||||
Revision ID: rename_personality
|
||||
Revises: d9f6a3b4c5e2
|
||||
Create Date: 2024-12-04
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import context, op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "rename_personality"
|
||||
down_revision: str | Sequence[str] | None = "d9f6a3b4c5e2"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_target_schema() -> str:
|
||||
"""Get the target schema name (tenant schema or 'public')."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return schema if schema else "public"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Rename personality column to disposition in banks table (if it exists)."""
|
||||
conn = op.get_bind()
|
||||
target_schema = _get_target_schema()
|
||||
|
||||
# Check if 'personality' column exists (old database)
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'personality'
|
||||
"""),
|
||||
{"schema": target_schema},
|
||||
)
|
||||
has_personality = result.fetchone() is not None
|
||||
|
||||
# Check if 'disposition' column exists (new database)
|
||||
result = conn.execute(
|
||||
sa.text("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'disposition'
|
||||
"""),
|
||||
{"schema": target_schema},
|
||||
)
|
||||
has_disposition = result.fetchone() is not None
|
||||
|
||||
if has_personality and not has_disposition:
|
||||
# Old database: rename personality -> disposition
|
||||
op.alter_column("banks", "personality", new_column_name="disposition")
|
||||
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,
|
||||
),
|
||||
)
|
||||
# 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("""
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'disposition'
|
||||
"""),
|
||||
{"schema": target_schema},
|
||||
)
|
||||
if result.fetchone():
|
||||
op.alter_column("banks", "disposition", new_column_name="personality")
|
||||
@@ -3,8 +3,10 @@ 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
|
||||
@@ -17,18 +19,17 @@ def create_app(
|
||||
http_api_enabled: bool = True,
|
||||
mcp_api_enabled: bool = False,
|
||||
mcp_mount_path: str = "/mcp",
|
||||
run_migrations: bool = True,
|
||||
initialize_memory: bool = True
|
||||
initialize_memory: bool = True,
|
||||
) -> FastAPI:
|
||||
"""
|
||||
Create and configure the unified Hindsight API application.
|
||||
|
||||
Args:
|
||||
memory: MemoryEngine instance (already initialized with required parameters)
|
||||
memory: MemoryEngine instance (already initialized with required parameters).
|
||||
Migrations are controlled by the MemoryEngine's run_migrations parameter.
|
||||
http_api_enabled: Whether to enable HTTP REST API endpoints (default: True)
|
||||
mcp_api_enabled: Whether to enable MCP server (default: False)
|
||||
mcp_mount_path: Path to mount MCP server (default: /mcp)
|
||||
run_migrations: Whether to run database migrations on startup (default: True)
|
||||
initialize_memory: Whether to initialize memory system on startup (default: True)
|
||||
|
||||
Returns:
|
||||
@@ -48,11 +49,8 @@ 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,
|
||||
run_migrations=run_migrations,
|
||||
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
|
||||
@@ -62,14 +60,13 @@ def create_app(
|
||||
# Mount MCP server if enabled
|
||||
if mcp_api_enabled:
|
||||
try:
|
||||
from .mcp import create_mcp_server
|
||||
from .mcp import create_mcp_app
|
||||
|
||||
# Create MCP server with shared memory instance
|
||||
mcp_server = create_mcp_server(memory=memory)
|
||||
|
||||
# Mount at specified path using http_app (modern non-SSE alternative)
|
||||
app.mount(mcp_mount_path, mcp_server.http_app())
|
||||
logger.info(f"MCP server enabled at {mcp_mount_path}")
|
||||
# Create MCP app with dynamic bank_id support
|
||||
# Supports: /mcp/{bank_id}/sse (bank-specific SSE endpoint)
|
||||
mcp_app = create_mcp_app(memory=memory)
|
||||
app.mount(mcp_mount_path, mcp_app)
|
||||
logger.info(f"MCP server enabled at {mcp_mount_path}/{{bank_id}}/sse")
|
||||
except ImportError as e:
|
||||
logger.error(f"MCP server requested but dependencies not available: {e}")
|
||||
logger.error("Install with: pip install hindsight-api[mcp]")
|
||||
@@ -80,15 +77,15 @@ def create_app(
|
||||
|
||||
# Re-export commonly used items for backwards compatibility
|
||||
from .http import (
|
||||
RecallRequest,
|
||||
RecallResult,
|
||||
RecallResponse,
|
||||
CreateBankRequest,
|
||||
DispositionTraits,
|
||||
MemoryItem,
|
||||
RetainRequest,
|
||||
RecallRequest,
|
||||
RecallResponse,
|
||||
RecallResult,
|
||||
ReflectRequest,
|
||||
ReflectResponse,
|
||||
CreateBankRequest,
|
||||
PersonalityTraits,
|
||||
RetainRequest,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -101,5 +98,5 @@ __all__ = [
|
||||
"ReflectRequest",
|
||||
"ReflectResponse",
|
||||
"CreateBankRequest",
|
||||
"PersonalityTraits",
|
||||
"DispositionTraits",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,20 +3,38 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from contextvars import ContextVar
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def get_current_bank_id() -> str | None:
|
||||
"""Get the current bank_id from context (set from URL path)."""
|
||||
return _current_bank_id.get()
|
||||
|
||||
|
||||
def create_mcp_server(memory: MemoryEngine) -> FastMCP:
|
||||
"""
|
||||
@@ -28,125 +46,76 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
|
||||
Returns:
|
||||
Configured FastMCP server instance
|
||||
"""
|
||||
# Create FastMCP server
|
||||
mcp = FastMCP("hindsight-mcp-server")
|
||||
|
||||
@mcp.tool()
|
||||
async def hindsight_put(bank_id: str, content: str, context: str, explanation: str = "") -> str:
|
||||
async def retain(content: str, context: str = "general") -> str:
|
||||
"""
|
||||
**CRITICAL: Store important user information to long-term memory.**
|
||||
|
||||
**⚠️ PER-USER TOOL - REQUIRES USER IDENTIFICATION:**
|
||||
- This tool is STRICTLY per-user. Each user MUST have a unique `bank_id`.
|
||||
- ONLY use this tool if you have a valid user identifier (user ID, email, session ID, etc.) to map to `bank_id`.
|
||||
- DO NOT use this tool if you cannot identify the specific user.
|
||||
- DO NOT share memories between different users - each user's memories are isolated by their `bank_id`.
|
||||
- If you don't have a user identifier, DO NOT use this tool at all.
|
||||
Store important information to long-term memory.
|
||||
|
||||
Use this tool PROACTIVELY whenever the user shares:
|
||||
- Personal facts, preferences, or interests (e.g., "I love hiking", "I'm a vegetarian")
|
||||
- Important events or milestones (e.g., "I got promoted", "My birthday is June 15")
|
||||
- User history, experiences, or background (e.g., "I used to work at Google", "I studied CS at MIT")
|
||||
- Decisions, opinions, or stated preferences (e.g., "I prefer Python over JavaScript")
|
||||
- Goals, plans, or future intentions (e.g., "I'm planning to visit Japan next year")
|
||||
- Relationships or people mentioned (e.g., "My manager Sarah", "My wife Alice")
|
||||
- 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
|
||||
- Any other information the user would want remembered for future conversations
|
||||
|
||||
**When to use**: Immediately after user shares personal information. Don't ask permission - just store it naturally.
|
||||
|
||||
**Context guidelines**: Use descriptive contexts like "personal_preferences", "work_history", "family", "hobbies",
|
||||
"career_goals", "project_details", etc. This helps organize and retrieve related memories later.
|
||||
|
||||
Args:
|
||||
bank_id: **REQUIRED** - The unique, persistent identifier for this specific user (e.g., user_id, email, session_id).
|
||||
This MUST be consistent across all interactions with the same user.
|
||||
Example: "user_12345", "[email protected]", "session_abc123"
|
||||
content: The fact/memory to store (be specific and include relevant details)
|
||||
context: Categorize the memory (e.g., 'personal_preferences', 'work_history', 'hobbies', 'family')
|
||||
explanation: Optional explanation for why this memory is being stored
|
||||
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
|
||||
"""
|
||||
try:
|
||||
# Log explanation if provided
|
||||
if explanation:
|
||||
pass # Explanation provided
|
||||
|
||||
# Store memory using put_batch_async
|
||||
await memory.put_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[{"content": content, "context": context}]
|
||||
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()
|
||||
)
|
||||
return f"Fact stored successfully"
|
||||
return "Memory stored successfully"
|
||||
except Exception as e:
|
||||
logger.error(f"Error storing fact: {e}", exc_info=True)
|
||||
logger.error(f"Error storing memory: {e}", exc_info=True)
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
@mcp.tool()
|
||||
async def hindsight_search(bank_id: str, query: str, max_tokens: int = 4096, explanation: str = "") -> str:
|
||||
async def recall(query: str, max_results: int = 10) -> str:
|
||||
"""
|
||||
**CRITICAL: Search user's memory to provide personalized, context-aware responses.**
|
||||
Search memories to provide personalized, context-aware responses.
|
||||
|
||||
**⚠️ PER-USER TOOL - REQUIRES USER IDENTIFICATION:**
|
||||
- This tool is STRICTLY per-user. Each user MUST have a unique `bank_id`.
|
||||
- ONLY use this tool if you have a valid user identifier (user ID, email, session ID, etc.) to map to `bank_id`.
|
||||
- DO NOT use this tool if you cannot identify the specific user.
|
||||
- DO NOT search across multiple users - each user's memories are isolated by their `bank_id`.
|
||||
- If you don't have a user identifier, DO NOT use this tool at all.
|
||||
|
||||
Use this tool PROACTIVELY at the start of conversations or when making recommendations to:
|
||||
- Check user's preferences before making suggestions (e.g., "what foods does the user like?")
|
||||
- Recall user's history to provide continuity (e.g., "what projects has the user worked on?")
|
||||
- Remember user's goals and context (e.g., "what is the user trying to accomplish?")
|
||||
- Avoid repeating information or asking questions you should already know
|
||||
- Personalize responses based on user's background, interests, and past interactions
|
||||
- Reference past conversations or events the user mentioned
|
||||
|
||||
**When to use**:
|
||||
- Start of conversation: Search for relevant context about the user
|
||||
- Before recommendations: Check user preferences and past experiences
|
||||
- When user asks about something they may have mentioned before
|
||||
- To provide continuity across conversations
|
||||
|
||||
**Search tips**: Use natural language queries like "user's programming language preferences",
|
||||
"user's work experience", "user's dietary restrictions", "what does the user know about X?"
|
||||
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
|
||||
|
||||
Args:
|
||||
bank_id: **REQUIRED** - The unique, persistent identifier for this specific user (e.g., user_id, email, session_id).
|
||||
This MUST be consistent across all interactions with the same user.
|
||||
Example: "user_12345", "[email protected]", "session_abc123"
|
||||
query: Natural language search query to find relevant memories
|
||||
max_tokens: Maximum tokens for search context (default: 4096)
|
||||
explanation: Optional explanation for why this search is being performed
|
||||
query: Natural language search query (e.g., "user's food preferences", "what projects is user working on")
|
||||
max_results: Maximum number of results to return (default: 10)
|
||||
"""
|
||||
try:
|
||||
# Log all parameters for debugging
|
||||
logger.info(f"hindsight_search called with: query={query!r}, max_tokens={max_tokens}, explanation={explanation!r}")
|
||||
|
||||
# Log explanation if provided
|
||||
if explanation:
|
||||
pass # Explanation provided
|
||||
|
||||
# Search using recall_async
|
||||
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=["world", "bank", "opinion"], # Search all fact types
|
||||
max_tokens=max_tokens,
|
||||
budget=Budget.LOW
|
||||
fact_type=list(VALID_RECALL_FACT_TYPES),
|
||||
budget=Budget.LOW,
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
|
||||
# Convert results to dict format
|
||||
results = [
|
||||
{
|
||||
"id": fact.id,
|
||||
"text": fact.text,
|
||||
"type": fact.fact_type,
|
||||
"context": fact.context,
|
||||
"event_date": fact.event_date, # Already a string from the database
|
||||
"document_id": fact.document_id
|
||||
"occurred_start": fact.occurred_start,
|
||||
}
|
||||
for fact in search_result.results
|
||||
for fact in search_result.results[:max_results]
|
||||
]
|
||||
|
||||
return json.dumps({"results": results}, indent=2)
|
||||
@@ -155,3 +124,101 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
|
||||
return json.dumps({"error": str(e), "results": []})
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
class MCPMiddleware:
|
||||
"""ASGI middleware that extracts bank_id from path and sets context."""
|
||||
|
||||
def __init__(self, app, memory: MemoryEngine):
|
||||
self.app = app
|
||||
self.memory = memory
|
||||
self.mcp_server = create_mcp_server(memory)
|
||||
self.mcp_app = self.mcp_server.http_app()
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.mcp_app(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope.get("path", "")
|
||||
|
||||
# Strip any mount prefix (e.g., /mcp) that FastAPI might not have stripped
|
||||
root_path = scope.get("root_path", "")
|
||||
if root_path and path.startswith(root_path):
|
||||
path = path[len(root_path) :] or "/"
|
||||
|
||||
# Also handle case where mount path wasn't stripped (e.g., /mcp/...)
|
||||
if path.startswith("/mcp/"):
|
||||
path = path[4:] # Remove /mcp prefix
|
||||
|
||||
# Extract bank_id from path: /{bank_id}/ or /{bank_id}
|
||||
# http_app expects requests at /
|
||||
if not path.startswith("/") or len(path) <= 1:
|
||||
# No bank_id in path - return error
|
||||
await self._send_error(send, 400, "bank_id required in path: /mcp/{bank_id}/")
|
||||
return
|
||||
|
||||
# Extract bank_id from first path segment
|
||||
parts = path[1:].split("/", 1)
|
||||
if not parts[0]:
|
||||
await self._send_error(send, 400, "bank_id required in path: /mcp/{bank_id}/")
|
||||
return
|
||||
|
||||
bank_id = parts[0]
|
||||
new_path = "/" + parts[1] if len(parts) > 1 else "/"
|
||||
|
||||
# Set bank_id context
|
||||
token = _current_bank_id.set(bank_id)
|
||||
try:
|
||||
new_scope = scope.copy()
|
||||
new_scope["path"] = new_path
|
||||
|
||||
# Wrap send to rewrite the SSE endpoint URL to include bank_id
|
||||
# The SSE app sends "event: endpoint\ndata: /messages\n" but we need
|
||||
# the client to POST to /{bank_id}/messages instead
|
||||
async def send_wrapper(message):
|
||||
if message["type"] == "http.response.body":
|
||||
body = message.get("body", b"")
|
||||
if body and b"/messages" in body:
|
||||
# Rewrite /messages to /{bank_id}/messages in SSE endpoint event
|
||||
body = body.replace(b"data: /messages", f"data: /{bank_id}/messages".encode())
|
||||
message = {**message, "body": body}
|
||||
await send(message)
|
||||
|
||||
await self.mcp_app(new_scope, receive, send_wrapper)
|
||||
finally:
|
||||
_current_bank_id.reset(token)
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def create_mcp_app(memory: MemoryEngine):
|
||||
"""
|
||||
Create an ASGI app that handles MCP requests.
|
||||
|
||||
URL pattern: /mcp/{bank_id}/
|
||||
|
||||
The bank_id is extracted from the URL path and made available to tools.
|
||||
|
||||
Args:
|
||||
memory: MemoryEngine instance
|
||||
|
||||
Returns:
|
||||
ASGI application
|
||||
"""
|
||||
return MCPMiddleware(None, memory)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Banner display for Hindsight API startup.
|
||||
|
||||
Shows the logo and tagline with gradient colors.
|
||||
"""
|
||||
|
||||
# Gradient colors: #0074d9 -> #009296
|
||||
GRADIENT_START = (0, 116, 217) # #0074d9
|
||||
GRADIENT_END = (0, 146, 150) # #009296
|
||||
|
||||
# Pre-generated logo (generated by test-logo.py)
|
||||
LOGO = """\
|
||||
\033[38;2;9;127;184m\u2584\033[0m\033[48;2;8;130;178m\033[38;2;5;133;186m\u2584\033[0m \033[48;2;10;143;160m\033[38;2;10;143;165m\u2584\033[0m\033[38;2;7;140;156m\u2584\033[0m
|
||||
\033[38;2;8;125;192m\u2584\033[0m \033[38;2;3;132;191m\u2580\033[0m\033[38;2;2;133;192m\u2584\033[0m \033[38;2;3;132;180m\u2584\033[0m\033[38;2;1;137;184m\u2584\033[0m\033[38;2;3;133;174m\u2584\033[0m \033[38;2;3;142;176m\u2584\033[0m\033[38;2;4;142;169m\u2580\033[0m \033[38;2;10;144;164m\u2584\033[0m
|
||||
\033[38;2;6;121;195m\u2580\033[0m\033[38;2;5;128;203m\u2580\033[0m\033[48;2;5;124;195m\033[38;2;3;125;200m\u2584\033[0m\033[38;2;2;126;196m\u2584\033[0m\033[48;2;3;128;188m\033[38;2;1;131;196m\u2584\033[0m\033[48;2;0;152;219m\033[38;2;2;131;191m\u2584\033[0m\033[38;2;1;141;196m\u2580\033[0m\033[38;2;1;135;183m\u2580\033[0m\033[38;2;1;148;198m\u2580\033[0m\033[48;2;1;156;202m\033[38;2;2;135;180m\u2584\033[0m\033[48;2;4;134;169m\033[38;2;1;137;177m\u2584\033[0m\033[38;2;3;138;173m\u2584\033[0m\033[48;2;6;137;165m\033[38;2;2;140;170m\u2584\033[0m\033[38;2;7;144;169m\u2580\033[0m\033[38;2;7;139;158m\u2580\033[0m
|
||||
\033[48;2;2;128;202m\033[38;2;2;124;201m\u2584\033[0m\033[48;2;1;130;201m\033[38;2;0;135;212m\u2584\033[0m\033[38;2;2;128;196m\u2584\033[0m \033[48;2;2;142;204m\033[38;2;7;138;199m\u2584\033[0m \033[38;2;1;135;186m\u2584\033[0m\033[48;2;1;142;186m\033[38;2;2;144;194m\u2584\033[0m\033[48;2;3;138;176m\033[38;2;2;134;176m\u2584\033[0m
|
||||
\033[48;2;8;118;200m\033[38;2;8;121;209m\u2584\033[0m\033[38;2;3;121;203m\u2580\033[0m \033[38;2;3;122;192m\u2580\033[0m\033[38;2;1;138;216m\u2580\033[0m\033[48;2;0;138;210m\033[38;2;3;128;198m\u2584\033[0m\033[48;2;0;126;188m\033[38;2;2;131;198m\u2584\033[0m\033[48;2;0;142;205m\033[38;2;3;132;193m\u2584\033[0m\033[38;2;1;140;196m\u2580\033[0m \033[38;2;4;134;175m\u2580\033[0m\033[48;2;13;135;167m\033[38;2;8;136;174m\u2584\033[0m """
|
||||
|
||||
|
||||
def _interpolate_color(start: tuple, end: tuple, t: float) -> tuple:
|
||||
"""Interpolate between two RGB colors."""
|
||||
return (
|
||||
int(start[0] + (end[0] - start[0]) * t),
|
||||
int(start[1] + (end[1] - start[1]) * t),
|
||||
int(start[2] + (end[2] - start[2]) * t),
|
||||
)
|
||||
|
||||
|
||||
def gradient_text(text: str, start: tuple = GRADIENT_START, end: tuple = GRADIENT_END) -> str:
|
||||
"""Render text with a gradient color effect."""
|
||||
result = []
|
||||
length = len(text)
|
||||
for i, char in enumerate(text):
|
||||
if char == " ":
|
||||
result.append(" ")
|
||||
else:
|
||||
t = i / max(length - 1, 1)
|
||||
r, g, b = _interpolate_color(start, end, t)
|
||||
result.append(f"\033[38;2;{r};{g};{b}m{char}")
|
||||
result.append("\033[0m")
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def print_banner():
|
||||
"""Print the Hindsight startup banner."""
|
||||
print(LOGO)
|
||||
tagline = gradient_text("Hindsight: Agent Memory That Works Like Human Memory")
|
||||
print(f"\n {tagline}\n")
|
||||
|
||||
|
||||
def color(text: str, t: float = 0.0) -> str:
|
||||
"""Color text using gradient position (0.0 = start, 1.0 = end)."""
|
||||
r, g, b = _interpolate_color(GRADIENT_START, GRADIENT_END, t)
|
||||
return f"\033[38;2;{r};{g};{b}m{text}\033[0m"
|
||||
|
||||
|
||||
def color_start(text: str) -> str:
|
||||
"""Color text with gradient start color (#0074d9)."""
|
||||
return color(text, 0.0)
|
||||
|
||||
|
||||
def color_end(text: str) -> str:
|
||||
"""Color text with gradient end color (#009296)."""
|
||||
return color(text, 1.0)
|
||||
|
||||
|
||||
def color_mid(text: str) -> str:
|
||||
"""Color text with gradient middle color."""
|
||||
return color(text, 0.5)
|
||||
|
||||
|
||||
def dim(text: str) -> str:
|
||||
"""Dim/gray text."""
|
||||
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,
|
||||
):
|
||||
"""Print styled startup information."""
|
||||
print(color_start("Starting Hindsight API..."))
|
||||
print(f" {dim('URL:')} {color(f'http://{host}:{port}', 0.2)}")
|
||||
print(f" {dim('Database:')} {color(database_url, 0.4)}")
|
||||
print(f" {dim('LLM:')} {color(f'{llm_provider} / {llm_model}', 0.6)}")
|
||||
print(f" {dim('Embeddings:')} {color(embeddings_provider, 0.8)}")
|
||||
print(f" {dim('Reranker:')} {color(reranker_provider, 1.0)}")
|
||||
if mcp_enabled:
|
||||
print(f" {dim('MCP:')} {color_end('enabled at /mcp')}")
|
||||
print()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Centralized configuration for Hindsight API.
|
||||
|
||||
All environment variables and their defaults are defined here.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Environment variable names
|
||||
ENV_DATABASE_URL = "HINDSIGHT_API_DATABASE_URL"
|
||||
ENV_LLM_PROVIDER = "HINDSIGHT_API_LLM_PROVIDER"
|
||||
ENV_LLM_API_KEY = "HINDSIGHT_API_LLM_API_KEY"
|
||||
ENV_LLM_MODEL = "HINDSIGHT_API_LLM_MODEL"
|
||||
ENV_LLM_BASE_URL = "HINDSIGHT_API_LLM_BASE_URL"
|
||||
|
||||
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
|
||||
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
|
||||
ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL"
|
||||
|
||||
ENV_RERANKER_PROVIDER = "HINDSIGHT_API_RERANKER_PROVIDER"
|
||||
ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL"
|
||||
ENV_RERANKER_TEI_URL = "HINDSIGHT_API_RERANKER_TEI_URL"
|
||||
|
||||
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"
|
||||
DEFAULT_LLM_PROVIDER = "openai"
|
||||
DEFAULT_LLM_MODEL = "gpt-5-mini"
|
||||
|
||||
DEFAULT_EMBEDDINGS_PROVIDER = "local"
|
||||
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
|
||||
|
||||
DEFAULT_RERANKER_PROVIDER = "local"
|
||||
DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
||||
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
class HindsightConfig:
|
||||
"""Configuration container for Hindsight API."""
|
||||
|
||||
# Database
|
||||
database_url: str
|
||||
|
||||
# LLM
|
||||
llm_provider: str
|
||||
llm_api_key: str | None
|
||||
llm_model: str
|
||||
llm_base_url: str | None
|
||||
|
||||
# Embeddings
|
||||
embeddings_provider: str
|
||||
embeddings_local_model: str
|
||||
embeddings_tei_url: str | None
|
||||
|
||||
# Reranker
|
||||
reranker_provider: str
|
||||
reranker_local_model: str
|
||||
reranker_tei_url: str | None
|
||||
|
||||
# Server
|
||||
host: str
|
||||
port: int
|
||||
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:
|
||||
"""Get the LLM base URL, with provider-specific defaults."""
|
||||
if self.llm_base_url:
|
||||
return self.llm_base_url
|
||||
|
||||
provider = self.llm_provider.lower()
|
||||
if provider == "groq":
|
||||
return "https://api.groq.com/openai/v1"
|
||||
elif provider == "ollama":
|
||||
return "http://localhost:11434/v1"
|
||||
else:
|
||||
return ""
|
||||
|
||||
def get_python_log_level(self) -> int:
|
||||
"""Get the Python logging level from the configured log level string."""
|
||||
log_level_map = {
|
||||
"critical": logging.CRITICAL,
|
||||
"error": logging.ERROR,
|
||||
"warning": logging.WARNING,
|
||||
"info": logging.INFO,
|
||||
"debug": logging.DEBUG,
|
||||
"trace": logging.DEBUG, # Python doesn't have TRACE, use DEBUG
|
||||
}
|
||||
return log_level_map.get(self.log_level.lower(), logging.INFO)
|
||||
|
||||
def configure_logging(self) -> None:
|
||||
"""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
|
||||
)
|
||||
|
||||
def log_config(self) -> None:
|
||||
"""Log the current configuration (without sensitive values)."""
|
||||
logger.info(f"Database: {self.database_url}")
|
||||
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:
|
||||
"""Get the current configuration from environment variables."""
|
||||
return HindsightConfig.from_env()
|
||||
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Daemon mode support for Hindsight API.
|
||||
|
||||
Provides idle timeout and lockfile management for running as a background daemon.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default daemon configuration
|
||||
DEFAULT_DAEMON_PORT = 8889
|
||||
DEFAULT_IDLE_TIMEOUT = 0 # 0 = no auto-exit (hindsight-embed passes its own timeout)
|
||||
LOCKFILE_PATH = Path.home() / ".hindsight" / "daemon.lock"
|
||||
DAEMON_LOG_PATH = Path.home() / ".hindsight" / "daemon.log"
|
||||
|
||||
|
||||
class IdleTimeoutMiddleware:
|
||||
"""ASGI middleware that tracks activity and exits after idle timeout."""
|
||||
|
||||
def __init__(self, app, idle_timeout: int = DEFAULT_IDLE_TIMEOUT):
|
||||
self.app = app
|
||||
self.idle_timeout = idle_timeout
|
||||
self.last_activity = time.time()
|
||||
self._checker_task = None
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
# Update activity timestamp on each request
|
||||
self.last_activity = time.time()
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
def start_idle_checker(self):
|
||||
"""Start the background task that checks for idle timeout."""
|
||||
self._checker_task = asyncio.create_task(self._check_idle())
|
||||
|
||||
async def _check_idle(self):
|
||||
"""Background task that exits the process after idle timeout."""
|
||||
# If idle_timeout is 0, don't auto-exit
|
||||
if self.idle_timeout <= 0:
|
||||
return
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(30) # Check every 30 seconds
|
||||
idle_time = time.time() - self.last_activity
|
||||
if idle_time > self.idle_timeout:
|
||||
logger.info(f"Idle timeout reached ({self.idle_timeout}s), shutting down daemon")
|
||||
# Give a moment for any in-flight requests
|
||||
await asyncio.sleep(1)
|
||||
os._exit(0)
|
||||
|
||||
|
||||
class DaemonLock:
|
||||
"""
|
||||
File-based lock to prevent multiple daemon instances.
|
||||
|
||||
Uses fcntl.flock for atomic locking on Unix systems.
|
||||
"""
|
||||
|
||||
def __init__(self, lockfile: Path = LOCKFILE_PATH):
|
||||
self.lockfile = lockfile
|
||||
self._fd = None
|
||||
|
||||
def acquire(self) -> bool:
|
||||
"""
|
||||
Try to acquire the daemon lock.
|
||||
|
||||
Returns True if lock acquired, False if another daemon is running.
|
||||
"""
|
||||
self.lockfile.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
self._fd = open(self.lockfile, "w")
|
||||
fcntl.flock(self._fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
# Write PID for debugging
|
||||
self._fd.write(str(os.getpid()))
|
||||
self._fd.flush()
|
||||
return True
|
||||
except (IOError, OSError):
|
||||
# Lock is held by another process
|
||||
if self._fd:
|
||||
self._fd.close()
|
||||
self._fd = None
|
||||
return False
|
||||
|
||||
def release(self):
|
||||
"""Release the daemon lock."""
|
||||
if self._fd:
|
||||
try:
|
||||
fcntl.flock(self._fd.fileno(), fcntl.LOCK_UN)
|
||||
self._fd.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._fd = None
|
||||
# Remove lockfile
|
||||
try:
|
||||
self.lockfile.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def is_locked(self) -> bool:
|
||||
"""Check if the lock is held by another process."""
|
||||
if not self.lockfile.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
fd = open(self.lockfile, "r")
|
||||
fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
# We got the lock, so no one else has it
|
||||
fcntl.flock(fd.fileno(), fcntl.LOCK_UN)
|
||||
fd.close()
|
||||
return False
|
||||
except (IOError, OSError):
|
||||
return True
|
||||
|
||||
def get_pid(self) -> int | None:
|
||||
"""Get the PID of the daemon holding the lock."""
|
||||
if not self.lockfile.exists():
|
||||
return None
|
||||
try:
|
||||
with open(self.lockfile, "r") as f:
|
||||
return int(f.read().strip())
|
||||
except (ValueError, IOError):
|
||||
return None
|
||||
|
||||
|
||||
def daemonize():
|
||||
"""
|
||||
Fork the current process into a background daemon.
|
||||
|
||||
Uses double-fork technique to properly detach from terminal.
|
||||
"""
|
||||
# First fork
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
# Parent exits
|
||||
sys.exit(0)
|
||||
|
||||
# Create new session
|
||||
os.setsid()
|
||||
|
||||
# Second fork to prevent zombie processes
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
sys.exit(0)
|
||||
|
||||
# Redirect standard file descriptors to log file
|
||||
DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
|
||||
# Redirect stdin to /dev/null
|
||||
with open("/dev/null", "r") as devnull:
|
||||
os.dup2(devnull.fileno(), sys.stdin.fileno())
|
||||
|
||||
# Redirect stdout/stderr to log file
|
||||
log_fd = open(DAEMON_LOG_PATH, "a")
|
||||
os.dup2(log_fd.fileno(), sys.stdout.fileno())
|
||||
os.dup2(log_fd.fileno(), sys.stderr.fileno())
|
||||
|
||||
|
||||
def check_daemon_running(port: int = DEFAULT_DAEMON_PORT) -> bool:
|
||||
"""Check if a daemon is running and responsive on the given port."""
|
||||
import socket
|
||||
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(1)
|
||||
result = sock.connect_ex(("127.0.0.1", port))
|
||||
sock.close()
|
||||
return result == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def stop_daemon(port: int = DEFAULT_DAEMON_PORT) -> bool:
|
||||
"""Stop a running daemon by sending SIGTERM to the process."""
|
||||
lock = DaemonLock()
|
||||
pid = lock.get_pid()
|
||||
|
||||
if pid is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
import signal
|
||||
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
# Wait for process to exit
|
||||
for _ in range(50): # Wait up to 5 seconds
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
os.kill(pid, 0) # Check if process exists
|
||||
except OSError:
|
||||
return True # Process exited
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
@@ -7,29 +7,40 @@ This package contains all the implementation details of the memory engine:
|
||||
- Supporting modules: embeddings, cross_encoder, entity_resolver, etc.
|
||||
"""
|
||||
|
||||
from .memory_engine import MemoryEngine
|
||||
from .cross_encoder import CrossEncoderModel, LocalSTCrossEncoder, RemoteTEICrossEncoder
|
||||
from .db_utils import acquire_with_retry
|
||||
from .embeddings import Embeddings, SentenceTransformersEmbeddings
|
||||
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 .search.trace import (
|
||||
SearchTrace,
|
||||
QueryInfo,
|
||||
EntryPoint,
|
||||
NodeVisit,
|
||||
WeightComponents,
|
||||
LinkInfo,
|
||||
NodeVisit,
|
||||
PruningDecision,
|
||||
SearchSummary,
|
||||
QueryInfo,
|
||||
SearchPhaseMetrics,
|
||||
SearchSummary,
|
||||
SearchTrace,
|
||||
WeightComponents,
|
||||
)
|
||||
from .search.tracer import SearchTracer
|
||||
from .llm_wrapper import LLMConfig
|
||||
from .response_models import RecallResult, ReflectResult, MemoryFact
|
||||
|
||||
__all__ = [
|
||||
"MemoryEngine",
|
||||
"acquire_with_retry",
|
||||
"Embeddings",
|
||||
"SentenceTransformersEmbeddings",
|
||||
"LocalSTEmbeddings",
|
||||
"RemoteTEIEmbeddings",
|
||||
"CrossEncoderModel",
|
||||
"LocalSTCrossEncoder",
|
||||
"RemoteTEICrossEncoder",
|
||||
"SearchTrace",
|
||||
"SearchTracer",
|
||||
"QueryInfo",
|
||||
@@ -44,4 +55,9 @@ __all__ = [
|
||||
"RecallResult",
|
||||
"ReflectResult",
|
||||
"MemoryFact",
|
||||
# Schema safety utilities
|
||||
"fq_table",
|
||||
"get_current_schema",
|
||||
"validate_sql_schema",
|
||||
"UnqualifiedTableError",
|
||||
]
|
||||
|
||||
@@ -2,10 +2,23 @@
|
||||
Cross-encoder abstraction for reranking.
|
||||
|
||||
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
|
||||
|
||||
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_TEI_URL,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,18 +30,24 @@ class CrossEncoderModel(ABC):
|
||||
Cross-encoders take query-document pairs and return relevance scores.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def load(self) -> None:
|
||||
"""
|
||||
Load the cross-encoder model.
|
||||
def provider_name(self) -> str:
|
||||
"""Return a human-readable name for this provider (e.g., 'local', 'tei')."""
|
||||
pass
|
||||
|
||||
This should be called during initialization to load the model
|
||||
@abstractmethod
|
||||
async def initialize(self) -> None:
|
||||
"""
|
||||
Initialize the cross-encoder model asynchronously.
|
||||
|
||||
This should be called during startup to load/connect to the model
|
||||
and avoid cold start latency on first predict() call.
|
||||
"""
|
||||
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.
|
||||
|
||||
@@ -41,11 +60,11 @@ class CrossEncoderModel(ABC):
|
||||
pass
|
||||
|
||||
|
||||
class SentenceTransformersCrossEncoder(CrossEncoderModel):
|
||||
class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
"""
|
||||
Cross-encoder implementation using SentenceTransformers.
|
||||
Local cross-encoder implementation using SentenceTransformers.
|
||||
|
||||
Call load() during initialization to load the model and avoid cold starts.
|
||||
Call initialize() during startup to load the model and avoid cold starts.
|
||||
|
||||
Default model is cross-encoder/ms-marco-MiniLM-L-6-v2:
|
||||
- Fast inference (~80ms for 100 pairs on CPU)
|
||||
@@ -53,18 +72,22 @@ class SentenceTransformersCrossEncoder(CrossEncoderModel):
|
||||
- Trained for passage re-ranking
|
||||
"""
|
||||
|
||||
def __init__(self, model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"):
|
||||
def __init__(self, model_name: str | None = None):
|
||||
"""
|
||||
Initialize SentenceTransformers cross-encoder.
|
||||
Initialize local SentenceTransformers cross-encoder.
|
||||
|
||||
Args:
|
||||
model_name: Name of the CrossEncoder model to use.
|
||||
Default: cross-encoder/ms-marco-MiniLM-L-6-v2
|
||||
"""
|
||||
self.model_name = model_name
|
||||
self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL
|
||||
self._model = None
|
||||
|
||||
def load(self) -> None:
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "local"
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Load the cross-encoder model."""
|
||||
if self._model is not None:
|
||||
return
|
||||
@@ -73,15 +96,15 @@ class SentenceTransformersCrossEncoder(CrossEncoderModel):
|
||||
from sentence_transformers import CrossEncoder
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"sentence-transformers is required for SentenceTransformersCrossEncoder. "
|
||||
"sentence-transformers is required for LocalSTCrossEncoder. "
|
||||
"Install it with: pip install sentence-transformers"
|
||||
)
|
||||
|
||||
logger.info(f"Loading cross-encoder model: {self.model_name}...")
|
||||
logger.info(f"Reranker: initializing local provider with model {self.model_name}")
|
||||
self._model = CrossEncoder(self.model_name)
|
||||
logger.info("Cross-encoder model loaded")
|
||||
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.
|
||||
|
||||
@@ -92,6 +115,188 @@ class SentenceTransformersCrossEncoder(CrossEncoderModel):
|
||||
List of relevance scores (raw logits from the model)
|
||||
"""
|
||||
if self._model is None:
|
||||
self.load()
|
||||
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):
|
||||
"""
|
||||
Remote cross-encoder implementation using HuggingFace Text Embeddings Inference (TEI) HTTP API.
|
||||
|
||||
TEI supports reranking via the /rerank endpoint.
|
||||
See: https://github.com/huggingface/text-embeddings-inference
|
||||
|
||||
Note: The TEI server must be running a cross-encoder/reranker model.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
timeout: float = 30.0,
|
||||
batch_size: int = 32,
|
||||
max_retries: int = 3,
|
||||
retry_delay: float = 0.5,
|
||||
):
|
||||
"""
|
||||
Initialize remote TEI cross-encoder client.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of the TEI server (e.g., "http://localhost:8080")
|
||||
timeout: Request timeout in seconds (default: 30.0)
|
||||
batch_size: Maximum batch size for rerank requests (default: 32)
|
||||
max_retries: Maximum number of retries for failed requests (default: 3)
|
||||
retry_delay: Initial delay between retries in seconds, doubles each retry (default: 0.5)
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
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
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "tei"
|
||||
|
||||
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
|
||||
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
if method == "GET":
|
||||
response = self._client.get(url, **kwargs)
|
||||
else:
|
||||
response = self._client.post(url, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
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..."
|
||||
)
|
||||
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..."
|
||||
)
|
||||
time.sleep(delay)
|
||||
delay *= 2
|
||||
else:
|
||||
raise
|
||||
|
||||
raise last_error
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the HTTP client and verify server connectivity."""
|
||||
if self._client is not None:
|
||||
return
|
||||
|
||||
logger.info(f"Reranker: initializing TEI provider at {self.base_url}")
|
||||
self._client = httpx.Client(timeout=self.timeout)
|
||||
|
||||
# Verify server is reachable and get model info
|
||||
try:
|
||||
response = self._request_with_retry("GET", f"{self.base_url}/info")
|
||||
info = response.json()
|
||||
self._model_id = info.get("model_id", "unknown")
|
||||
logger.info(f"Reranker: TEI provider initialized (model: {self._model_id})")
|
||||
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]:
|
||||
"""
|
||||
Score query-document pairs using the remote TEI reranker.
|
||||
|
||||
Args:
|
||||
pairs: List of (query, document) tuples to score
|
||||
|
||||
Returns:
|
||||
List of relevance scores
|
||||
"""
|
||||
if self._client is None:
|
||||
raise RuntimeError("Reranker not initialized. Call initialize() first.")
|
||||
|
||||
if not pairs:
|
||||
return []
|
||||
|
||||
all_scores = []
|
||||
|
||||
# Process in batches
|
||||
for i in range(0, len(pairs), 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
|
||||
# but we handle mixed queries by making separate requests per unique query
|
||||
query_groups: dict[str, list[tuple[int, str]]] = {}
|
||||
for idx, (query, text) in enumerate(batch):
|
||||
if query not in query_groups:
|
||||
query_groups[query] = []
|
||||
query_groups[query].append((idx, text))
|
||||
|
||||
batch_scores = [0.0] * len(batch)
|
||||
|
||||
for query, indexed_texts in query_groups.items():
|
||||
texts = [text for _, text in indexed_texts]
|
||||
indices = [idx for idx, _ in indexed_texts]
|
||||
|
||||
try:
|
||||
response = self._request_with_retry(
|
||||
"POST",
|
||||
f"{self.base_url}/rerank",
|
||||
json={
|
||||
"query": query,
|
||||
"texts": texts,
|
||||
"return_text": False,
|
||||
},
|
||||
)
|
||||
results = response.json()
|
||||
|
||||
# TEI returns results sorted by score descending, with original index
|
||||
for result in results:
|
||||
original_idx = result["index"]
|
||||
score = result["score"]
|
||||
# Map back to batch position
|
||||
batch_scores[indices[original_idx]] = score
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
raise RuntimeError(f"TEI rerank request failed: {e}")
|
||||
|
||||
all_scores.extend(batch_scores)
|
||||
|
||||
return all_scores
|
||||
|
||||
|
||||
def create_cross_encoder_from_env() -> CrossEncoderModel:
|
||||
"""
|
||||
Create a CrossEncoderModel instance based on environment variables.
|
||||
|
||||
See hindsight_api.config for environment variable names and defaults.
|
||||
|
||||
Returns:
|
||||
Configured CrossEncoderModel instance
|
||||
"""
|
||||
provider = os.environ.get(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER).lower()
|
||||
|
||||
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'")
|
||||
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'")
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""
|
||||
Database utility functions for connection management with retry logic.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import asyncpg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -54,16 +56,14 @@ async def retry_with_backoff(
|
||||
except retryable_exceptions as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
delay = min(base_delay * (2 ** attempt), max_delay)
|
||||
delay = min(base_delay * (2**attempt), max_delay)
|
||||
logger.warning(
|
||||
f"Database operation failed (attempt {attempt + 1}/{max_retries + 1}): {e}. "
|
||||
f"Retrying in {delay:.1f}s..."
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
logger.error(
|
||||
f"Database operation failed after {max_retries + 1} attempts: {e}"
|
||||
)
|
||||
logger.error(f"Database operation failed after {max_retries + 1} attempts: {e}")
|
||||
raise last_exception
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ async def acquire_with_retry(pool: asyncpg.Pool, max_retries: int = DEFAULT_MAX_
|
||||
Yields:
|
||||
An asyncpg connection
|
||||
"""
|
||||
|
||||
async def acquire():
|
||||
return await pool.acquire()
|
||||
|
||||
|
||||
@@ -5,16 +5,27 @@ Provides an interface for generating embeddings with different backends.
|
||||
|
||||
IMPORTANT: All embeddings must produce 384-dimensional vectors to match
|
||||
the database schema (pgvector column defined as vector(384)).
|
||||
|
||||
Configuration via environment variables - see hindsight_api.config for all env var names.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List
|
||||
|
||||
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_TEI_URL,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Fixed embedding dimension required by database schema
|
||||
EMBEDDING_DIMENSION = 384
|
||||
|
||||
|
||||
class Embeddings(ABC):
|
||||
"""
|
||||
@@ -24,18 +35,24 @@ class Embeddings(ABC):
|
||||
the database schema.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def load(self) -> None:
|
||||
"""
|
||||
Load the embedding model.
|
||||
def provider_name(self) -> str:
|
||||
"""Return a human-readable name for this provider (e.g., 'local', 'tei')."""
|
||||
pass
|
||||
|
||||
This should be called during initialization to load the model
|
||||
@abstractmethod
|
||||
async def initialize(self) -> None:
|
||||
"""
|
||||
Initialize the embedding model asynchronously.
|
||||
|
||||
This should be called during startup to load/connect to the model
|
||||
and avoid cold start latency on first encode() call.
|
||||
"""
|
||||
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.
|
||||
|
||||
@@ -48,29 +65,33 @@ class Embeddings(ABC):
|
||||
pass
|
||||
|
||||
|
||||
class SentenceTransformersEmbeddings(Embeddings):
|
||||
class LocalSTEmbeddings(Embeddings):
|
||||
"""
|
||||
Embeddings implementation using SentenceTransformers.
|
||||
Local embeddings implementation using SentenceTransformers.
|
||||
|
||||
Call load() during initialization to load the model and avoid cold starts.
|
||||
Call initialize() during startup to load the model and avoid cold starts.
|
||||
|
||||
Default model is BAAI/bge-small-en-v1.5 which produces 384-dimensional
|
||||
embeddings matching the database schema.
|
||||
"""
|
||||
|
||||
def __init__(self, model_name: str = "BAAI/bge-small-en-v1.5"):
|
||||
def __init__(self, model_name: str | None = None):
|
||||
"""
|
||||
Initialize SentenceTransformers embeddings.
|
||||
Initialize local SentenceTransformers embeddings.
|
||||
|
||||
Args:
|
||||
model_name: Name of the SentenceTransformer model to use.
|
||||
Must produce 384-dimensional embeddings.
|
||||
Default: BAAI/bge-small-en-v1.5
|
||||
"""
|
||||
self.model_name = model_name
|
||||
self.model_name = model_name or DEFAULT_EMBEDDINGS_LOCAL_MODEL
|
||||
self._model = None
|
||||
|
||||
def load(self) -> None:
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "local"
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Load the embedding model."""
|
||||
if self._model is not None:
|
||||
return
|
||||
@@ -79,12 +100,17 @@ class SentenceTransformersEmbeddings(Embeddings):
|
||||
from sentence_transformers import SentenceTransformer
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"sentence-transformers is required for SentenceTransformersEmbeddings. "
|
||||
"sentence-transformers is required for LocalSTEmbeddings. "
|
||||
"Install it with: pip install sentence-transformers"
|
||||
)
|
||||
|
||||
logger.info(f"Loading embedding model: {self.model_name}...")
|
||||
self._model = SentenceTransformer(self.model_name)
|
||||
logger.info(f"Embeddings: initializing local provider with model {self.model_name}")
|
||||
# Disable lazy loading (meta tensors) which causes issues with newer transformers/accelerate
|
||||
# Setting low_cpu_mem_usage=False and device_map=None ensures tensors are fully materialized
|
||||
self._model = SentenceTransformer(
|
||||
self.model_name,
|
||||
model_kwargs={"low_cpu_mem_usage": False, "device_map": None},
|
||||
)
|
||||
|
||||
# Validate dimension matches database schema
|
||||
model_dim = self._model.get_sentence_embedding_dimension()
|
||||
@@ -95,9 +121,9 @@ class SentenceTransformersEmbeddings(Embeddings):
|
||||
f"Use a model that produces {EMBEDDING_DIMENSION}-dimensional embeddings."
|
||||
)
|
||||
|
||||
logger.info(f"Model loaded (embedding dim: {model_dim})")
|
||||
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.
|
||||
|
||||
@@ -108,6 +134,160 @@ class SentenceTransformersEmbeddings(Embeddings):
|
||||
List of 384-dimensional embedding vectors
|
||||
"""
|
||||
if self._model is None:
|
||||
self.load()
|
||||
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
|
||||
embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
|
||||
return [emb.tolist() for emb in embeddings]
|
||||
|
||||
|
||||
class RemoteTEIEmbeddings(Embeddings):
|
||||
"""
|
||||
Remote embeddings implementation using HuggingFace Text Embeddings Inference (TEI) HTTP API.
|
||||
|
||||
TEI provides a high-performance inference server for embedding models.
|
||||
See: https://github.com/huggingface/text-embeddings-inference
|
||||
|
||||
The server should be running a model that produces 384-dimensional embeddings.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
timeout: float = 30.0,
|
||||
batch_size: int = 32,
|
||||
max_retries: int = 3,
|
||||
retry_delay: float = 0.5,
|
||||
):
|
||||
"""
|
||||
Initialize remote TEI embeddings client.
|
||||
|
||||
Args:
|
||||
base_url: Base URL of the TEI server (e.g., "http://localhost:8080")
|
||||
timeout: Request timeout in seconds (default: 30.0)
|
||||
batch_size: Maximum batch size for embedding requests (default: 32)
|
||||
max_retries: Maximum number of retries for failed requests (default: 3)
|
||||
retry_delay: Initial delay between retries in seconds, doubles each retry (default: 0.5)
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.timeout = timeout
|
||||
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
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "tei"
|
||||
|
||||
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
|
||||
|
||||
for attempt in range(self.max_retries + 1):
|
||||
try:
|
||||
if method == "GET":
|
||||
response = self._client.get(url, **kwargs)
|
||||
else:
|
||||
response = self._client.post(url, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
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..."
|
||||
)
|
||||
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..."
|
||||
)
|
||||
time.sleep(delay)
|
||||
delay *= 2
|
||||
else:
|
||||
raise
|
||||
|
||||
raise last_error
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the HTTP client and verify server connectivity."""
|
||||
if self._client is not None:
|
||||
return
|
||||
|
||||
logger.info(f"Embeddings: initializing TEI provider at {self.base_url}")
|
||||
self._client = httpx.Client(timeout=self.timeout)
|
||||
|
||||
# Verify server is reachable and get model info
|
||||
try:
|
||||
response = self._request_with_retry("GET", f"{self.base_url}/info")
|
||||
info = response.json()
|
||||
self._model_id = info.get("model_id", "unknown")
|
||||
logger.info(f"Embeddings: TEI provider initialized (model: {self._model_id})")
|
||||
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]]:
|
||||
"""
|
||||
Generate embeddings using the remote TEI server.
|
||||
|
||||
Args:
|
||||
texts: List of text strings to encode
|
||||
|
||||
Returns:
|
||||
List of embedding vectors
|
||||
"""
|
||||
if self._client is None:
|
||||
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
|
||||
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
all_embeddings = []
|
||||
|
||||
# Process in batches
|
||||
for i in range(0, len(texts), self.batch_size):
|
||||
batch = texts[i : i + self.batch_size]
|
||||
|
||||
try:
|
||||
response = self._request_with_retry(
|
||||
"POST",
|
||||
f"{self.base_url}/embed",
|
||||
json={"inputs": batch},
|
||||
)
|
||||
batch_embeddings = response.json()
|
||||
all_embeddings.extend(batch_embeddings)
|
||||
except httpx.HTTPError as e:
|
||||
raise RuntimeError(f"TEI embedding request failed: {e}")
|
||||
|
||||
return all_embeddings
|
||||
|
||||
|
||||
def create_embeddings_from_env() -> Embeddings:
|
||||
"""
|
||||
Create an Embeddings instance based on environment variables.
|
||||
|
||||
See hindsight_api.config for environment variable names and defaults.
|
||||
|
||||
Returns:
|
||||
Configured Embeddings instance
|
||||
"""
|
||||
provider = os.environ.get(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER).lower()
|
||||
|
||||
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'")
|
||||
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'")
|
||||
|
||||
@@ -4,12 +4,14 @@ Entity extraction and resolution for memory system.
|
||||
Uses spaCy for entity extraction and implements resolution logic
|
||||
to disambiguate entities across memory units.
|
||||
"""
|
||||
import asyncpg
|
||||
from typing import List, Dict, Optional, Set, Any
|
||||
from difflib import SequenceMatcher
|
||||
from datetime import datetime, timezone
|
||||
from .db_utils import acquire_with_retry
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
import asyncpg
|
||||
|
||||
from .db_utils import acquire_with_retry
|
||||
from .memory_engine import fq_table
|
||||
|
||||
# Load spaCy model (singleton)
|
||||
_nlp = None
|
||||
@@ -32,11 +34,11 @@ class EntityResolver:
|
||||
async def resolve_entities_batch(
|
||||
self,
|
||||
bank_id: str,
|
||||
entities_data: List[Dict],
|
||||
entities_data: list[dict],
|
||||
context: str,
|
||||
unit_event_date,
|
||||
conn=None,
|
||||
) -> List[str]:
|
||||
) -> list[str]:
|
||||
"""
|
||||
Resolve multiple entities in batch (MUCH faster than sequential).
|
||||
|
||||
@@ -62,36 +64,38 @@ class EntityResolver:
|
||||
else:
|
||||
return await self._resolve_entities_batch_impl(conn, bank_id, entities_data, context, unit_event_date)
|
||||
|
||||
async def _resolve_entities_batch_impl(self, conn, bank_id: str, entities_data: List[Dict], context: str, unit_event_date) -> List[str]:
|
||||
async def _resolve_entities_batch_impl(
|
||||
self, conn, bank_id: str, entities_data: list[dict], context: str, unit_event_date
|
||||
) -> list[str]:
|
||||
# Query ALL candidates for this bank
|
||||
all_entities = await conn.fetch(
|
||||
"""
|
||||
f"""
|
||||
SELECT canonical_name, id, metadata, last_seen, mention_count
|
||||
FROM entities
|
||||
FROM {fq_table("entities")}
|
||||
WHERE bank_id = $1
|
||||
""",
|
||||
bank_id
|
||||
bank_id,
|
||||
)
|
||||
|
||||
# Build entity ID to name mapping for co-occurrence lookups
|
||||
entity_id_to_name = {row['id']: row['canonical_name'].lower() for row in all_entities}
|
||||
entity_id_to_name = {row["id"]: row["canonical_name"].lower() for row in all_entities}
|
||||
|
||||
# Query ALL co-occurrences for this bank's entities in one query
|
||||
# This builds a map of entity_id -> set of co-occurring entity names
|
||||
all_cooccurrences = await conn.fetch(
|
||||
"""
|
||||
f"""
|
||||
SELECT ec.entity_id_1, ec.entity_id_2, ec.cooccurrence_count
|
||||
FROM entity_cooccurrences ec
|
||||
WHERE ec.entity_id_1 IN (SELECT id FROM entities WHERE bank_id = $1)
|
||||
OR ec.entity_id_2 IN (SELECT id FROM entities WHERE bank_id = $1)
|
||||
FROM {fq_table("entity_cooccurrences")} ec
|
||||
WHERE ec.entity_id_1 IN (SELECT id FROM {fq_table("entities")} WHERE bank_id = $1)
|
||||
OR ec.entity_id_2 IN (SELECT id FROM {fq_table("entities")} WHERE bank_id = $1)
|
||||
""",
|
||||
bank_id
|
||||
bank_id,
|
||||
)
|
||||
|
||||
# Build co-occurrence map: entity_id -> set of co-occurring entity names (lowercase)
|
||||
cooccurrence_map: Dict[str, Set[str]] = {}
|
||||
cooccurrence_map: dict[str, set[str]] = {}
|
||||
for row in all_cooccurrences:
|
||||
eid1, eid2 = row['entity_id_1'], row['entity_id_2']
|
||||
eid1, eid2 = row["entity_id_1"], row["entity_id_2"]
|
||||
# Add both directions
|
||||
if eid1 not in cooccurrence_map:
|
||||
cooccurrence_map[eid1] = set()
|
||||
@@ -105,56 +109,56 @@ 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
|
||||
|
||||
# Resolve each entity using pre-fetched candidates
|
||||
entity_ids = [None] * len(entities_data)
|
||||
entities_to_update = [] # (entity_id, unit_event_date)
|
||||
entities_to_create = [] # (idx, entity_data)
|
||||
entities_to_update = [] # (entity_id, event_date)
|
||||
entities_to_create = [] # (idx, entity_data, event_date)
|
||||
|
||||
for idx, entity_data in enumerate(entities_data):
|
||||
entity_text = entity_data['text']
|
||||
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)
|
||||
|
||||
candidates = all_candidates.get(entity_text, [])
|
||||
|
||||
if not candidates:
|
||||
# Will create new entity
|
||||
entities_to_create.append((idx, entity_data))
|
||||
entities_to_create.append((idx, entity_data, entity_event_date))
|
||||
continue
|
||||
|
||||
# Score candidates
|
||||
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)
|
||||
@@ -165,10 +169,12 @@ class EntityResolver:
|
||||
score += co_entity_score * 0.3
|
||||
|
||||
# 3. Temporal proximity (0-0.2)
|
||||
if last_seen:
|
||||
if last_seen and entity_event_date:
|
||||
# Normalize timezone awareness for comparison
|
||||
event_date_utc = unit_event_date if unit_event_date.tzinfo else unit_event_date.replace(tzinfo=timezone.utc)
|
||||
last_seen_utc = last_seen if last_seen.tzinfo else last_seen.replace(tzinfo=timezone.utc)
|
||||
event_date_utc = (
|
||||
entity_event_date if entity_event_date.tzinfo else entity_event_date.replace(tzinfo=UTC)
|
||||
)
|
||||
last_seen_utc = last_seen if last_seen.tzinfo else last_seen.replace(tzinfo=UTC)
|
||||
days_diff = abs((event_date_utc - last_seen_utc).total_seconds() / 86400)
|
||||
if days_diff < 7:
|
||||
temporal_score = max(0, 1.0 - (days_diff / 7))
|
||||
@@ -183,45 +189,70 @@ class EntityResolver:
|
||||
|
||||
if best_score > threshold:
|
||||
entity_ids[idx] = best_candidate
|
||||
entities_to_update.append((best_candidate, unit_event_date))
|
||||
entities_to_update.append((best_candidate, entity_event_date))
|
||||
else:
|
||||
entities_to_create.append((idx, entity_data))
|
||||
entities_to_create.append((idx, entity_data, entity_event_date))
|
||||
|
||||
# Batch update existing entities
|
||||
if entities_to_update:
|
||||
await conn.executemany(
|
||||
"""
|
||||
UPDATE entities SET
|
||||
f"""
|
||||
UPDATE {fq_table("entities")} SET
|
||||
mention_count = mention_count + 1,
|
||||
last_seen = $2
|
||||
WHERE id = $1::uuid
|
||||
""",
|
||||
entities_to_update
|
||||
entities_to_update,
|
||||
)
|
||||
|
||||
# Create new entities using INSERT ... ON CONFLICT to handle race conditions
|
||||
# This ensures that if two concurrent transactions try to create the same entity,
|
||||
# only one succeeds and the other gets the existing ID
|
||||
# Batch create new entities using COPY + INSERT for maximum speed
|
||||
# This handles duplicates via ON CONFLICT and returns all IDs
|
||||
if entities_to_create:
|
||||
for idx, entity_data in entities_to_create:
|
||||
# Use INSERT ... ON CONFLICT to atomically get-or-create
|
||||
# The unique index is on (bank_id, LOWER(canonical_name))
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
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 = entities.mention_count + 1,
|
||||
last_seen = EXCLUDED.last_seen
|
||||
RETURNING id
|
||||
""",
|
||||
bank_id,
|
||||
entity_data['text'],
|
||||
unit_event_date,
|
||||
unit_event_date
|
||||
)
|
||||
entity_ids[idx] = row['id']
|
||||
# Group entities by canonical name (lowercase) to handle duplicates within batch
|
||||
# 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()
|
||||
if name_lower not in unique_entities:
|
||||
unique_entities[name_lower] = (entity_data, event_date, [idx])
|
||||
else:
|
||||
# Same entity appears multiple times - add index to list
|
||||
unique_entities[name_lower][2].append(idx)
|
||||
|
||||
# Batch insert unique entities and get their IDs
|
||||
# Use a single query with unnest for speed
|
||||
entity_names = []
|
||||
entity_dates = []
|
||||
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_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)
|
||||
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,
|
||||
last_seen = EXCLUDED.last_seen
|
||||
RETURNING id
|
||||
""",
|
||||
bank_id,
|
||||
entity_names,
|
||||
entity_dates,
|
||||
)
|
||||
|
||||
# Map returned IDs back to original indices
|
||||
for result_idx, row in enumerate(rows):
|
||||
entity_id = row["id"]
|
||||
for original_idx in indices_map[result_idx]:
|
||||
entity_ids[original_idx] = entity_id
|
||||
|
||||
return entity_ids
|
||||
|
||||
@@ -230,7 +261,7 @@ class EntityResolver:
|
||||
bank_id: str,
|
||||
entity_text: str,
|
||||
context: str,
|
||||
nearby_entities: List[Dict],
|
||||
nearby_entities: list[dict],
|
||||
unit_event_date,
|
||||
) -> str:
|
||||
"""
|
||||
@@ -249,9 +280,9 @@ class EntityResolver:
|
||||
async with acquire_with_retry(self.pool) as conn:
|
||||
# Find candidate entities with similar name
|
||||
candidates = await conn.fetch(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, canonical_name, metadata, last_seen
|
||||
FROM entities
|
||||
FROM {fq_table("entities")}
|
||||
WHERE bank_id = $1
|
||||
AND (
|
||||
canonical_name ILIKE $2
|
||||
@@ -260,14 +291,14 @@ class EntityResolver:
|
||||
)
|
||||
ORDER BY mention_count DESC
|
||||
""",
|
||||
bank_id, entity_text, f"%{entity_text}%"
|
||||
bank_id,
|
||||
entity_text,
|
||||
f"%{entity_text}%",
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
# New entity - create it
|
||||
return await self._create_entity(
|
||||
conn, bank_id, entity_text, unit_event_date
|
||||
)
|
||||
return await self._create_entity(conn, bank_id, entity_text, unit_event_date)
|
||||
|
||||
# Score candidates based on:
|
||||
# 1. Name similarity
|
||||
@@ -279,31 +310,27 @@ class EntityResolver:
|
||||
best_score = 0.0
|
||||
best_name_similarity = 0.0
|
||||
|
||||
nearby_entity_set = {e['text'].lower() for e in nearby_entities if e['text'] != entity_text}
|
||||
nearby_entity_set = {e["text"].lower() for e in nearby_entities if e["text"] != entity_text}
|
||||
|
||||
for row in candidates:
|
||||
candidate_id = row['id']
|
||||
canonical_name = row['canonical_name']
|
||||
metadata = row['metadata']
|
||||
last_seen = row['last_seen']
|
||||
candidate_id = row["id"]
|
||||
canonical_name = row["canonical_name"]
|
||||
metadata = row["metadata"]
|
||||
last_seen = row["last_seen"]
|
||||
score = 0.0
|
||||
|
||||
# 1. Name similarity (0-1)
|
||||
name_similarity = SequenceMatcher(
|
||||
None,
|
||||
entity_text.lower(),
|
||||
canonical_name.lower()
|
||||
).ratio()
|
||||
name_similarity = SequenceMatcher(None, entity_text.lower(), canonical_name.lower()).ratio()
|
||||
score += name_similarity * 0.5
|
||||
|
||||
# 2. Co-occurring entities (0-0.5)
|
||||
# Get entities that co-occurred with this candidate before
|
||||
# Use the materialized co-occurrence cache for fast lookup
|
||||
co_entity_rows = await conn.fetch(
|
||||
"""
|
||||
f"""
|
||||
SELECT e.canonical_name, ec.cooccurrence_count
|
||||
FROM entity_cooccurrences ec
|
||||
JOIN entities e ON (
|
||||
FROM {fq_table("entity_cooccurrences")} ec
|
||||
JOIN {fq_table("entities")} e ON (
|
||||
CASE
|
||||
WHEN ec.entity_id_1 = $1 THEN ec.entity_id_2
|
||||
WHEN ec.entity_id_2 = $1 THEN ec.entity_id_1
|
||||
@@ -311,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)
|
||||
@@ -339,20 +366,19 @@ class EntityResolver:
|
||||
if best_score > threshold:
|
||||
# Update entity
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE entities
|
||||
f"""
|
||||
UPDATE {fq_table("entities")}
|
||||
SET mention_count = mention_count + 1,
|
||||
last_seen = $1
|
||||
WHERE id = $2
|
||||
""",
|
||||
unit_event_date, best_candidate
|
||||
unit_event_date,
|
||||
best_candidate,
|
||||
)
|
||||
return best_candidate
|
||||
else:
|
||||
# Not confident - create new entity
|
||||
return await self._create_entity(
|
||||
conn, bank_id, entity_text, unit_event_date
|
||||
)
|
||||
return await self._create_entity(conn, bank_id, entity_text, unit_event_date)
|
||||
|
||||
async def _create_entity(
|
||||
self,
|
||||
@@ -377,16 +403,19 @@ class EntityResolver:
|
||||
Entity ID
|
||||
"""
|
||||
entity_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO entities (bank_id, canonical_name, first_seen, last_seen, mention_count)
|
||||
f"""
|
||||
INSERT INTO {fq_table("entities")} (bank_id, canonical_name, first_seen, last_seen, mention_count)
|
||||
VALUES ($1, $2, $3, $4, 1)
|
||||
ON CONFLICT (bank_id, LOWER(canonical_name))
|
||||
DO UPDATE SET
|
||||
mention_count = entities.mention_count + 1,
|
||||
mention_count = {fq_table("entities")}.mention_count + 1,
|
||||
last_seen = EXCLUDED.last_seen
|
||||
RETURNING id
|
||||
""",
|
||||
bank_id, entity_text, event_date, event_date
|
||||
bank_id,
|
||||
entity_text,
|
||||
event_date,
|
||||
event_date,
|
||||
)
|
||||
return entity_id
|
||||
|
||||
@@ -402,25 +431,27 @@ class EntityResolver:
|
||||
async with acquire_with_retry(self.pool) as conn:
|
||||
# Insert unit-entity link
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO unit_entities (unit_id, entity_id)
|
||||
f"""
|
||||
INSERT INTO {fq_table("unit_entities")} (unit_id, entity_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
unit_id, entity_id
|
||||
unit_id,
|
||||
entity_id,
|
||||
)
|
||||
|
||||
# Update co-occurrence cache: find other entities in this unit
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
f"""
|
||||
SELECT entity_id
|
||||
FROM unit_entities
|
||||
FROM {fq_table("unit_entities")}
|
||||
WHERE unit_id = $1 AND entity_id != $2
|
||||
""",
|
||||
unit_id, entity_id
|
||||
unit_id,
|
||||
entity_id,
|
||||
)
|
||||
|
||||
other_entities = [row['entity_id'] for row in rows]
|
||||
other_entities = [row["entity_id"] for row in rows]
|
||||
|
||||
# Update co-occurrences for each pair
|
||||
for other_entity_id in other_entities:
|
||||
@@ -442,18 +473,19 @@ class EntityResolver:
|
||||
entity_id_1, entity_id_2 = entity_id_2, entity_id_1
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO entity_cooccurrences (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
|
||||
f"""
|
||||
INSERT INTO {fq_table("entity_cooccurrences")} (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
|
||||
VALUES ($1, $2, 1, NOW())
|
||||
ON CONFLICT (entity_id_1, entity_id_2)
|
||||
DO UPDATE SET
|
||||
cooccurrence_count = entity_cooccurrences.cooccurrence_count + 1,
|
||||
cooccurrence_count = {fq_table("entity_cooccurrences")}.cooccurrence_count + 1,
|
||||
last_cooccurred = NOW()
|
||||
""",
|
||||
entity_id_1, entity_id_2
|
||||
entity_id_1,
|
||||
entity_id_2,
|
||||
)
|
||||
|
||||
async def link_units_to_entities_batch(self, unit_entity_pairs: List[tuple[str, str]], conn=None):
|
||||
async def link_units_to_entities_batch(self, unit_entity_pairs: list[tuple[str, str]], conn=None):
|
||||
"""
|
||||
Link multiple memory units to entities in batch (MUCH faster than sequential).
|
||||
|
||||
@@ -472,15 +504,15 @@ class EntityResolver:
|
||||
else:
|
||||
return await self._link_units_to_entities_batch_impl(conn, unit_entity_pairs)
|
||||
|
||||
async def _link_units_to_entities_batch_impl(self, conn, unit_entity_pairs: List[tuple[str, str]]):
|
||||
async def _link_units_to_entities_batch_impl(self, conn, unit_entity_pairs: list[tuple[str, str]]):
|
||||
# Batch insert all unit-entity links
|
||||
await conn.executemany(
|
||||
"""
|
||||
INSERT INTO unit_entities (unit_id, entity_id)
|
||||
f"""
|
||||
INSERT INTO {fq_table("unit_entities")} (unit_id, entity_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
unit_entity_pairs
|
||||
unit_entity_pairs,
|
||||
)
|
||||
|
||||
# Build map of unit -> entities for co-occurrence calculation
|
||||
@@ -497,7 +529,7 @@ class EntityResolver:
|
||||
entity_list = list(entity_ids) # Convert set to list for iteration
|
||||
# For each pair of entities in this unit, create co-occurrence
|
||||
for i, entity_id_1 in enumerate(entity_list):
|
||||
for entity_id_2 in entity_list[i+1:]:
|
||||
for entity_id_2 in entity_list[i + 1 :]:
|
||||
# Skip if same entity (shouldn't happen with set, but be safe)
|
||||
if entity_id_1 == entity_id_2:
|
||||
continue
|
||||
@@ -508,20 +540,20 @@ class EntityResolver:
|
||||
|
||||
# Batch update co-occurrences
|
||||
if cooccurrence_pairs:
|
||||
now = datetime.now(timezone.utc)
|
||||
now = datetime.now(UTC)
|
||||
await conn.executemany(
|
||||
"""
|
||||
INSERT INTO entity_cooccurrences (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
|
||||
f"""
|
||||
INSERT INTO {fq_table("entity_cooccurrences")} (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (entity_id_1, entity_id_2)
|
||||
DO UPDATE SET
|
||||
cooccurrence_count = entity_cooccurrences.cooccurrence_count + 1,
|
||||
cooccurrence_count = {fq_table("entity_cooccurrences")}.cooccurrence_count + 1,
|
||||
last_cooccurred = EXCLUDED.last_cooccurred
|
||||
""",
|
||||
[(e1, e2, 1, now) for e1, e2 in cooccurrence_pairs]
|
||||
[(e1, e2, 1, now) for e1, e2 in cooccurrence_pairs],
|
||||
)
|
||||
|
||||
async def get_units_by_entity(self, entity_id: str, limit: int = 100) -> List[str]:
|
||||
async def get_units_by_entity(self, entity_id: str, limit: int = 100) -> list[str]:
|
||||
"""
|
||||
Get all units that mention an entity.
|
||||
|
||||
@@ -534,22 +566,23 @@ class EntityResolver:
|
||||
"""
|
||||
async with acquire_with_retry(self.pool) as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
f"""
|
||||
SELECT unit_id
|
||||
FROM unit_entities
|
||||
FROM {fq_table("unit_entities")}
|
||||
WHERE entity_id = $1
|
||||
ORDER BY unit_id
|
||||
LIMIT $2
|
||||
""",
|
||||
entity_id, limit
|
||||
entity_id,
|
||||
limit,
|
||||
)
|
||||
return [row['unit_id'] for row in rows]
|
||||
return [row["unit_id"] for row in rows]
|
||||
|
||||
async def get_entity_by_text(
|
||||
self,
|
||||
bank_id: str,
|
||||
entity_text: str,
|
||||
) -> Optional[str]:
|
||||
) -> str | None:
|
||||
"""
|
||||
Find an entity by text (for query resolution).
|
||||
|
||||
@@ -562,14 +595,15 @@ class EntityResolver:
|
||||
"""
|
||||
async with acquire_with_retry(self.pool) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id FROM entities
|
||||
f"""
|
||||
SELECT id FROM {fq_table("entities")}
|
||||
WHERE bank_id = $1
|
||||
AND canonical_name ILIKE $2
|
||||
ORDER BY mention_count DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
bank_id, entity_text
|
||||
bank_id,
|
||||
entity_text,
|
||||
)
|
||||
|
||||
return row['id'] if row else None
|
||||
return row["id"] if row else None
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
"""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.
|
||||
"""
|
||||
...
|
||||
@@ -1,12 +1,22 @@
|
||||
"""
|
||||
LLM wrapper for unified configuration across providers.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import asyncio
|
||||
from typing import Optional, Any, Dict, List
|
||||
from openai import AsyncOpenAI, RateLimitError, APIError, APIStatusError, LengthFinishReasonError
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from google import genai
|
||||
from google.genai import errors as genai_errors
|
||||
from google.genai import types as genai_types
|
||||
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
|
||||
|
||||
# Seed applied to every Groq request for deterministic behavior.
|
||||
DEFAULT_LLM_SEED = 4242
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,11 +35,16 @@ class OutputTooLongError(Exception):
|
||||
to allow callers to handle output length issues without depending on
|
||||
provider-specific implementations.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LLMConfig:
|
||||
"""Configuration for an LLM provider."""
|
||||
class LLMProvider:
|
||||
"""
|
||||
Unified LLM provider.
|
||||
|
||||
Supports OpenAI, Groq, Ollama (OpenAI-compatible), and Gemini.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -37,26 +52,28 @@ class LLMConfig:
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
reasoning_effort: str = "low",
|
||||
):
|
||||
"""
|
||||
Initialize LLM configuration.
|
||||
Initialize LLM provider.
|
||||
|
||||
Args:
|
||||
provider: Provider name ("openai", "groq", "ollama"). Required.
|
||||
api_key: API key. Required.
|
||||
base_url: Base URL. Required.
|
||||
model: Model name. Required.
|
||||
provider: Provider name ("openai", "groq", "ollama", "gemini").
|
||||
api_key: API key.
|
||||
base_url: Base URL for the API.
|
||||
model: Model name.
|
||||
reasoning_effort: Reasoning effort level for supported providers.
|
||||
"""
|
||||
self.provider = provider.lower()
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
self.model = model
|
||||
self.reasoning_effort = reasoning_effort
|
||||
|
||||
# Validate provider
|
||||
if self.provider not in ["openai", "groq", "ollama"]:
|
||||
raise ValueError(
|
||||
f"Invalid LLM provider: {self.provider}. Must be 'openai', 'groq', or 'ollama'."
|
||||
)
|
||||
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)}")
|
||||
|
||||
# Set default base URLs
|
||||
if not self.base_url:
|
||||
@@ -67,142 +84,250 @@ class LLMConfig:
|
||||
|
||||
# Validate API key (not needed for ollama)
|
||||
if self.provider != "ollama" and not self.api_key:
|
||||
raise ValueError(
|
||||
f"API key not found for {self.provider}"
|
||||
)
|
||||
raise ValueError(f"API key not found for {self.provider}")
|
||||
|
||||
# Create client (private - use .call() method instead)
|
||||
# Disable automatic retries - we handle retries in the call() method
|
||||
if self.provider == "ollama":
|
||||
# Create client based on provider
|
||||
if self.provider == "gemini":
|
||||
self._gemini_client = genai.Client(api_key=self.api_key)
|
||||
self._client = None
|
||||
elif self.provider == "ollama":
|
||||
self._client = AsyncOpenAI(api_key="ollama", base_url=self.base_url, max_retries=0)
|
||||
elif self.base_url:
|
||||
self._client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url, max_retries=0)
|
||||
self._gemini_client = None
|
||||
else:
|
||||
self._client = AsyncOpenAI(api_key=self.api_key, max_retries=0)
|
||||
# Only pass base_url if it's set (OpenAI uses default URL otherwise)
|
||||
client_kwargs = {"api_key": self.api_key, "max_retries": 0}
|
||||
if self.base_url:
|
||||
client_kwargs["base_url"] = self.base_url
|
||||
self._client = AsyncOpenAI(**client_kwargs) # type: ignore[invalid-argument-type] - dict kwargs
|
||||
self._gemini_client = None
|
||||
|
||||
logger.info(
|
||||
f"Initialized LLM: provider={self.provider}, model={self.model}, base_url={self.base_url}"
|
||||
)
|
||||
async def verify_connection(self) -> None:
|
||||
"""
|
||||
Verify that the LLM provider is configured correctly by making a simple test call.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the connection test fails.
|
||||
"""
|
||||
try:
|
||||
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_retries=2,
|
||||
initial_backoff=0.5,
|
||||
max_backoff=2.0,
|
||||
)
|
||||
# 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
|
||||
|
||||
async def call(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
response_format: Optional[Any] = None,
|
||||
messages: list[dict[str, str]],
|
||||
response_format: Any | None = None,
|
||||
max_completion_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
scope: str = "memory",
|
||||
max_retries: int = 10,
|
||||
initial_backoff: float = 1.0,
|
||||
max_backoff: float = 60.0,
|
||||
skip_validation: bool = False,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""
|
||||
Make an LLM API call with consistent configuration and retry logic.
|
||||
Make an LLM API call with retry logic.
|
||||
|
||||
Args:
|
||||
messages: List of message dicts with 'role' and 'content'
|
||||
response_format: Optional Pydantic model for structured output
|
||||
scope: Scope identifier (e.g., 'memory', 'judge') for future tracking
|
||||
max_retries: Maximum number of retry attempts (default: 5)
|
||||
initial_backoff: Initial backoff time in seconds (default: 1.0)
|
||||
max_backoff: Maximum backoff time in seconds (default: 60.0)
|
||||
**kwargs: Additional parameters to pass to the API (temperature, max_tokens, etc.)
|
||||
messages: List of message dicts with 'role' and 'content'.
|
||||
response_format: Optional Pydantic model for structured output.
|
||||
max_completion_tokens: Maximum tokens in response.
|
||||
temperature: Sampling temperature (0.0-2.0).
|
||||
scope: Scope identifier for tracking.
|
||||
max_retries: Maximum retry attempts.
|
||||
initial_backoff: Initial backoff time in seconds.
|
||||
max_backoff: Maximum backoff time in seconds.
|
||||
skip_validation: Return raw JSON without Pydantic validation.
|
||||
|
||||
Returns:
|
||||
Parsed response if response_format is provided, otherwise the text content
|
||||
Parsed response if response_format is provided, otherwise text content.
|
||||
|
||||
Raises:
|
||||
Exception: Re-raises any API errors after all retries are exhausted
|
||||
OutputTooLongError: If output exceeds token limits.
|
||||
Exception: Re-raises API errors after retries exhausted.
|
||||
"""
|
||||
# Use global semaphore to limit concurrent requests
|
||||
async with _global_llm_semaphore:
|
||||
start_time = time.time()
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
call_params = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
**kwargs
|
||||
}
|
||||
|
||||
# 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"])
|
||||
|
||||
# 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:
|
||||
max_completion_tokens = 32000
|
||||
# For reasoning models, max_completion_tokens includes reasoning + output tokens
|
||||
# Enforce minimum of 16000 to ensure enough space for both
|
||||
if is_reasoning_model and max_completion_tokens < 16000:
|
||||
max_completion_tokens = 16000
|
||||
call_params["max_completion_tokens"] = max_completion_tokens
|
||||
|
||||
# GPT-5/o1/o3 family doesn't support custom temperature (only default 1)
|
||||
if temperature is not None and not is_reasoning_model:
|
||||
call_params["temperature"] = temperature
|
||||
|
||||
# Set reasoning_effort for reasoning models (OpenAI gpt-5, o1, o3)
|
||||
if is_reasoning_model:
|
||||
call_params["reasoning_effort"] = self.reasoning_effort
|
||||
|
||||
# Provider-specific parameters
|
||||
if self.provider == "groq":
|
||||
call_params["extra_body"] = {
|
||||
"service_tier": "auto",
|
||||
"reasoning_effort": "low", # Reduce reasoning overhead
|
||||
"include_reasoning": False, # Disable hidden reasoning tokens
|
||||
}
|
||||
call_params["seed"] = DEFAULT_LLM_SEED
|
||||
extra_body = {"service_tier": "auto"}
|
||||
# Only add reasoning parameters for reasoning models
|
||||
if is_reasoning_model:
|
||||
extra_body["include_reasoning"] = False
|
||||
call_params["extra_body"] = extra_body
|
||||
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
# Use the appropriate response format
|
||||
if response_format is not None:
|
||||
# Use JSON mode instead of strict parse for flexibility with optional fields
|
||||
# This allows the LLM to omit optional fields without validation errors
|
||||
import json
|
||||
|
||||
# Add schema to the system message
|
||||
if hasattr(response_format, 'model_json_schema'):
|
||||
# Add schema to system message for JSON mode
|
||||
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)}"
|
||||
|
||||
# Add schema to the system message if present, otherwise prepend as user message
|
||||
if call_params['messages'] and call_params['messages'][0].get('role') == 'system':
|
||||
call_params['messages'][0]['content'] += schema_msg
|
||||
else:
|
||||
# No system message, add schema instruction to first user message
|
||||
if 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)
|
||||
|
||||
# Parse the JSON response
|
||||
content = response.choices[0].message.content
|
||||
json_data = json.loads(content)
|
||||
|
||||
# Return raw JSON if skip_validation is True, otherwise validate with Pydantic
|
||||
# Log raw LLM response for debugging JSON parse issues
|
||||
try:
|
||||
json_data = json.loads(content)
|
||||
except json.JSONDecodeError as json_err:
|
||||
# Truncate content for logging (first 500 and last 200 chars)
|
||||
content_preview = content[:500] if content else "<empty>"
|
||||
if content and len(content) > 700:
|
||||
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
|
||||
logger.warning(
|
||||
f"JSON parse error from LLM response (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
|
||||
f" Model: {self.provider}/{self.model}\n"
|
||||
f" Content length: {len(content) if content else 0} chars\n"
|
||||
f" Content preview: {content_preview!r}\n"
|
||||
f" Finish reason: {response.choices[0].finish_reason if response.choices else 'unknown'}"
|
||||
)
|
||||
# Retry on JSON parse errors - LLM may return valid JSON on next attempt
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
last_exception = json_err
|
||||
continue
|
||||
else:
|
||||
logger.error(f"JSON parse error after {max_retries + 1} attempts, giving up")
|
||||
raise
|
||||
|
||||
if skip_validation:
|
||||
result = json_data
|
||||
else:
|
||||
result = response_format.model_validate(json_data)
|
||||
else:
|
||||
# Standard completion and return text content
|
||||
response = await self._client.chat.completions.create(**call_params)
|
||||
result = response.choices[0].message.content
|
||||
|
||||
# Log call details only if it takes more than 5 seconds
|
||||
# Log slow calls
|
||||
duration = time.time() - start_time
|
||||
usage = response.usage
|
||||
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
|
||||
cache_info = f", cached_tokens={cached_tokens}" if cached_tokens > 0 else ""
|
||||
logger.info(
|
||||
f"slow llm call: model={self.provider}/{self.model}, "
|
||||
f"input_tokens={usage.prompt_tokens}, output_tokens={usage.completion_tokens}, "
|
||||
f"total_tokens={usage.total_tokens}, time={duration:.3f}s, ratio out/in={ratio:.2f}"
|
||||
f"total_tokens={usage.total_tokens}{cache_info}, time={duration:.3f}s, ratio out/in={ratio:.2f}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except LengthFinishReasonError as e:
|
||||
# Output exceeded token limits - raise bridge exception for caller to handle
|
||||
logger.warning(f"LLM output exceeded token limits: {str(e)}")
|
||||
raise OutputTooLongError(
|
||||
f"LLM output exceeded token limits. Input may need to be split into smaller chunks."
|
||||
"LLM output exceeded token limits. Input may need to be split into smaller chunks."
|
||||
) from e
|
||||
|
||||
except APIStatusError as e:
|
||||
except APIConnectionError as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
# Calculate exponential backoff with jitter
|
||||
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
|
||||
# Add jitter (±20%)
|
||||
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:
|
||||
logger.error(f"Connection error after {max_retries + 1} attempts: {str(e)}")
|
||||
raise
|
||||
|
||||
except APIStatusError as e:
|
||||
# Fast fail only on 401 (unauthorized) and 403 (forbidden) - 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)}")
|
||||
raise
|
||||
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
|
||||
sleep_time = backoff + jitter
|
||||
|
||||
# Only log if it's a non-retryable error or final attempt
|
||||
# Silent retry for common transient errors like capacity exceeded
|
||||
await asyncio.sleep(sleep_time)
|
||||
else:
|
||||
# Log only on final failed attempt
|
||||
logger.error(f"API error after {max_retries + 1} attempts: {str(e)}")
|
||||
raise
|
||||
|
||||
@@ -210,60 +335,309 @@ class LLMConfig:
|
||||
logger.error(f"Unexpected error during LLM call: {type(e).__name__}: {str(e)}")
|
||||
raise
|
||||
|
||||
# This should never be reached, but just in case
|
||||
if last_exception:
|
||||
raise last_exception
|
||||
raise RuntimeError(f"LLM call failed after all retries with no exception captured")
|
||||
raise RuntimeError("LLM call failed after all retries with no exception captured")
|
||||
|
||||
async def _call_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")
|
||||
|
||||
async def _call_gemini(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
response_format: Any | None,
|
||||
max_retries: int,
|
||||
initial_backoff: float,
|
||||
max_backoff: float,
|
||||
skip_validation: bool,
|
||||
start_time: float,
|
||||
) -> Any:
|
||||
"""Handle Gemini-specific API calls."""
|
||||
# Convert OpenAI-style messages to Gemini format
|
||||
system_instruction = None
|
||||
gemini_contents = []
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content", "")
|
||||
|
||||
if role == "system":
|
||||
if system_instruction:
|
||||
system_instruction += "\n\n" + content
|
||||
else:
|
||||
system_instruction = content
|
||||
elif role == "assistant":
|
||||
gemini_contents.append(genai_types.Content(role="model", parts=[genai_types.Part(text=content)]))
|
||||
else:
|
||||
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
|
||||
|
||||
# Add JSON schema instruction if response_format is provided
|
||||
if response_format is not None and hasattr(response_format, "model_json_schema"):
|
||||
schema = response_format.model_json_schema()
|
||||
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
|
||||
if system_instruction:
|
||||
system_instruction += schema_msg
|
||||
else:
|
||||
system_instruction = schema_msg
|
||||
|
||||
# Build generation config
|
||||
config_kwargs = {}
|
||||
if system_instruction:
|
||||
config_kwargs["system_instruction"] = system_instruction
|
||||
if response_format is not None:
|
||||
config_kwargs["response_mime_type"] = "application/json"
|
||||
config_kwargs["response_schema"] = response_format
|
||||
|
||||
generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
|
||||
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
response = await self._gemini_client.aio.models.generate_content(
|
||||
model=self.model,
|
||||
contents=gemini_contents,
|
||||
config=generation_config,
|
||||
)
|
||||
|
||||
content = response.text
|
||||
|
||||
# Handle empty response
|
||||
if content is None:
|
||||
block_reason = None
|
||||
if hasattr(response, "candidates") and response.candidates:
|
||||
candidate = response.candidates[0]
|
||||
if hasattr(candidate, "finish_reason"):
|
||||
block_reason = candidate.finish_reason
|
||||
|
||||
if attempt < max_retries:
|
||||
logger.warning(f"Gemini returned empty response (reason: {block_reason}), retrying...")
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
else:
|
||||
raise RuntimeError(f"Gemini returned empty response after {max_retries + 1} attempts")
|
||||
|
||||
if response_format is not None:
|
||||
json_data = json.loads(content)
|
||||
if skip_validation:
|
||||
result = json_data
|
||||
else:
|
||||
result = response_format.model_validate(json_data)
|
||||
else:
|
||||
result = content
|
||||
|
||||
# Log slow calls
|
||||
duration = time.time() - start_time
|
||||
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}, "
|
||||
f"input_tokens={usage.prompt_token_count}, output_tokens={usage.candidates_token_count}, "
|
||||
f"time={duration:.3f}s"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
logger.warning("Gemini returned invalid JSON, retrying...")
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
else:
|
||||
logger.error(f"Gemini returned invalid JSON after {max_retries + 1} attempts")
|
||||
raise
|
||||
|
||||
except genai_errors.APIError as e:
|
||||
# Fast fail 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)}")
|
||||
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):
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
|
||||
await asyncio.sleep(backoff + jitter)
|
||||
else:
|
||||
logger.error(f"Gemini API error after {max_retries + 1} attempts: {str(e)}")
|
||||
raise
|
||||
else:
|
||||
logger.error(f"Gemini API error: {type(e).__name__}: {str(e)}")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during Gemini call: {type(e).__name__}: {str(e)}")
|
||||
raise
|
||||
|
||||
if last_exception:
|
||||
raise last_exception
|
||||
raise RuntimeError("Gemini call failed after all retries")
|
||||
|
||||
@classmethod
|
||||
def for_memory(cls) -> "LLMConfig":
|
||||
"""Create configuration for memory operations from environment variables."""
|
||||
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")
|
||||
base_url = os.getenv("HINDSIGHT_API_LLM_BASE_URL")
|
||||
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")
|
||||
|
||||
# Set default base URL if not provided
|
||||
if not base_url:
|
||||
if provider == "groq":
|
||||
base_url = "https://api.groq.com/openai/v1"
|
||||
elif provider == "ollama":
|
||||
base_url = "http://localhost:11434/v1"
|
||||
else:
|
||||
base_url = ""
|
||||
|
||||
return cls(
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
)
|
||||
return cls(provider=provider, api_key=api_key, base_url=base_url, model=model, reasoning_effort="low")
|
||||
|
||||
@classmethod
|
||||
def for_judge(cls) -> "LLMConfig":
|
||||
"""
|
||||
Create configuration for judge/evaluator operations from environment variables.
|
||||
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"))
|
||||
|
||||
Falls back to memory LLM config if judge-specific config not set.
|
||||
"""
|
||||
# Check if judge-specific config exists, otherwise fall back to memory config
|
||||
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"))
|
||||
base_url = os.getenv("HINDSIGHT_API_JUDGE_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL"))
|
||||
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"))
|
||||
|
||||
# Set default base URL if not provided
|
||||
if not base_url:
|
||||
if provider == "groq":
|
||||
base_url = "https://api.groq.com/openai/v1"
|
||||
elif provider == "ollama":
|
||||
base_url = "http://localhost:11434/v1"
|
||||
else:
|
||||
base_url = ""
|
||||
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,
|
||||
)
|
||||
|
||||
# Backwards compatibility alias
|
||||
LLMConfig = LLMProvider
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,12 @@ Query analysis abstraction for the memory system.
|
||||
Provides an interface for analyzing natural language queries to extract
|
||||
structured information like temporal constraints.
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
import logging
|
||||
import re
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,6 +21,7 @@ class TemporalConstraint(BaseModel):
|
||||
|
||||
Represents a time range with start and end dates.
|
||||
"""
|
||||
|
||||
start_date: datetime = Field(description="Start of the time range (inclusive)")
|
||||
end_date: datetime = Field(description="End of the time range (inclusive)")
|
||||
|
||||
@@ -32,9 +35,9 @@ class QueryAnalysis(BaseModel):
|
||||
|
||||
Contains extracted structured information like temporal constraints.
|
||||
"""
|
||||
temporal_constraint: Optional[TemporalConstraint] = Field(
|
||||
default=None,
|
||||
description="Extracted temporal constraint, if any"
|
||||
|
||||
temporal_constraint: TemporalConstraint | None = Field(
|
||||
default=None, description="Extracted temporal constraint, if any"
|
||||
)
|
||||
|
||||
|
||||
@@ -57,9 +60,7 @@ class QueryAnalyzer(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def analyze(
|
||||
self, query: str, reference_date: Optional[datetime] = None
|
||||
) -> QueryAnalysis:
|
||||
def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAnalysis:
|
||||
"""
|
||||
Analyze a natural language query.
|
||||
|
||||
@@ -73,6 +74,205 @@ class QueryAnalyzer(ABC):
|
||||
pass
|
||||
|
||||
|
||||
class DateparserQueryAnalyzer(QueryAnalyzer):
|
||||
"""
|
||||
Query analyzer using dateparser library.
|
||||
|
||||
Uses dateparser to extract temporal expressions from natural language
|
||||
queries. Supports 200+ languages including English, Spanish, Italian,
|
||||
French, German, etc.
|
||||
|
||||
Performance:
|
||||
- ~10-50ms per query
|
||||
- No model loading required
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize dateparser query analyzer."""
|
||||
self._search_dates = None
|
||||
|
||||
def load(self) -> None:
|
||||
"""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:
|
||||
"""
|
||||
Analyze query using dateparser.
|
||||
|
||||
Extracts temporal expressions from the query text. Supports multiple
|
||||
languages automatically.
|
||||
|
||||
Args:
|
||||
query: Natural language query (any language)
|
||||
reference_date: Reference date for relative terms (defaults to now)
|
||||
|
||||
Returns:
|
||||
QueryAnalysis with temporal_constraint if found
|
||||
"""
|
||||
self.load()
|
||||
|
||||
if reference_date is None:
|
||||
reference_date = datetime.now()
|
||||
|
||||
# Check for period expressions first (these need special handling)
|
||||
query_lower = query.lower()
|
||||
period_result = self._extract_period(query_lower, reference_date)
|
||||
if period_result is not None:
|
||||
return QueryAnalysis(temporal_constraint=period_result)
|
||||
|
||||
# Use dateparser's search_dates to find temporal expressions
|
||||
settings = {
|
||||
"RELATIVE_BASE": reference_date,
|
||||
"PREFER_DATES_FROM": "past",
|
||||
"RETURN_AS_TIMEZONE_AWARE": False,
|
||||
}
|
||||
|
||||
results = self._search_dates(query, settings=settings)
|
||||
|
||||
if not results:
|
||||
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]
|
||||
|
||||
if not valid_results:
|
||||
return QueryAnalysis(temporal_constraint=None)
|
||||
|
||||
# Use the first valid date found
|
||||
_, parsed_date = valid_results[0]
|
||||
|
||||
# Create constraint for single day
|
||||
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))
|
||||
|
||||
def _extract_period(self, query: str, reference_date: datetime) -> TemporalConstraint | None:
|
||||
"""
|
||||
Extract period-based temporal expressions (week, month, year, weekend).
|
||||
|
||||
These need special handling as they represent date ranges, not single dates.
|
||||
Supports multiple languages.
|
||||
"""
|
||||
|
||||
def constraint(start: datetime, end: datetime) -> TemporalConstraint:
|
||||
return TemporalConstraint(
|
||||
start_date=start.replace(hour=0, minute=0, second=0, microsecond=0),
|
||||
end_date=end.replace(hour=23, minute=59, second=59, microsecond=999999),
|
||||
)
|
||||
|
||||
# Yesterday patterns (English, Spanish, Italian, French, German)
|
||||
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):
|
||||
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):
|
||||
# "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):
|
||||
# "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):
|
||||
# "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):
|
||||
# "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):
|
||||
# "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):
|
||||
# "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,
|
||||
):
|
||||
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,
|
||||
):
|
||||
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,
|
||||
):
|
||||
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,
|
||||
):
|
||||
days_since_sat = (reference_date.weekday() + 2) % 7
|
||||
if days_since_sat == 0:
|
||||
days_since_sat = 7
|
||||
sat = reference_date - timedelta(days=days_since_sat)
|
||||
return constraint(sat, sat + timedelta(days=1))
|
||||
|
||||
# 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,
|
||||
}
|
||||
|
||||
for pattern, month_num in month_patterns.items():
|
||||
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)
|
||||
if month_num == 12:
|
||||
end = datetime(year, 12, 31)
|
||||
else:
|
||||
end = datetime(year, month_num + 1, 1) - timedelta(days=1)
|
||||
return constraint(start, end)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class TransformerQueryAnalyzer(QueryAnalyzer):
|
||||
"""
|
||||
Query analyzer using T5-based generative models.
|
||||
@@ -85,11 +285,7 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
|
||||
- Model size: ~80M params (~300MB download)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str = "google/flan-t5-small",
|
||||
device: str = "cpu"
|
||||
):
|
||||
def __init__(self, model_name: str = "google/flan-t5-small", device: str = "cpu"):
|
||||
"""
|
||||
Initialize T5 query analyzer.
|
||||
|
||||
@@ -110,11 +306,10 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
|
||||
return
|
||||
|
||||
try:
|
||||
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
|
||||
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"transformers is required for TransformerQueryAnalyzer. "
|
||||
"Install it with: pip install transformers"
|
||||
"transformers is required for TransformerQueryAnalyzer. Install it with: pip install transformers"
|
||||
)
|
||||
|
||||
logger.info(f"Loading query analyzer model: {self.model_name}...")
|
||||
@@ -128,13 +323,96 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
|
||||
"""Lazy load the T5 model for temporal extraction (calls load())."""
|
||||
self.load()
|
||||
|
||||
def analyze(
|
||||
self, query: str, reference_date: Optional[datetime] = None
|
||||
) -> QueryAnalysis:
|
||||
def _extract_with_rules(self, query: str, reference_date: datetime) -> TemporalConstraint | None:
|
||||
"""
|
||||
Analyze query using T5 model.
|
||||
Extract temporal expressions using rule-based patterns.
|
||||
|
||||
Uses T5 to generate structured temporal output directly.
|
||||
Handles common patterns reliably and fast. Returns None for
|
||||
patterns that need model-based extraction.
|
||||
"""
|
||||
import re
|
||||
|
||||
query_lower = query.lower()
|
||||
|
||||
def get_last_weekday(weekday: int) -> datetime:
|
||||
days_ago = (reference_date.weekday() - weekday) % 7
|
||||
if days_ago == 0:
|
||||
days_ago = 7
|
||||
return reference_date - timedelta(days=days_ago)
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
# Yesterday
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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):
|
||||
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}
|
||||
for name, num in weekdays.items():
|
||||
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,
|
||||
}
|
||||
for name, num in months.items():
|
||||
match = re.search(rf"\b{name}\s+(\d{{4}})\b", query_lower)
|
||||
if match:
|
||||
year = int(match.group(1))
|
||||
if num == 12:
|
||||
last_day = 31
|
||||
else:
|
||||
last_day = (datetime(year, num + 1, 1) - timedelta(days=1)).day
|
||||
return constraint(datetime(year, num, 1), datetime(year, num, last_day))
|
||||
|
||||
return None
|
||||
|
||||
def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAnalysis:
|
||||
"""
|
||||
Analyze query for temporal expressions.
|
||||
|
||||
Uses rule-based extraction for common patterns (fast & reliable),
|
||||
falls back to T5 model for complex/unusual patterns.
|
||||
|
||||
Args:
|
||||
query: Natural language query
|
||||
@@ -146,17 +424,30 @@ class TransformerQueryAnalyzer(QueryAnalyzer):
|
||||
if reference_date is None:
|
||||
reference_date = datetime.now()
|
||||
|
||||
# Try rule-based extraction first (handles 90%+ of cases)
|
||||
result = self._extract_with_rules(query, reference_date)
|
||||
if result is not None:
|
||||
return QueryAnalysis(temporal_constraint=result)
|
||||
|
||||
# Fall back to T5 model for unusual patterns
|
||||
self._load_model()
|
||||
|
||||
# Build prompt for T5 to generate structured temporal output
|
||||
# Use fill-in-the-blank format which T5 handles better
|
||||
prompt = f"""Today is {reference_date.strftime('%Y-%m-%d')}. Convert temporal expressions to date ranges.
|
||||
# Helper to calculate example dates
|
||||
def get_last_weekday(weekday: int) -> datetime:
|
||||
days_ago = (reference_date.weekday() - weekday) % 7
|
||||
if days_ago == 0:
|
||||
days_ago = 7
|
||||
return reference_date - timedelta(days=days_ago)
|
||||
|
||||
yesterday = reference_date - timedelta(days=1)
|
||||
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".
|
||||
|
||||
June 2024 = 2024-06-01 to 2024-06-30
|
||||
March 2023 = 2023-03-01 to 2023-03-31
|
||||
dogs in June 2023 = 2023-06-01 to 2023-06-30
|
||||
last year = {reference_date.year - 1}-01-01 to {reference_date.year - 1}-12-31
|
||||
events in January 2020 = 2020-01-01 to 2020-01-31
|
||||
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} ="""
|
||||
|
||||
@@ -165,13 +456,7 @@ what is the weather = none
|
||||
inputs = {k: v.to(self.device) for k, v in inputs.items()}
|
||||
|
||||
with self._no_grad():
|
||||
outputs = self._model.generate(
|
||||
**inputs,
|
||||
max_new_tokens=30,
|
||||
num_beams=3,
|
||||
do_sample=False,
|
||||
temperature=1.0
|
||||
)
|
||||
outputs = self._model.generate(**inputs, max_new_tokens=30, num_beams=3, do_sample=False, temperature=1.0)
|
||||
|
||||
result = self._tokenizer.decode(outputs[0], skip_special_tokens=True).strip()
|
||||
|
||||
@@ -183,14 +468,14 @@ what is the weather = none
|
||||
"""Get torch.no_grad context manager."""
|
||||
try:
|
||||
import torch
|
||||
|
||||
return torch.no_grad()
|
||||
except ImportError:
|
||||
from contextlib import nullcontext
|
||||
|
||||
return nullcontext()
|
||||
|
||||
def _parse_generated_output(
|
||||
self, result: str, reference_date: datetime
|
||||
) -> Optional[TemporalConstraint]:
|
||||
def _parse_generated_output(self, result: str, reference_date: datetime) -> TemporalConstraint | None:
|
||||
"""
|
||||
Parse T5 generated output into TemporalConstraint.
|
||||
|
||||
@@ -209,7 +494,8 @@ what is the weather = none
|
||||
try:
|
||||
# Parse "YYYY-MM-DD to YYYY-MM-DD"
|
||||
import re
|
||||
pattern = r'(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})'
|
||||
|
||||
pattern = r"(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})"
|
||||
match = re.search(pattern, result, re.IGNORECASE)
|
||||
|
||||
if match:
|
||||
@@ -230,7 +516,7 @@ what is the weather = none
|
||||
|
||||
return TemporalConstraint(start_date=start_date, end_date=end_date)
|
||||
|
||||
except (ValueError, AttributeError) as e:
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
@@ -6,33 +6,29 @@ API response models should be kept separate and convert from these core models t
|
||||
API stability even if internal models change.
|
||||
"""
|
||||
|
||||
from typing import Optional, List, Dict, Any
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# Valid fact types for recall operations (excludes 'observation' which is internal)
|
||||
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "opinion"])
|
||||
|
||||
|
||||
class PersonalityTraits(BaseModel):
|
||||
class DispositionTraits(BaseModel):
|
||||
"""
|
||||
Personality traits for a bank using the Big Five model.
|
||||
Disposition traits for a memory bank.
|
||||
|
||||
All traits are scored 0.0-1.0 where higher values indicate stronger presence of the trait.
|
||||
All traits are scored 1-5 where:
|
||||
- skepticism: 1=trusting, 5=skeptical (how much to doubt or question information)
|
||||
- literalism: 1=flexible interpretation, 5=literal interpretation (how strictly to interpret information)
|
||||
- empathy: 1=detached, 5=empathetic (how much to consider emotional context)
|
||||
"""
|
||||
openness: float = Field(description="Openness to experience (0.0-1.0)")
|
||||
conscientiousness: float = Field(description="Conscientiousness and organization (0.0-1.0)")
|
||||
extraversion: float = Field(description="Extraversion and sociability (0.0-1.0)")
|
||||
agreeableness: float = Field(description="Agreeableness and cooperation (0.0-1.0)")
|
||||
neuroticism: float = Field(description="Emotional sensitivity and neuroticism (0.0-1.0)")
|
||||
bias_strength: float = Field(description="How strongly personality influences thinking (0.0-1.0)")
|
||||
|
||||
model_config = ConfigDict(json_schema_extra={
|
||||
"example": {
|
||||
"openness": 0.8,
|
||||
"conscientiousness": 0.6,
|
||||
"extraversion": 0.4,
|
||||
"agreeableness": 0.7,
|
||||
"neuroticism": 0.3,
|
||||
"bias_strength": 0.5
|
||||
}
|
||||
})
|
||||
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}})
|
||||
|
||||
|
||||
class MemoryFact(BaseModel):
|
||||
@@ -42,41 +38,44 @@ class MemoryFact(BaseModel):
|
||||
This represents a unit of information stored in the memory system,
|
||||
including both the content and metadata.
|
||||
"""
|
||||
model_config = ConfigDict(json_schema_extra={
|
||||
"example": {
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"text": "Alice works at Google on the AI team",
|
||||
"fact_type": "world",
|
||||
"entities": ["Alice", "Google"],
|
||||
"context": "work info",
|
||||
"occurred_start": "2024-01-15T10:30:00Z",
|
||||
"occurred_end": "2024-01-15T10:30:00Z",
|
||||
"mentioned_at": "2024-01-15T10:30:00Z",
|
||||
"document_id": "session_abc123",
|
||||
"metadata": {"source": "slack"},
|
||||
"chunk_id": "bank123_session_abc123_0",
|
||||
"activation": 0.95
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"text": "Alice works at Google on the AI team",
|
||||
"fact_type": "world",
|
||||
"entities": ["Alice", "Google"],
|
||||
"context": "work info",
|
||||
"occurred_start": "2024-01-15T10:30:00Z",
|
||||
"occurred_end": "2024-01-15T10:30:00Z",
|
||||
"mentioned_at": "2024-01-15T10:30:00Z",
|
||||
"document_id": "session_abc123",
|
||||
"metadata": {"source": "slack"},
|
||||
"chunk_id": "bank123_session_abc123_0",
|
||||
"activation": 0.95,
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
id: str = Field(description="Unique identifier for the memory fact")
|
||||
text: str = Field(description="The actual text content of the memory")
|
||||
fact_type: str = Field(description="Type of fact: 'world', 'bank', 'opinion', or 'observation'")
|
||||
entities: Optional[List[str]] = Field(None, description="Entity names mentioned in this fact")
|
||||
context: Optional[str] = Field(None, description="Additional context for the memory")
|
||||
occurred_start: Optional[str] = Field(None, description="ISO format date when the event started occurring")
|
||||
occurred_end: Optional[str] = Field(None, description="ISO format date when the event ended occurring")
|
||||
mentioned_at: Optional[str] = Field(None, description="ISO format date when the fact was mentioned/learned")
|
||||
document_id: Optional[str] = Field(None, description="ID of the document this memory belongs to")
|
||||
metadata: Optional[Dict[str, str]] = Field(None, description="User-defined metadata")
|
||||
chunk_id: Optional[str] = Field(None, description="ID of the chunk this fact was extracted from (format: bank_id_document_id_chunk_index)")
|
||||
|
||||
# Internal metrics (used by system but may not be exposed in API)
|
||||
activation: Optional[float] = Field(None, description="Internal activation score")
|
||||
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)"
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
@@ -89,35 +88,33 @@ class RecallResult(BaseModel):
|
||||
Contains a list of matching memory facts and optional trace information
|
||||
for debugging and transparency.
|
||||
"""
|
||||
model_config = ConfigDict(json_schema_extra={
|
||||
"example": {
|
||||
"results": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"text": "Alice works at Google on the AI team",
|
||||
"fact_type": "world",
|
||||
"context": "work info",
|
||||
"occurred_start": "2024-01-15T10:30:00Z",
|
||||
"occurred_end": "2024-01-15T10:30:00Z",
|
||||
"activation": 0.95
|
||||
}
|
||||
],
|
||||
"trace": {
|
||||
"query": "What did Alice say about machine learning?",
|
||||
"num_results": 1
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"results": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"text": "Alice works at Google on the AI team",
|
||||
"fact_type": "world",
|
||||
"context": "work info",
|
||||
"occurred_start": "2024-01-15T10:30:00Z",
|
||||
"occurred_end": "2024-01-15T10:30:00Z",
|
||||
"activation": 0.95,
|
||||
}
|
||||
],
|
||||
"trace": {"query": "What did Alice say about machine learning?", "num_results": 1},
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
results: List[MemoryFact] = Field(description="List of memory facts matching the query")
|
||||
trace: Optional[Dict[str, Any]] = Field(None, description="Trace information for debugging")
|
||||
entities: Optional[Dict[str, "EntityState"]] = Field(
|
||||
None,
|
||||
description="Entity states for entities mentioned in results (keyed by canonical name)"
|
||||
)
|
||||
chunks: Optional[Dict[str, ChunkInfo]] = Field(
|
||||
None,
|
||||
description="Chunks for facts, keyed by '{document_id}_{chunk_index}'"
|
||||
|
||||
results: list[MemoryFact] = Field(description="List of memory facts matching the query")
|
||||
trace: dict[str, Any] | None = Field(None, description="Trace information for debugging")
|
||||
entities: dict[str, "EntityState"] | None = Field(
|
||||
None, description="Entity states for entities mentioned in results (keyed by canonical name)"
|
||||
)
|
||||
chunks: dict[str, ChunkInfo] | None = Field(
|
||||
None, description="Chunks for facts, keyed by '{document_id}_{chunk_index}'"
|
||||
)
|
||||
|
||||
|
||||
@@ -128,37 +125,35 @@ 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"
|
||||
}
|
||||
],
|
||||
"agent": [],
|
||||
"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(
|
||||
description="Facts used to formulate the answer, organized by type (world, agent, opinion)"
|
||||
)
|
||||
new_opinions: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of newly formed opinions during reflection"
|
||||
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")
|
||||
|
||||
|
||||
class Opinion(BaseModel):
|
||||
@@ -168,12 +163,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")
|
||||
@@ -186,15 +181,15 @@ class EntityObservation(BaseModel):
|
||||
Observations are objective facts synthesized from multiple memory facts
|
||||
about an entity, without personality influence.
|
||||
"""
|
||||
model_config = ConfigDict(json_schema_extra={
|
||||
"example": {
|
||||
"text": "John is detail-oriented and works at Google",
|
||||
"mentioned_at": "2024-01-15T10:30:00Z"
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {"text": "John is detail-oriented and works at Google", "mentioned_at": "2024-01-15T10:30:00Z"}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
text: str = Field(description="The observation text")
|
||||
mentioned_at: Optional[str] = Field(None, description="ISO format date when this observation was created")
|
||||
mentioned_at: str | None = Field(None, description="ISO format date when this observation was created")
|
||||
|
||||
|
||||
class EntityState(BaseModel):
|
||||
@@ -203,20 +198,22 @@ class EntityState(BaseModel):
|
||||
|
||||
Contains observations synthesized from facts about the entity.
|
||||
"""
|
||||
model_config = ConfigDict(json_schema_extra={
|
||||
"example": {
|
||||
"entity_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"canonical_name": "John",
|
||||
"observations": [
|
||||
{"text": "John is detail-oriented", "mentioned_at": "2024-01-15T10:30:00Z"},
|
||||
{"text": "John works at Google on the AI team", "mentioned_at": "2024-01-14T09:00:00Z"}
|
||||
]
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"entity_id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"canonical_name": "John",
|
||||
"observations": [
|
||||
{"text": "John is detail-oriented", "mentioned_at": "2024-01-15T10:30:00Z"},
|
||||
{"text": "John works at Google on the AI team", "mentioned_at": "2024-01-14T09:00:00Z"},
|
||||
],
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
entity_id: str = Field(description="Unique identifier for the entity")
|
||||
canonical_name: str = Field(description="Canonical name of the entity")
|
||||
observations: List[EntityObservation] = Field(
|
||||
default_factory=list,
|
||||
description="List of observations about this entity"
|
||||
observations: list[EntityObservation] = Field(
|
||||
default_factory=list, description="List of observations about this entity"
|
||||
)
|
||||
|
||||
@@ -12,23 +12,16 @@ This package contains modular components for the retain operation:
|
||||
- fact_storage: Handle fact insertion into database
|
||||
"""
|
||||
|
||||
from .types import (
|
||||
RetainContent,
|
||||
ExtractedFact,
|
||||
ProcessedFact,
|
||||
ChunkMetadata,
|
||||
EntityRef,
|
||||
CausalRelation,
|
||||
RetainBatch
|
||||
from . import (
|
||||
chunk_storage,
|
||||
deduplication,
|
||||
embedding_processing,
|
||||
entity_processing,
|
||||
fact_extraction,
|
||||
fact_storage,
|
||||
link_creation,
|
||||
)
|
||||
|
||||
from . import fact_extraction
|
||||
from . import embedding_processing
|
||||
from . import deduplication
|
||||
from . import entity_processing
|
||||
from . import link_creation
|
||||
from . import chunk_storage
|
||||
from . import fact_storage
|
||||
from .types import CausalRelation, ChunkMetadata, EntityRef, ExtractedFact, ProcessedFact, RetainBatch, RetainContent
|
||||
|
||||
__all__ = [
|
||||
# Types
|
||||
|
||||
@@ -1,43 +1,45 @@
|
||||
"""
|
||||
bank profile utilities for personality and background management.
|
||||
bank profile utilities for disposition and background management.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, Optional, TypedDict
|
||||
from typing import TypedDict
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..db_utils import acquire_with_retry
|
||||
from ..response_models import PersonalityTraits
|
||||
from ..memory_engine import fq_table
|
||||
from ..response_models import DispositionTraits
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PERSONALITY = {
|
||||
"openness": 0.5,
|
||||
"conscientiousness": 0.5,
|
||||
"extraversion": 0.5,
|
||||
"agreeableness": 0.5,
|
||||
"neuroticism": 0.5,
|
||||
"bias_strength": 0.5,
|
||||
DEFAULT_DISPOSITION = {
|
||||
"skepticism": 3,
|
||||
"literalism": 3,
|
||||
"empathy": 3,
|
||||
}
|
||||
|
||||
|
||||
class BankProfile(TypedDict):
|
||||
"""Type for bank profile data."""
|
||||
|
||||
name: str
|
||||
personality: PersonalityTraits
|
||||
disposition: DispositionTraits
|
||||
background: str
|
||||
|
||||
|
||||
class BackgroundMergeResponse(BaseModel):
|
||||
"""LLM response for background merge with personality inference."""
|
||||
"""LLM response for background merge with disposition inference."""
|
||||
|
||||
background: str = Field(description="Merged background in first person perspective")
|
||||
personality: PersonalityTraits = Field(description="Inferred Big Five personality traits")
|
||||
disposition: DispositionTraits = Field(description="Inferred disposition traits (skepticism, literalism, empathy)")
|
||||
|
||||
|
||||
async def get_bank_profile(pool, bank_id: str) -> BankProfile:
|
||||
"""
|
||||
Get bank profile (name, personality + background).
|
||||
Get bank profile (name, disposition + background).
|
||||
Auto-creates bank with default values if not exists.
|
||||
|
||||
Args:
|
||||
@@ -45,173 +47,147 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
|
||||
bank_id: bank IDentifier
|
||||
|
||||
Returns:
|
||||
BankProfile with name, typed PersonalityTraits, and background
|
||||
BankProfile with name, typed DispositionTraits, and background
|
||||
"""
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
# Try to get existing bank
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT name, personality, background
|
||||
FROM banks WHERE bank_id = $1
|
||||
f"""
|
||||
SELECT name, disposition, background
|
||||
FROM {fq_table("banks")} WHERE bank_id = $1
|
||||
""",
|
||||
bank_id
|
||||
bank_id,
|
||||
)
|
||||
|
||||
if row:
|
||||
# asyncpg returns JSONB as a string, so parse it
|
||||
personality_data = row["personality"]
|
||||
if isinstance(personality_data, str):
|
||||
personality_data = json.loads(personality_data)
|
||||
disposition_data = row["disposition"]
|
||||
if isinstance(disposition_data, str):
|
||||
disposition_data = json.loads(disposition_data)
|
||||
|
||||
return BankProfile(
|
||||
name=row["name"],
|
||||
personality=PersonalityTraits(**personality_data),
|
||||
background=row["background"]
|
||||
name=row["name"], disposition=DispositionTraits(**disposition_data), background=row["background"]
|
||||
)
|
||||
|
||||
# Bank doesn't exist, create with defaults
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO banks (bank_id, name, personality, background)
|
||||
f"""
|
||||
INSERT INTO {fq_table("banks")} (bank_id, name, disposition, background)
|
||||
VALUES ($1, $2, $3::jsonb, $4)
|
||||
ON CONFLICT (bank_id) DO NOTHING
|
||||
""",
|
||||
bank_id,
|
||||
bank_id, # Default name is the bank_id
|
||||
json.dumps(DEFAULT_PERSONALITY),
|
||||
""
|
||||
json.dumps(DEFAULT_DISPOSITION),
|
||||
"",
|
||||
)
|
||||
|
||||
return BankProfile(
|
||||
name=bank_id,
|
||||
personality=PersonalityTraits(**DEFAULT_PERSONALITY),
|
||||
background=""
|
||||
)
|
||||
return BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), background="")
|
||||
|
||||
|
||||
async def update_bank_personality(
|
||||
pool,
|
||||
bank_id: str,
|
||||
personality: Dict[str, float]
|
||||
) -> None:
|
||||
async def update_bank_disposition(pool, bank_id: str, disposition: dict[str, int]) -> None:
|
||||
"""
|
||||
Update bank personality traits.
|
||||
Update bank disposition traits.
|
||||
|
||||
Args:
|
||||
pool: Database connection pool
|
||||
bank_id: bank IDentifier
|
||||
personality: Dict with Big Five traits + bias_strength (all 0-1)
|
||||
disposition: Dict with skepticism, literalism, empathy (all 1-5)
|
||||
"""
|
||||
# Ensure bank exists first
|
||||
await get_bank_profile(pool, bank_id)
|
||||
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE banks
|
||||
SET personality = $2::jsonb,
|
||||
f"""
|
||||
UPDATE {fq_table("banks")}
|
||||
SET disposition = $2::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE bank_id = $1
|
||||
""",
|
||||
bank_id,
|
||||
json.dumps(personality)
|
||||
json.dumps(disposition),
|
||||
)
|
||||
|
||||
|
||||
async def merge_bank_background(
|
||||
pool,
|
||||
llm_config,
|
||||
bank_id: str,
|
||||
new_info: str,
|
||||
update_personality: 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.
|
||||
Optionally infers personality traits from the merged background.
|
||||
Optionally infers disposition traits from the merged background.
|
||||
|
||||
Args:
|
||||
pool: Database connection pool
|
||||
llm_config: LLM configuration for background merging
|
||||
bank_id: bank IDentifier
|
||||
new_info: New background information to add/merge
|
||||
update_personality: If True, infer Big Five traits from background (default: True)
|
||||
update_disposition: If True, infer Big Five traits from background (default: True)
|
||||
|
||||
Returns:
|
||||
Dict with 'background' (str) and optionally 'personality' (dict) keys
|
||||
Dict with 'background' (str) and optionally 'disposition' (dict) keys
|
||||
"""
|
||||
# Get current profile
|
||||
profile = await get_bank_profile(pool, bank_id)
|
||||
current_background = profile["background"]
|
||||
|
||||
# Use LLM to merge backgrounds and optionally infer personality
|
||||
result = await _llm_merge_background(
|
||||
llm_config,
|
||||
current_background,
|
||||
new_info,
|
||||
infer_personality=update_personality
|
||||
)
|
||||
# Use LLM to merge backgrounds and optionally infer disposition
|
||||
result = await _llm_merge_background(llm_config, current_background, new_info, infer_disposition=update_disposition)
|
||||
|
||||
merged_background = result["background"]
|
||||
inferred_personality = result.get("personality")
|
||||
inferred_disposition = result.get("disposition")
|
||||
|
||||
# Update in database
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
if inferred_personality:
|
||||
# Update both background and personality
|
||||
if inferred_disposition:
|
||||
# Update both background and disposition
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE banks
|
||||
f"""
|
||||
UPDATE {fq_table("banks")}
|
||||
SET background = $2,
|
||||
personality = $3::jsonb,
|
||||
disposition = $3::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE bank_id = $1
|
||||
""",
|
||||
bank_id,
|
||||
merged_background,
|
||||
json.dumps(inferred_personality)
|
||||
json.dumps(inferred_disposition),
|
||||
)
|
||||
else:
|
||||
# Update only background
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE banks
|
||||
f"""
|
||||
UPDATE {fq_table("banks")}
|
||||
SET background = $2,
|
||||
updated_at = NOW()
|
||||
WHERE bank_id = $1
|
||||
""",
|
||||
bank_id,
|
||||
merged_background
|
||||
merged_background,
|
||||
)
|
||||
|
||||
response = {"background": merged_background}
|
||||
if inferred_personality:
|
||||
response["personality"] = inferred_personality
|
||||
if inferred_disposition:
|
||||
response["disposition"] = inferred_disposition
|
||||
|
||||
return response
|
||||
|
||||
|
||||
async def _llm_merge_background(
|
||||
llm_config,
|
||||
current: str,
|
||||
new_info: str,
|
||||
infer_personality: 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 personality traits from the merged background.
|
||||
Optionally infer Big Five disposition traits from the merged background.
|
||||
|
||||
Args:
|
||||
llm_config: LLM configuration to use
|
||||
current: Current background text
|
||||
new_info: New information to merge
|
||||
infer_personality: If True, also infer personality traits
|
||||
infer_disposition: If True, also infer disposition traits
|
||||
|
||||
Returns:
|
||||
Dict with 'background' (str) and optionally 'personality' (dict) keys
|
||||
Dict with 'background' (str) and optionally 'disposition' (dict) keys
|
||||
"""
|
||||
if infer_personality:
|
||||
prompt = f"""You are helping maintain a memory bank's background/profile and infer their personality. You MUST respond with ONLY valid JSON.
|
||||
if infer_disposition:
|
||||
prompt = f"""You are helping maintain a memory bank's background/profile and infer their disposition. You MUST respond with ONLY valid JSON.
|
||||
|
||||
Current background: {current if current else "(empty)"}
|
||||
|
||||
@@ -223,36 +199,30 @@ Instructions:
|
||||
3. Keep additions that don't conflict
|
||||
4. Output in FIRST PERSON ("I") perspective
|
||||
5. Be concise - keep merged background under 500 characters
|
||||
6. Infer Big Five personality traits from the merged background:
|
||||
- Openness: 0.0-1.0 (creativity, curiosity, openness to new ideas)
|
||||
- Conscientiousness: 0.0-1.0 (organization, discipline, goal-directed)
|
||||
- Extraversion: 0.0-1.0 (sociability, assertiveness, energy from others)
|
||||
- Agreeableness: 0.0-1.0 (cooperation, empathy, consideration)
|
||||
- Neuroticism: 0.0-1.0 (emotional sensitivity, anxiety, stress response)
|
||||
- Bias Strength: 0.0-1.0 (how much personality influences opinions)
|
||||
6. Infer disposition traits from the merged background (each 1-5 integer):
|
||||
- Skepticism: 1-5 (1=trusting, takes things at face value; 5=skeptical, questions everything)
|
||||
- Literalism: 1-5 (1=flexible interpretation, reads between lines; 5=literal, exact interpretation)
|
||||
- Empathy: 1-5 (1=detached, focuses on facts; 5=empathetic, considers emotional context)
|
||||
|
||||
CRITICAL: You MUST respond with ONLY a valid JSON object. No markdown, no code blocks, no explanations. Just the JSON.
|
||||
|
||||
Format:
|
||||
{{
|
||||
"background": "the merged background text in first person",
|
||||
"personality": {{
|
||||
"openness": 0.7,
|
||||
"conscientiousness": 0.6,
|
||||
"extraversion": 0.5,
|
||||
"agreeableness": 0.8,
|
||||
"neuroticism": 0.4,
|
||||
"bias_strength": 0.6
|
||||
"disposition": {{
|
||||
"skepticism": 3,
|
||||
"literalism": 3,
|
||||
"empathy": 3
|
||||
}}
|
||||
}}
|
||||
|
||||
Trait inference examples:
|
||||
- "creative artist" → openness: 0.8+, bias_strength: 0.6
|
||||
- "organized engineer" → conscientiousness: 0.8+, openness: 0.5-0.6
|
||||
- "startup founder" → openness: 0.8+, extraversion: 0.7+, neuroticism: 0.3-0.4
|
||||
- "risk-averse analyst" → openness: 0.3-0.4, conscientiousness: 0.8+, neuroticism: 0.6+
|
||||
- "rational and diligent" → conscientiousness: 0.7+, openness: 0.6+
|
||||
- "passionate and dramatic" → extraversion: 0.7+, neuroticism: 0.6+, openness: 0.7+"""
|
||||
- "I'm a lawyer" → skepticism: 4, literalism: 5, empathy: 2
|
||||
- "I'm a therapist" → skepticism: 2, literalism: 2, empathy: 5
|
||||
- "I'm an engineer" → skepticism: 3, literalism: 4, empathy: 3
|
||||
- "I've been burned before by trusting people" → skepticism: 5, literalism: 3, empathy: 3
|
||||
- "I try to understand what people really mean" → skepticism: 3, literalism: 2, empathy: 4
|
||||
- "I take contracts very seriously" → skepticism: 4, literalism: 5, empathy: 2"""
|
||||
else:
|
||||
prompt = f"""You are helping maintain a memory bank's background/profile.
|
||||
|
||||
@@ -274,38 +244,32 @@ Merged background:"""
|
||||
# Prepare messages
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
|
||||
if infer_personality:
|
||||
# Use structured output with Pydantic model for personality inference
|
||||
if infer_disposition:
|
||||
# Use structured output with Pydantic model for disposition inference
|
||||
try:
|
||||
parsed = await llm_config.call(
|
||||
messages=messages,
|
||||
response_format=BackgroundMergeResponse,
|
||||
scope="bank_background",
|
||||
temperature=0.3,
|
||||
max_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,
|
||||
"personality": parsed.personality.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-personality merge
|
||||
# Manual parsing fallback or non-disposition merge
|
||||
content = await llm_config.call(
|
||||
messages=messages,
|
||||
scope="bank_background",
|
||||
temperature=0.3,
|
||||
max_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]}")
|
||||
|
||||
if infer_personality:
|
||||
if infer_disposition:
|
||||
# Parse JSON response - try multiple extraction methods
|
||||
result = None
|
||||
|
||||
@@ -319,7 +283,7 @@ Merged background:"""
|
||||
# Method 2: Extract from markdown code blocks
|
||||
if result is None:
|
||||
# Remove markdown code blocks
|
||||
code_block_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', content, re.DOTALL)
|
||||
code_block_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", content, re.DOTALL)
|
||||
if code_block_match:
|
||||
try:
|
||||
result = json.loads(code_block_match.group(1))
|
||||
@@ -330,7 +294,9 @@ Merged background:"""
|
||||
# Method 3: Find nested JSON structure
|
||||
if result is None:
|
||||
# Look for JSON object with nested structure
|
||||
json_match = re.search(r'\{[^{}]*"background"[^{}]*"personality"[^{}]*\{[^{}]*\}[^{}]*\}', content, re.DOTALL)
|
||||
json_match = re.search(
|
||||
r'\{[^{}]*"background"[^{}]*"disposition"[^{}]*\{[^{}]*\}[^{}]*\}', content, re.DOTALL
|
||||
)
|
||||
if json_match:
|
||||
try:
|
||||
result = json.loads(json_match.group())
|
||||
@@ -341,23 +307,22 @@ Merged background:"""
|
||||
# All parsing methods failed - use fallback
|
||||
if result is None:
|
||||
logger.warning(f"Failed to extract JSON from LLM response. Raw content: {content[:200]}")
|
||||
# Fallback: use new_info as background with default personality
|
||||
# Fallback: use new_info as background with default disposition
|
||||
return {
|
||||
"background": new_info if new_info else current if current else "",
|
||||
"personality": DEFAULT_PERSONALITY.copy()
|
||||
"disposition": DEFAULT_DISPOSITION.copy(),
|
||||
}
|
||||
|
||||
# Validate personality values
|
||||
personality = result.get("personality", {})
|
||||
for key in ["openness", "conscientiousness", "extraversion",
|
||||
"agreeableness", "neuroticism", "bias_strength"]:
|
||||
if key not in personality:
|
||||
personality[key] = 0.5 # Default to neutral
|
||||
# Validate disposition values
|
||||
disposition = result.get("disposition", {})
|
||||
for key in ["skepticism", "literalism", "empathy"]:
|
||||
if key not in disposition:
|
||||
disposition[key] = 3 # Default to neutral
|
||||
else:
|
||||
# Clamp to [0, 1]
|
||||
personality[key] = max(0.0, min(1.0, float(personality[key])))
|
||||
# Clamp to [1, 5] and convert to int
|
||||
disposition[key] = max(1, min(5, int(disposition[key])))
|
||||
|
||||
result["personality"] = personality
|
||||
result["disposition"] = disposition
|
||||
|
||||
# Ensure background exists
|
||||
if "background" not in result or not result["background"]:
|
||||
@@ -380,8 +345,8 @@ Merged background:"""
|
||||
merged = new_info
|
||||
|
||||
result = {"background": merged}
|
||||
if infer_personality:
|
||||
result["personality"] = DEFAULT_PERSONALITY.copy()
|
||||
if infer_disposition:
|
||||
result["disposition"] = DEFAULT_DISPOSITION.copy()
|
||||
return result
|
||||
|
||||
|
||||
@@ -393,13 +358,13 @@ async def list_banks(pool) -> list:
|
||||
pool: Database connection pool
|
||||
|
||||
Returns:
|
||||
List of dicts with bank_id, name, personality, background, created_at, updated_at
|
||||
List of dicts with bank_id, name, disposition, background, created_at, updated_at
|
||||
"""
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT bank_id, name, personality, background, created_at, updated_at
|
||||
FROM banks
|
||||
f"""
|
||||
SELECT bank_id, name, disposition, background, created_at, updated_at
|
||||
FROM {fq_table("banks")}
|
||||
ORDER BY updated_at DESC
|
||||
"""
|
||||
)
|
||||
@@ -407,17 +372,19 @@ async def list_banks(pool) -> list:
|
||||
result = []
|
||||
for row in rows:
|
||||
# asyncpg returns JSONB as a string, so parse it
|
||||
personality_data = row["personality"]
|
||||
if isinstance(personality_data, str):
|
||||
personality_data = json.loads(personality_data)
|
||||
disposition_data = row["disposition"]
|
||||
if isinstance(disposition_data, str):
|
||||
disposition_data = json.loads(disposition_data)
|
||||
|
||||
result.append({
|
||||
"bank_id": row["bank_id"],
|
||||
"name": row["name"],
|
||||
"personality": personality_data,
|
||||
"background": row["background"],
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
|
||||
})
|
||||
result.append(
|
||||
{
|
||||
"bank_id": row["bank_id"],
|
||||
"name": row["name"],
|
||||
"disposition": disposition_data,
|
||||
"background": row["background"],
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@@ -3,20 +3,16 @@ Chunk storage for retain pipeline.
|
||||
|
||||
Handles storage of document chunks in the database.
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
import logging
|
||||
|
||||
from ..memory_engine import fq_table
|
||||
from .types import ChunkMetadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def store_chunks_batch(
|
||||
conn,
|
||||
bank_id: str,
|
||||
document_id: str,
|
||||
chunks: List[ChunkMetadata]
|
||||
) -> Dict[int, str]:
|
||||
async def store_chunks_batch(conn, bank_id: str, document_id: str, chunks: list[ChunkMetadata]) -> dict[int, str]:
|
||||
"""
|
||||
Store document chunks in the database.
|
||||
|
||||
@@ -47,24 +43,21 @@ async def store_chunks_batch(
|
||||
|
||||
# Batch insert all chunks
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO chunks (chunk_id, document_id, bank_id, chunk_text, chunk_index)
|
||||
f"""
|
||||
INSERT INTO {fq_table("chunks")} (chunk_id, document_id, bank_id, chunk_text, chunk_index)
|
||||
SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::text[], $5::integer[])
|
||||
""",
|
||||
chunk_ids,
|
||||
[document_id] * len(chunk_texts),
|
||||
[bank_id] * len(chunk_texts),
|
||||
chunk_texts,
|
||||
chunk_indices
|
||||
chunk_indices,
|
||||
)
|
||||
|
||||
return chunk_id_map
|
||||
|
||||
|
||||
def map_facts_to_chunks(
|
||||
facts_chunk_indices: List[int],
|
||||
chunk_id_map: Dict[int, str]
|
||||
) -> List[Optional[str]]:
|
||||
def map_facts_to_chunks(facts_chunk_indices: list[int], chunk_id_map: dict[int, str]) -> list[str | None]:
|
||||
"""
|
||||
Map fact chunk indices to chunk IDs.
|
||||
|
||||
|
||||
@@ -3,22 +3,17 @@ Deduplication logic for retain pipeline.
|
||||
|
||||
Checks for duplicate facts using semantic similarity and temporal proximity.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from collections import defaultdict
|
||||
from datetime import UTC
|
||||
|
||||
from .types import ProcessedFact
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def check_duplicates_batch(
|
||||
conn,
|
||||
bank_id: str,
|
||||
facts: List[ProcessedFact],
|
||||
duplicate_checker_fn
|
||||
) -> List[bool]:
|
||||
async def check_duplicates_batch(conn, bank_id: str, facts: list[ProcessedFact], duplicate_checker_fn) -> list[bool]:
|
||||
"""
|
||||
Check which facts are duplicates using batched time-window queries.
|
||||
|
||||
@@ -47,16 +42,12 @@ async def check_duplicates_batch(
|
||||
|
||||
# Defensive: if both are None (shouldn't happen), use now()
|
||||
if fact_date is None:
|
||||
from datetime import datetime, timezone
|
||||
fact_date = datetime.now(timezone.utc)
|
||||
from datetime import datetime
|
||||
|
||||
fact_date = datetime.now(UTC)
|
||||
|
||||
# Round to 12-hour bucket to group similar times
|
||||
bucket_key = fact_date.replace(
|
||||
hour=(fact_date.hour // 12) * 12,
|
||||
minute=0,
|
||||
second=0,
|
||||
microsecond=0
|
||||
)
|
||||
bucket_key = fact_date.replace(hour=(fact_date.hour // 12) * 12, minute=0, second=0, microsecond=0)
|
||||
time_buckets[bucket_key].append((idx, fact))
|
||||
|
||||
# Process each bucket in batch
|
||||
@@ -68,14 +59,7 @@ async def check_duplicates_batch(
|
||||
embeddings = [item[1].embedding for item in bucket_items]
|
||||
|
||||
# Check duplicates for this time bucket
|
||||
dup_flags = await duplicate_checker_fn(
|
||||
conn,
|
||||
bank_id,
|
||||
texts,
|
||||
embeddings,
|
||||
bucket_date,
|
||||
time_window_hours=24
|
||||
)
|
||||
dup_flags = await duplicate_checker_fn(conn, bank_id, texts, embeddings, bucket_date, time_window_hours=24)
|
||||
|
||||
# Map results back to original indices
|
||||
for idx, is_dup in zip(indices, dup_flags):
|
||||
@@ -84,10 +68,7 @@ async def check_duplicates_batch(
|
||||
return all_is_duplicate
|
||||
|
||||
|
||||
def filter_duplicates(
|
||||
facts: List[ProcessedFact],
|
||||
is_duplicate_flags: List[bool]
|
||||
) -> List[ProcessedFact]:
|
||||
def filter_duplicates(facts: list[ProcessedFact], is_duplicate_flags: list[bool]) -> list[ProcessedFact]:
|
||||
"""
|
||||
Filter out duplicate facts based on duplicate flags.
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ Embedding processing for retain pipeline.
|
||||
|
||||
Handles augmenting fact texts with temporal information and generating embeddings.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
from datetime import datetime
|
||||
|
||||
from . import embedding_utils
|
||||
from .types import ExtractedFact
|
||||
@@ -13,7 +12,7 @@ from .types import ExtractedFact
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def augment_texts_with_dates(facts: List[ExtractedFact], format_date_fn) -> List[str]:
|
||||
def augment_texts_with_dates(facts: list[ExtractedFact], format_date_fn) -> list[str]:
|
||||
"""
|
||||
Augment fact texts with readable dates for better temporal matching.
|
||||
|
||||
@@ -37,10 +36,7 @@ def augment_texts_with_dates(facts: List[ExtractedFact], format_date_fn) -> List
|
||||
return augmented_texts
|
||||
|
||||
|
||||
async def generate_embeddings_batch(
|
||||
embeddings_model,
|
||||
texts: List[str]
|
||||
) -> List[List[float]]:
|
||||
async def generate_embeddings_batch(embeddings_model, texts: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
Generate embeddings for a batch of texts.
|
||||
|
||||
@@ -54,9 +50,6 @@ async def generate_embeddings_batch(
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
embeddings = await embedding_utils.generate_embeddings_batch(
|
||||
embeddings_model,
|
||||
texts
|
||||
)
|
||||
embeddings = await embedding_utils.generate_embeddings_batch(embeddings_model, texts)
|
||||
|
||||
return embeddings
|
||||
|
||||
@@ -4,12 +4,11 @@ Embedding generation utilities for memory units.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_embedding(embeddings_backend, text: str) -> List[float]:
|
||||
def generate_embedding(embeddings_backend, text: str) -> list[float]:
|
||||
"""
|
||||
Generate embedding for text using the provided embeddings backend.
|
||||
|
||||
@@ -27,7 +26,7 @@ def generate_embedding(embeddings_backend, text: str) -> List[float]:
|
||||
raise Exception(f"Failed to generate embedding: {str(e)}")
|
||||
|
||||
|
||||
async def generate_embeddings_batch(embeddings_backend, texts: List[str]) -> List[List[float]]:
|
||||
async def generate_embeddings_batch(embeddings_backend, texts: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
Generate embeddings for multiple texts using the provided embeddings backend.
|
||||
|
||||
@@ -47,7 +46,7 @@ async def generate_embeddings_batch(embeddings_backend, texts: List[str]) -> Lis
|
||||
embeddings = await loop.run_in_executor(
|
||||
None, # Use default thread pool
|
||||
embeddings_backend.encode,
|
||||
texts
|
||||
texts,
|
||||
)
|
||||
return embeddings
|
||||
except Exception as e:
|
||||
|
||||
@@ -3,24 +3,18 @@ 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
|
||||
import logging
|
||||
|
||||
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[Tuple[str, str, float]]:
|
||||
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.
|
||||
|
||||
@@ -39,7 +33,7 @@ async def process_entities_batch(
|
||||
log_buffer: Optional buffer for detailed logging
|
||||
|
||||
Returns:
|
||||
List of entity link tuples: (unit_id, entity_id, confidence)
|
||||
List of EntityLink objects for batch insertion
|
||||
"""
|
||||
if not unit_ids or not facts:
|
||||
return []
|
||||
@@ -53,8 +47,7 @@ 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
|
||||
@@ -67,22 +60,19 @@ 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[Tuple[str, str, float]]
|
||||
) -> None:
|
||||
async def insert_entity_links_batch(conn, entity_links: list[EntityLink]) -> None:
|
||||
"""
|
||||
Insert entity links in batch.
|
||||
|
||||
Args:
|
||||
conn: Database connection
|
||||
entity_links: List of (unit_id, entity_id, confidence) tuples
|
||||
entity_links: List of EntityLink objects
|
||||
"""
|
||||
if not entity_links:
|
||||
return
|
||||
|
||||
@@ -4,20 +4,40 @@ Fact extraction from text using LLM.
|
||||
Extracts semantic facts, entities, and temporal information from text.
|
||||
Uses the LLMConfig wrapper for all LLM calls.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
import re
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
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
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from ..llm_wrapper import LLMConfig, OutputTooLongError
|
||||
|
||||
|
||||
def _sanitize_text(text: str) -> str:
|
||||
"""
|
||||
Sanitize text by removing invalid Unicode surrogate characters.
|
||||
|
||||
Surrogate characters (U+D800 to U+DFFF) are used in UTF-16 encoding
|
||||
but cannot be encoded in UTF-8. They can appear in Python strings
|
||||
from improperly decoded data (e.g., from JavaScript or broken files).
|
||||
|
||||
This function removes unpaired surrogates to prevent UnicodeEncodeError
|
||||
when the text is sent to the LLM API.
|
||||
"""
|
||||
if not text:
|
||||
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)
|
||||
|
||||
|
||||
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."
|
||||
)
|
||||
@@ -30,42 +50,46 @@ 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", "bank", "opinion"] = Field(description="Perspective: world/bank/opinion")
|
||||
fact_type: Literal["world", "experience", "opinion"] = Field(description="Perspective: world/experience/opinion")
|
||||
|
||||
# Optional temporal fields
|
||||
occurred_start: Optional[str] = None
|
||||
occurred_end: Optional[str] = None
|
||||
mentioned_at: Optional[str] = None
|
||||
occurred_start: str | None = None
|
||||
occurred_end: str | None = None
|
||||
mentioned_at: str | None = None
|
||||
|
||||
# Optional location field
|
||||
where: Optional[str] = Field(None, description="WHERE the fact occurred or is about (specific location, place, or area)")
|
||||
where: str | None = Field(
|
||||
None, description="WHERE the fact occurred or is about (specific location, place, or area)"
|
||||
)
|
||||
|
||||
# Optional structured data
|
||||
entities: Optional[List[Entity]] = None
|
||||
causal_relations: Optional[List['CausalRelation']] = None
|
||||
entities: list[Entity] | None = None
|
||||
causal_relations: list["CausalRelation"] | None = 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -74,9 +98,7 @@ 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"]},
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
@@ -85,43 +107,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'"
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
@@ -130,36 +152,35 @@ 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: Optional[str] = Field(
|
||||
occurred_start: str | None = 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: Optional[str] = Field(
|
||||
occurred_end: str | None = 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)
|
||||
# Note: LLM uses "assistant" but we convert to "bank" for storage
|
||||
fact_type: Literal["world", "assistant"] = Field(
|
||||
description="'world' = about the user/others (background, experiences). 'assistant' = interactions with the assistant."
|
||||
description="'world' = about the user/others (background, experiences). 'assistant' = experience with the assistant."
|
||||
)
|
||||
|
||||
# Entities - extracted from 'who' field
|
||||
entities: Optional[List[Entity]] = Field(
|
||||
# Entities - extracted from fact content
|
||||
entities: list[Entity] | None = Field(
|
||||
default=None,
|
||||
description="Named entities from 'who': people names, organizations, places. NOT generic relations."
|
||||
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: Optional[List[CausalRelation]] = Field(
|
||||
default=None,
|
||||
description="Causal links to other facts. Can be null."
|
||||
causal_relations: list[CausalRelation] | None = 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)."""
|
||||
@@ -167,7 +188,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)."""
|
||||
@@ -180,11 +201,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:
|
||||
@@ -195,12 +216,11 @@ 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.
|
||||
|
||||
@@ -214,7 +234,6 @@ 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
|
||||
@@ -238,21 +257,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.
|
||||
|
||||
@@ -263,7 +282,6 @@ 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 = []
|
||||
@@ -297,17 +315,17 @@ 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).
|
||||
|
||||
Note: event_date parameter is kept for backward compatibility but not used in prompt.
|
||||
The LLM extracts temporal information from the context string instead.
|
||||
"""
|
||||
agent_context = f"\n- Your name: {agent_name}" if agent_name else ""
|
||||
memory_bank_context = f"\n- Your name: {agent_name}" if agent_name and extract_opinions else ""
|
||||
|
||||
# Determine which fact types to extract based on the flag
|
||||
# Note: We use "assistant" in the prompt but convert to "bank" for storage
|
||||
@@ -315,13 +333,15 @@ 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.
|
||||
|
||||
{fact_types_instruction}
|
||||
|
||||
Context: {context if context else 'none'}{agent_context}
|
||||
|
||||
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
FACT FORMAT - ALL FIVE DIMENSIONS REQUIRED - MAXIMUM VERBOSITY
|
||||
@@ -330,7 +350,9 @@ FACT FORMAT - ALL FIVE DIMENSIONS REQUIRED - MAXIMUM VERBOSITY
|
||||
For EACH fact, CAPTURE ALL DETAILS - NEVER SUMMARIZE OR OMIT:
|
||||
|
||||
1. **what**: WHAT happened - COMPLETE description with ALL specifics (objects, actions, quantities, details)
|
||||
2. **when**: WHEN it happened - ALWAYS include temporal info (dates, times, durations, relative times)
|
||||
2. **when**: WHEN it happened - ALWAYS include temporal info with DAY OF WEEK (e.g., "Monday, June 10, 2024")
|
||||
- Always include the day name: Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday
|
||||
- Format: "day_name, month day, year" (e.g., "Saturday, June 9, 2024")
|
||||
3. **where**: WHERE it happened or is about - SPECIFIC locations, places, areas, regions (if applicable)
|
||||
4. **who**: WHO is involved - ALL people/entities with FULL relationships and background
|
||||
5. **why**: WHY it matters - ALL emotions, preferences, motivations, significance, nuance
|
||||
@@ -350,7 +372,7 @@ Example input: "I went to my college roommate's wedding last June. Emily finally
|
||||
|
||||
CORRECT output:
|
||||
- what: "Emily got married to Sarah at a rooftop garden ceremony"
|
||||
- when: "in June 2024, after dating for 5 years"
|
||||
- when: "Saturday, June 8, 2024, after dating for 5 years"
|
||||
- where: "downtown San Francisco, at a rooftop garden venue"
|
||||
- who: "Emily (user's college roommate), Sarah (Emily's partner of 5 years)"
|
||||
- why: "User found it romantic and beautiful, dreams of similar outdoor ceremony"
|
||||
@@ -362,12 +384,42 @@ WRONG output:
|
||||
- where: (missing) ← WRONG - include the location!
|
||||
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
TEMPORAL HANDLING
|
||||
FACT_KIND CLASSIFICATION (CRITICAL FOR TEMPORAL HANDLING)
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
For EVENTS (fact_kind="event"):
|
||||
- Convert relative dates → absolute: "yesterday" on March 15 → "March 14, 2024"
|
||||
- Set occurred_start/occurred_end to WHEN IT HAPPENED (not when mentioned)
|
||||
⚠️ MUST set fact_kind correctly - this determines whether occurred_start/end are set!
|
||||
|
||||
fact_kind="event" - USE FOR:
|
||||
- Actions that happened at a specific time: "went to", "attended", "visited", "bought", "made"
|
||||
- Past events: "yesterday I...", "last week...", "in March 2020..."
|
||||
- Future plans with dates: "will go to", "scheduled for"
|
||||
- Examples: "I went to a pottery workshop" → event
|
||||
"Alice visited Paris in February" → event
|
||||
"I bought a new car yesterday" → event
|
||||
"The user graduated from MIT in March 2020" → event
|
||||
|
||||
fact_kind="conversation" - USE FOR:
|
||||
- Ongoing states: "works as", "lives in", "is married to"
|
||||
- Preferences: "loves", "prefers", "enjoys"
|
||||
- Traits/abilities: "speaks fluent French", "knows Python"
|
||||
- Examples: "I love Italian food" → conversation
|
||||
"Alice works at Google" → conversation
|
||||
"I prefer outdoor dining" → conversation
|
||||
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
TEMPORAL HANDLING (CRITICAL - USE EVENT DATE AS REFERENCE)
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
⚠️ IMPORTANT: Use the "Event Date" provided in the input as your reference point!
|
||||
All relative dates ("yesterday", "last week", "recently") must be resolved relative to the Event Date, NOT today's date.
|
||||
|
||||
For EVENTS (fact_kind="event") - MUST SET BOTH occurred_start AND occurred_end:
|
||||
- Convert relative dates → absolute using Event Date as reference
|
||||
- If Event Date is "Saturday, March 15, 2020", then "yesterday" = Friday, March 14, 2020
|
||||
- Dates mentioned in text (e.g., "in March 2020") should use THAT year, not current year
|
||||
- Always include the day name (Monday, Tuesday, etc.) in the 'when' field
|
||||
- Set occurred_start AND occurred_end to WHEN IT HAPPENED (not when mentioned)
|
||||
- For single-day/point events: set occurred_end = occurred_start (same timestamp)
|
||||
|
||||
For CONVERSATIONS (fact_kind="conversation"):
|
||||
- General info, preferences, ongoing states → NO occurred dates
|
||||
@@ -394,20 +446,32 @@ Example: "I love Italian food and prefer outdoor dining"
|
||||
→ Fact 2: what="User prefers outdoor dining", who="user", why="This is a dining preference", entities=["user"]
|
||||
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
ENTITIES - INCLUDE "user" (CRITICAL)
|
||||
ENTITIES - INCLUDE PEOPLE, PLACES, OBJECTS, AND CONCEPTS (CRITICAL)
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
When a fact is ABOUT the user (their preferences, plans, experiences), ALWAYS include "user" in entities!
|
||||
Extract entities that help link related facts together. Include:
|
||||
1. "user" - when the fact is about the user
|
||||
2. People names - Emily, Dr. Smith, etc.
|
||||
3. Organizations/Places - IKEA, Goodwill, New York, etc.
|
||||
4. Specific objects - coffee maker, toaster, car, laptop, kitchen, etc.
|
||||
5. Abstract concepts - themes, values, emotions, or ideas that capture the essence of the fact:
|
||||
- "friendship" for facts about friends helping each other, bonding, loyalty
|
||||
- "career growth" for facts about promotions, learning new skills, job changes
|
||||
- "loss" or "grief" for facts about death, endings, saying goodbye
|
||||
- "celebration" for facts about parties, achievements, milestones
|
||||
- "trust" or "betrayal" for facts involving those themes
|
||||
|
||||
✅ CORRECT: entities=["user"] for "User loves coffee"
|
||||
✅ CORRECT: entities=["user", "Emily"] for "User attended Emily's wedding"
|
||||
❌ WRONG: entities=[] for facts about the user
|
||||
✅ CORRECT: entities=["user", "coffee maker", "Goodwill", "kitchen"] for "User donated their coffee maker to Goodwill"
|
||||
✅ CORRECT: entities=["user", "Emily", "friendship"] for "Emily helped user move to a new apartment"
|
||||
✅ CORRECT: entities=["user", "promotion", "career growth"] for "User got promoted to senior engineer"
|
||||
✅ CORRECT: entities=["user", "grandmother", "loss", "grief"] for "User's grandmother passed away last week"
|
||||
❌ WRONG: entities=["user", "Emily"] only - missing the "friendship" concept that links to other friendship facts!
|
||||
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
EXAMPLES
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Example 1 - World Facts (Context: June 10, 2024):
|
||||
Example 1 - World Facts (Event Date: Tuesday, June 10, 2024):
|
||||
Input: "I'm planning my wedding and want a small outdoor ceremony. I just got back from my college roommate Emily's wedding - she married Sarah at a rooftop garden, it was so romantic!"
|
||||
|
||||
Output facts:
|
||||
@@ -417,22 +481,23 @@ Output facts:
|
||||
- who: "user"
|
||||
- why: "User prefers intimate outdoor settings"
|
||||
- fact_type: "world", fact_kind: "conversation"
|
||||
- entities: ["user"]
|
||||
- entities: ["user", "wedding", "outdoor ceremony"]
|
||||
|
||||
2. User planning wedding
|
||||
- what: "User is planning their own wedding"
|
||||
- who: "user"
|
||||
- why: "Inspired by Emily's ceremony"
|
||||
- fact_type: "world", fact_kind: "conversation"
|
||||
- entities: ["user"]
|
||||
- entities: ["user", "wedding"]
|
||||
|
||||
3. Emily's wedding (THE EVENT)
|
||||
3. Emily's wedding (THE EVENT - note occurred_start AND occurred_end both set)
|
||||
- what: "Emily got married to Sarah at a rooftop garden ceremony in the city"
|
||||
- who: "Emily (user's college roommate), Sarah (Emily's partner)"
|
||||
- why: "User found it romantic and beautiful"
|
||||
- fact_type: "world", fact_kind: "event"
|
||||
- occurred_start: "2024-06-09T00:00:00Z" (recently, user "just got back")
|
||||
- entities: ["user", "Emily", "Sarah"]
|
||||
- occurred_start: "2024-06-09T00:00:00Z" (recently, user "just got back" - relative to Event Date June 10, 2024)
|
||||
- occurred_end: "2024-06-09T23:59:59Z" (same day - point event)
|
||||
- entities: ["user", "Emily", "Sarah", "wedding", "rooftop garden"]
|
||||
|
||||
Example 2 - Assistant Facts (Context: March 5, 2024):
|
||||
Input: "User: My API is really slow when we have 1000+ concurrent users. What can I do?
|
||||
@@ -444,7 +509,23 @@ Output fact:
|
||||
- who: "user, assistant"
|
||||
- why: "User asked how to fix slow API performance with 1000+ concurrent users, expected 70-80% reduction in database load"
|
||||
- fact_type: "assistant", fact_kind: "conversation"
|
||||
- entities: ["user"]
|
||||
- entities: ["user", "API", "Redis"]
|
||||
|
||||
Example 3 - Kitchen Items with Concept Inference (Event Date: Thursday, May 30, 2024):
|
||||
Input: "I finally donated my old coffee maker to Goodwill. I upgraded to that new espresso machine last month and the old one was just taking up counter space."
|
||||
|
||||
Output fact:
|
||||
- what: "User donated their old coffee maker to Goodwill after upgrading to a new espresso machine"
|
||||
- when: "Thursday, May 30, 2024"
|
||||
- who: "user"
|
||||
- why: "The old coffee maker was taking up counter space after the upgrade"
|
||||
- fact_type: "world", fact_kind: "event"
|
||||
- occurred_start: "2024-05-30T00:00:00Z" (uses Event Date year)
|
||||
- occurred_end: "2024-05-30T23:59:59Z" (same day - point event)
|
||||
- entities: ["user", "coffee maker", "Goodwill", "espresso machine", "kitchen"]
|
||||
|
||||
Note: "kitchen" is inferred as a concept because coffee makers and espresso machines are kitchen appliances.
|
||||
This links the fact to other kitchen-related facts (toaster, faucet, kitchen mat, etc.) via the shared "kitchen" entity.
|
||||
|
||||
Note how the "why" field captures the FULL STORY: what the user asked AND what outcome was expected!
|
||||
|
||||
@@ -455,10 +536,8 @@ 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
|
||||
|
||||
from openai import BadRequestError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -467,33 +546,31 @@ WHAT TO EXTRACT vs SKIP
|
||||
max_retries = 2
|
||||
last_error = None
|
||||
|
||||
# Sanitize input text to prevent Unicode encoding errors (e.g., unpaired surrogates)
|
||||
sanitized_chunk = _sanitize_text(chunk)
|
||||
sanitized_context = _sanitize_text(context) if context else "none"
|
||||
|
||||
# Build user message with metadata and chunk content in a clear format
|
||||
# Format event_date with day of week for better temporal reasoning
|
||||
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}
|
||||
|
||||
Chunk: {chunk_index + 1}/{total_chunks}
|
||||
Event Date: {event_date.isoformat()}
|
||||
Context: {context if context else 'none'}
|
||||
Event Date: {event_date_formatted} ({event_date.isoformat()})
|
||||
Context: {sanitized_context}
|
||||
|
||||
Text:
|
||||
{chunk}"""
|
||||
{sanitized_chunk}"""
|
||||
|
||||
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,
|
||||
max_tokens=65000,
|
||||
max_completion_tokens=65000,
|
||||
skip_validation=True, # Get raw JSON, we'll validate leniently
|
||||
)
|
||||
|
||||
@@ -515,7 +592,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. "
|
||||
@@ -536,48 +613,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 "bank" for storage
|
||||
fact_type = llm_fact.get('fact_type')
|
||||
# LLM uses "assistant" but we convert to "experience" for storage
|
||||
fact_type = llm_fact.get("fact_type")
|
||||
|
||||
# Convert "assistant" → "bank" for storage
|
||||
if fact_type == 'assistant':
|
||||
fact_type = 'bank'
|
||||
# Convert "assistant" → "experience" for storage
|
||||
if fact_type == "assistant":
|
||||
fact_type = "experience"
|
||||
|
||||
# Validate fact_type (after conversion)
|
||||
if fact_type not in ['world', 'bank', '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 = 'bank'
|
||||
elif fact_kind in ['world', 'bank', '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 = {}
|
||||
@@ -596,17 +673,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
|
||||
if occurred_end:
|
||||
fact_data['occurred_end'] = occurred_end
|
||||
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
|
||||
else:
|
||||
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 = []
|
||||
@@ -614,38 +694,34 @@ 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}")
|
||||
@@ -664,7 +740,9 @@ 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
|
||||
@@ -683,8 +761,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.
|
||||
|
||||
@@ -705,6 +783,7 @@ 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:
|
||||
@@ -717,9 +796,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 as e:
|
||||
except OutputTooLongError:
|
||||
# Output exceeded token limits - split the chunk in half and retry
|
||||
logger.warning(
|
||||
f"Output too long for chunk {chunk_index + 1}/{total_chunks} "
|
||||
@@ -735,7 +814,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:
|
||||
@@ -749,8 +828,7 @@ 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: "
|
||||
f"{len(first_half)} chars and {len(second_half)} chars"
|
||||
f"Split chunk {chunk_index + 1} into two sub-chunks: {len(first_half)} chars and {len(second_half)} chars"
|
||||
)
|
||||
|
||||
# Process both halves recursively (in parallel)
|
||||
@@ -763,7 +841,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,
|
||||
@@ -773,8 +851,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)
|
||||
@@ -784,9 +862,7 @@ 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
|
||||
|
||||
@@ -798,7 +874,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.
|
||||
|
||||
@@ -831,7 +907,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)
|
||||
]
|
||||
@@ -849,8 +925,10 @@ 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 RetainContent, ExtractedFact as ExtractedFactType, ChunkMetadata, CausalRelation as CausalRelationType
|
||||
from typing import Tuple
|
||||
|
||||
from .types import CausalRelation as CausalRelationType
|
||||
from .types import ChunkMetadata, RetainContent
|
||||
from .types import ExtractedFact as ExtractedFactType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -859,11 +937,8 @@ 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.
|
||||
|
||||
@@ -896,7 +971,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)
|
||||
|
||||
@@ -904,8 +979,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
|
||||
@@ -919,7 +994,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
|
||||
@@ -940,18 +1015,21 @@ 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)
|
||||
@@ -967,13 +1045,14 @@ 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.
|
||||
|
||||
@@ -984,13 +1063,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,22 +3,19 @@ Fact storage for retain pipeline.
|
||||
|
||||
Handles insertion of facts into the database.
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from ..memory_engine import fq_table
|
||||
from .types import ProcessedFact
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def insert_facts_batch(
|
||||
conn,
|
||||
bank_id: str,
|
||||
facts: List[ProcessedFact],
|
||||
document_id: Optional[str] = None
|
||||
) -> List[str]:
|
||||
conn, bank_id: str, facts: list[ProcessedFact], document_id: str | None = None
|
||||
) -> list[str]:
|
||||
"""
|
||||
Insert facts into the database in batch.
|
||||
|
||||
@@ -62,7 +59,7 @@ async def insert_facts_batch(
|
||||
contexts.append(fact.context)
|
||||
fact_types.append(fact.fact_type)
|
||||
# confidence_score is only for opinion facts
|
||||
confidence_scores.append(1.0 if fact.fact_type == 'opinion' else None)
|
||||
confidence_scores.append(1.0 if fact.fact_type == "opinion" else None)
|
||||
access_counts.append(0) # Initial access count
|
||||
metadata_jsons.append(json.dumps(fact.metadata))
|
||||
chunk_ids.append(fact.chunk_id)
|
||||
@@ -71,8 +68,8 @@ async def insert_facts_batch(
|
||||
|
||||
# Batch insert all facts
|
||||
results = await conn.fetch(
|
||||
"""
|
||||
INSERT INTO memory_units (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
f"""
|
||||
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id)
|
||||
SELECT $1, * FROM unnest(
|
||||
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
|
||||
@@ -93,10 +90,10 @@ async def insert_facts_batch(
|
||||
access_counts,
|
||||
metadata_jsons,
|
||||
chunk_ids,
|
||||
document_ids
|
||||
document_ids,
|
||||
)
|
||||
|
||||
unit_ids = [str(row['id']) for row in results]
|
||||
unit_ids = [str(row["id"]) for row in results]
|
||||
return unit_ids
|
||||
|
||||
|
||||
@@ -111,25 +108,20 @@ async def ensure_bank_exists(conn, bank_id: str) -> None:
|
||||
bank_id: Bank identifier
|
||||
"""
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO banks (bank_id, personality, background)
|
||||
f"""
|
||||
INSERT INTO {fq_table("banks")} (bank_id, disposition, background)
|
||||
VALUES ($1, $2::jsonb, $3)
|
||||
ON CONFLICT (bank_id) DO UPDATE
|
||||
SET updated_at = NOW()
|
||||
""",
|
||||
bank_id,
|
||||
'{"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.5, "agreeableness": 0.5, "neuroticism": 0.5, "bias_strength": 0.5}',
|
||||
""
|
||||
'{"skepticism": 3, "literalism": 3, "empathy": 3}',
|
||||
"",
|
||||
)
|
||||
|
||||
|
||||
async def handle_document_tracking(
|
||||
conn,
|
||||
bank_id: str,
|
||||
document_id: str,
|
||||
combined_content: str,
|
||||
is_first_batch: bool,
|
||||
retain_params: Optional[dict] = None
|
||||
conn, bank_id: str, document_id: str, combined_content: str, is_first_batch: bool, retain_params: dict | None = None
|
||||
) -> None:
|
||||
"""
|
||||
Handle document tracking in the database.
|
||||
@@ -151,14 +143,13 @@ async def handle_document_tracking(
|
||||
# Only delete on the first batch to avoid deleting data we just inserted
|
||||
if is_first_batch:
|
||||
await conn.fetchval(
|
||||
"DELETE FROM documents WHERE id = $1 AND bank_id = $2 RETURNING id",
|
||||
document_id, bank_id
|
||||
f"DELETE FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 RETURNING id", document_id, bank_id
|
||||
)
|
||||
|
||||
# Insert document (or update if exists from concurrent operations)
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO documents (id, bank_id, original_text, content_hash, metadata, retain_params)
|
||||
f"""
|
||||
INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, metadata, retain_params)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (id, bank_id) DO UPDATE
|
||||
SET original_text = EXCLUDED.original_text,
|
||||
@@ -172,5 +163,5 @@ async def handle_document_tracking(
|
||||
combined_content,
|
||||
content_hash,
|
||||
json.dumps({}), # Empty metadata dict
|
||||
json.dumps(retain_params) if retain_params else None
|
||||
json.dumps(retain_params) if retain_params else None,
|
||||
)
|
||||
|
||||
@@ -3,20 +3,16 @@ Link creation for retain pipeline.
|
||||
|
||||
Handles creation of temporal, semantic, and causal links between facts.
|
||||
"""
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from .types import ProcessedFact, CausalRelation
|
||||
import logging
|
||||
|
||||
from . import link_utils
|
||||
from .types import ProcessedFact
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_temporal_links_batch(
|
||||
conn,
|
||||
bank_id: str,
|
||||
unit_ids: List[str]
|
||||
) -> None:
|
||||
async def create_temporal_links_batch(conn, bank_id: str, unit_ids: list[str]) -> int:
|
||||
"""
|
||||
Create temporal links between facts.
|
||||
|
||||
@@ -26,24 +22,17 @@ async def create_temporal_links_batch(
|
||||
conn: Database connection
|
||||
bank_id: Bank identifier
|
||||
unit_ids: List of unit IDs to create links for
|
||||
|
||||
Returns:
|
||||
Number of temporal links created
|
||||
"""
|
||||
if not unit_ids:
|
||||
return
|
||||
return 0
|
||||
|
||||
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]]
|
||||
) -> None:
|
||||
async def create_semantic_links_batch(conn, bank_id: str, unit_ids: list[str], embeddings: list[list[float]]) -> int:
|
||||
"""
|
||||
Create semantic links between facts.
|
||||
|
||||
@@ -54,27 +43,20 @@ async def create_semantic_links_batch(
|
||||
bank_id: Bank identifier
|
||||
unit_ids: List of unit IDs to create links for
|
||||
embeddings: List of embedding vectors (same length as unit_ids)
|
||||
|
||||
Returns:
|
||||
Number of semantic links created
|
||||
"""
|
||||
if not unit_ids or not embeddings:
|
||||
return
|
||||
return 0
|
||||
|
||||
if len(unit_ids) != len(embeddings):
|
||||
raise ValueError(f"Mismatch between unit_ids ({len(unit_ids)}) and embeddings ({len(embeddings)})")
|
||||
|
||||
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.
|
||||
|
||||
@@ -102,9 +84,9 @@ async def create_causal_links_batch(
|
||||
# Convert CausalRelation objects to dicts
|
||||
relations_dicts = [
|
||||
{
|
||||
'relation_type': rel.relation_type,
|
||||
'target_fact_index': rel.target_fact_index,
|
||||
'strength': rel.strength
|
||||
"relation_type": rel.relation_type,
|
||||
"target_fact_index": rel.target_fact_index,
|
||||
"strength": rel.strength,
|
||||
}
|
||||
for rel in fact.causal_relations
|
||||
]
|
||||
@@ -112,10 +94,6 @@ async def create_causal_links_batch(
|
||||
else:
|
||||
causal_relations_per_fact.append([])
|
||||
|
||||
link_count = await link_utils.create_causal_links_batch(
|
||||
conn,
|
||||
unit_ids,
|
||||
causal_relations_per_fact
|
||||
)
|
||||
link_count = await link_utils.create_causal_links_batch(conn, unit_ids, causal_relations_per_fact)
|
||||
|
||||
return link_count
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
Link creation utilities for temporal, semantic, and entity links.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import List
|
||||
from datetime import timedelta, datetime, timezone
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from ..memory_engine import fq_table
|
||||
from .types import EntityLink
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,7 +19,7 @@ def _normalize_datetime(dt):
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
# Naive datetime - assume UTC
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.replace(tzinfo=UTC)
|
||||
return dt
|
||||
|
||||
|
||||
@@ -51,24 +54,26 @@ def compute_temporal_links(
|
||||
try:
|
||||
time_lower = unit_event_date_norm - timedelta(hours=time_window_hours)
|
||||
except OverflowError:
|
||||
time_lower = datetime.min.replace(tzinfo=timezone.utc)
|
||||
time_lower = datetime.min.replace(tzinfo=UTC)
|
||||
try:
|
||||
time_upper = unit_event_date_norm + timedelta(hours=time_window_hours)
|
||||
except OverflowError:
|
||||
time_upper = datetime.max.replace(tzinfo=timezone.utc)
|
||||
time_upper = datetime.max.replace(tzinfo=UTC)
|
||||
|
||||
# Filter candidates within this unit's time window
|
||||
matching_neighbors = [
|
||||
(row['id'], row['event_date'])
|
||||
(row["id"], row["event_date"])
|
||||
for row in candidates
|
||||
if time_lower <= _normalize_datetime(row['event_date']) <= time_upper
|
||||
if time_lower <= _normalize_datetime(row["event_date"]) <= time_upper
|
||||
][:10] # Limit to top 10
|
||||
|
||||
for recent_id, recent_event_date in matching_neighbors:
|
||||
# Calculate temporal proximity weight
|
||||
time_diff_hours = abs((unit_event_date_norm - _normalize_datetime(recent_event_date)).total_seconds() / 3600)
|
||||
time_diff_hours = abs(
|
||||
(unit_event_date_norm - _normalize_datetime(recent_event_date)).total_seconds() / 3600
|
||||
)
|
||||
weight = max(0.3, 1.0 - (time_diff_hours / time_window_hours))
|
||||
links.append((unit_id, str(recent_id), 'temporal', weight, None))
|
||||
links.append((unit_id, str(recent_id), "temporal", weight, None))
|
||||
|
||||
return links
|
||||
|
||||
@@ -96,38 +101,49 @@ def compute_temporal_query_bounds(
|
||||
try:
|
||||
min_date = min(all_dates) - timedelta(hours=time_window_hours)
|
||||
except OverflowError:
|
||||
min_date = datetime.min.replace(tzinfo=timezone.utc)
|
||||
min_date = datetime.min.replace(tzinfo=UTC)
|
||||
|
||||
try:
|
||||
max_date = max(all_dates) + timedelta(hours=time_window_hours)
|
||||
except OverflowError:
|
||||
max_date = datetime.max.replace(tzinfo=timezone.utc)
|
||||
max_date = datetime.max.replace(tzinfo=UTC)
|
||||
|
||||
return min_date, max_date
|
||||
|
||||
|
||||
def _log(log_buffer, message, level='info'):
|
||||
"""Helper to log to buffer if available, otherwise use logger."""
|
||||
def _log(log_buffer, message, level="info"):
|
||||
"""Helper to log to buffer if available, otherwise use logger.
|
||||
|
||||
Args:
|
||||
log_buffer: Buffer to append messages to (for main output)
|
||||
message: The log message
|
||||
level: 'info', 'debug', 'warning', or 'error'. Debug messages are not added to buffer.
|
||||
"""
|
||||
if level == "debug":
|
||||
# Debug messages only go to logger, not to buffer
|
||||
logger.debug(message)
|
||||
return
|
||||
|
||||
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.
|
||||
|
||||
@@ -157,15 +173,19 @@ async def extract_entities_batch_optimized(
|
||||
formatted_entities = []
|
||||
for ent in entity_list:
|
||||
# Handle both Entity objects and dicts
|
||||
if hasattr(ent, 'text'):
|
||||
if hasattr(ent, "text"):
|
||||
# Entity objects only have 'text', default type to 'CONCEPT'
|
||||
formatted_entities.append({'text': ent.text, 'type': 'CONCEPT'})
|
||||
formatted_entities.append({"text": ent.text, "type": "CONCEPT"})
|
||||
elif isinstance(ent, dict):
|
||||
formatted_entities.append({'text': ent.get('text', ''), 'type': ent.get('type', 'CONCEPT')})
|
||||
formatted_entities.append({"text": ent.get("text", ""), "type": ent.get("type", "CONCEPT")})
|
||||
all_entities.append(formatted_entities)
|
||||
|
||||
total_entities = sum(len(ents) for ents in all_entities)
|
||||
_log(log_buffer, f" [6.1] Process LLM entities: {total_entities} entities from {len(sentences)} facts in {time.time() - substep_start:.3f}s")
|
||||
_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()
|
||||
@@ -181,68 +201,44 @@ 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")
|
||||
_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:
|
||||
# [6.2.2] Batch resolve entities
|
||||
# [6.2.2] Batch resolve entities - single call with per-entity dates
|
||||
substep_6_2_2_start = time.time()
|
||||
# Group by date for batch resolution (round to hour to reduce buckets)
|
||||
entities_by_date = {}
|
||||
|
||||
# Add per-entity dates to entity data for batch resolution
|
||||
for idx, (unit_id, local_idx, fact_date) in enumerate(entity_to_unit):
|
||||
# Round to hour to group facts from same time period
|
||||
date_key = fact_date.replace(minute=0, second=0, microsecond=0)
|
||||
if date_key not in entities_by_date:
|
||||
entities_by_date[date_key] = []
|
||||
entities_by_date[date_key].append((idx, all_entities_flat[idx]))
|
||||
all_entities_flat[idx]["event_date"] = fact_date
|
||||
|
||||
_log(log_buffer, f" [6.2.2] Grouped into {len(entities_by_date)} date buckets, resolving in parallel...")
|
||||
# Resolve ALL entities in ONE batch call (much faster than sequential buckets)
|
||||
# INSERT ... ON CONFLICT handles any race conditions at the DB level
|
||||
resolved_entity_ids = await entity_resolver.resolve_entities_batch(
|
||||
bank_id=bank_id,
|
||||
entities_data=all_entities_flat,
|
||||
context=context,
|
||||
unit_event_date=None, # Not used when per-entity dates provided
|
||||
conn=conn, # Use main transaction connection
|
||||
)
|
||||
|
||||
# Resolve all date groups in PARALLEL using asyncio.gather
|
||||
resolved_entity_ids = [None] * len(all_entities_flat)
|
||||
|
||||
# Prepare all resolution tasks
|
||||
async def resolve_date_bucket(date_idx, date_key, entities_group):
|
||||
date_bucket_start = time.time()
|
||||
indices = [idx for idx, _ in entities_group]
|
||||
entities_data = [entity_data for _, entity_data in entities_group]
|
||||
# Use the first fact's date for this bucket (all should be in same hour)
|
||||
fact_date = entity_to_unit[indices[0]][2]
|
||||
|
||||
# Pass conn=None to let each parallel task acquire its own connection
|
||||
batch_resolved = await entity_resolver.resolve_entities_batch(
|
||||
bank_id=bank_id,
|
||||
entities_data=entities_data,
|
||||
context=context,
|
||||
unit_event_date=fact_date,
|
||||
conn=None # Each task gets its own connection from pool
|
||||
)
|
||||
|
||||
if len(entities_by_date) <= 10: # Only log individual buckets if there aren't too many
|
||||
_log(log_buffer, f" [6.2.2.{date_idx}] Resolved {len(entities_data)} entities in {time.time() - date_bucket_start:.3f}s")
|
||||
|
||||
return indices, batch_resolved
|
||||
|
||||
# Execute all resolution tasks in parallel
|
||||
import asyncio
|
||||
tasks = [
|
||||
resolve_date_bucket(date_idx, date_key, entities_group)
|
||||
for date_idx, (date_key, entities_group) in enumerate(entities_by_date.items(), 1)
|
||||
]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# Map results back to resolved_entity_ids
|
||||
for indices, batch_resolved in results:
|
||||
for idx, entity_id in zip(indices, batch_resolved):
|
||||
resolved_entity_ids[idx] = entity_id
|
||||
|
||||
_log(log_buffer, f" [6.2.2] Resolve entities: {len(all_entities_flat)} entities across {len(entities_by_date)} buckets in {time.time() - substep_6_2_2_start:.3f}s")
|
||||
_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()
|
||||
@@ -259,12 +255,24 @@ async def extract_entities_batch_optimized(
|
||||
|
||||
# Batch insert all unit-entity links (MUCH faster!)
|
||||
await entity_resolver.link_units_to_entities_batch(unit_entity_pairs, conn=conn)
|
||||
_log(log_buffer, f" [6.2.3] Create unit-entity links (batched): {len(unit_entity_pairs)} links in {time.time() - substep_6_2_3_start:.3f}s")
|
||||
_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")
|
||||
_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")
|
||||
_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()
|
||||
@@ -273,52 +281,103 @@ 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...")
|
||||
_log(log_buffer, f" [6.3] Creating entity links for {len(all_entity_ids)} unique entities...", level="debug")
|
||||
|
||||
# Find all units that reference these entities (ONE batched query)
|
||||
entity_to_units = {}
|
||||
if all_entity_ids:
|
||||
query_start = time.time()
|
||||
import uuid
|
||||
|
||||
entity_id_list = [uuid.UUID(eid) if isinstance(eid, str) else eid for eid in all_entity_ids]
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
f"""
|
||||
SELECT entity_id, unit_id
|
||||
FROM unit_entities
|
||||
FROM {fq_table("unit_entities")}
|
||||
WHERE entity_id = ANY($1::uuid[])
|
||||
""",
|
||||
entity_id_list
|
||||
entity_id_list,
|
||||
)
|
||||
_log(
|
||||
log_buffer,
|
||||
f" [6.3.1] Query unit_entities: {len(rows)} rows in {time.time() - query_start:.3f}s",
|
||||
level="debug",
|
||||
)
|
||||
_log(log_buffer, f" [6.3.1] Query unit_entities: {len(rows)} rows in {time.time() - query_start:.3f}s")
|
||||
|
||||
# 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")
|
||||
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 = []
|
||||
for entity_id, units_with_entity in entity_to_units.items():
|
||||
# For each pair of units with this entity, create bidirectional links
|
||||
for i, unit_id_1 in enumerate(units_with_entity):
|
||||
for unit_id_2 in units_with_entity[i+1:]:
|
||||
# Bidirectional links
|
||||
links.append((unit_id_1, unit_id_2, 'entity', 1.0, entity_id))
|
||||
links.append((unit_id_2, unit_id_1, 'entity', 1.0, entity_id))
|
||||
links: list[EntityLink] = []
|
||||
new_unit_set = set(unit_ids) # Units from this batch
|
||||
|
||||
_log(log_buffer, f" [6.3.3] Generate {len(links)} links: {time.time() - link_gen_start:.3f}s")
|
||||
_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")
|
||||
def to_uuid(val) -> UUID:
|
||||
return UUID(val) if isinstance(val, str) else val
|
||||
|
||||
for entity_id, units_with_entity in entity_to_units.items():
|
||||
entity_uuid = to_uuid(entity_id)
|
||||
# Separate new units (from this batch) and existing units
|
||||
new_units = [u for u in units_with_entity if str(u) in new_unit_set or u in new_unit_set]
|
||||
existing_units = [u for u in units_with_entity if str(u) not in new_unit_set and u not in new_unit_set]
|
||||
|
||||
# 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
|
||||
)
|
||||
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
|
||||
)
|
||||
)
|
||||
|
||||
# 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
|
||||
)
|
||||
)
|
||||
|
||||
_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
|
||||
|
||||
@@ -326,10 +385,10 @@ 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.
|
||||
|
||||
@@ -342,9 +401,12 @@ async def create_temporal_links_batch_per_fact(
|
||||
unit_ids: List of unit IDs
|
||||
time_window_hours: Time window in hours for temporal links
|
||||
log_buffer: Optional buffer for logging
|
||||
|
||||
Returns:
|
||||
Number of temporal links created
|
||||
"""
|
||||
if not unit_ids:
|
||||
return
|
||||
return 0
|
||||
|
||||
try:
|
||||
import time as time_mod
|
||||
@@ -352,15 +414,18 @@ async def create_temporal_links_batch_per_fact(
|
||||
# Get the event_date for each new unit
|
||||
fetch_dates_start = time_mod.time()
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, event_date
|
||||
FROM memory_units
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id::text = ANY($1)
|
||||
""",
|
||||
unit_ids
|
||||
unit_ids,
|
||||
)
|
||||
new_units = {str(row["id"]): row["event_date"] for row in rows}
|
||||
_log(
|
||||
log_buffer,
|
||||
f" [7.1] Fetch event_dates for {len(unit_ids)} units: {time_mod.time() - fetch_dates_start:.3f}s",
|
||||
)
|
||||
new_units = {str(row['id']): row['event_date'] for row in rows}
|
||||
_log(log_buffer, f" [7.1] Fetch event_dates for {len(unit_ids)} units: {time_mod.time() - fetch_dates_start:.3f}s")
|
||||
|
||||
# Fetch ALL potential temporal neighbors in ONE query (much faster!)
|
||||
# Get time range across all units with overflow protection
|
||||
@@ -368,9 +433,9 @@ async def create_temporal_links_batch_per_fact(
|
||||
|
||||
fetch_neighbors_start = time_mod.time()
|
||||
all_candidates = await conn.fetch(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, event_date
|
||||
FROM memory_units
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $1
|
||||
AND event_date BETWEEN $2 AND $3
|
||||
AND id::text != ALL($4)
|
||||
@@ -379,30 +444,57 @@ async def create_temporal_links_batch_per_fact(
|
||||
bank_id,
|
||||
min_date,
|
||||
max_date,
|
||||
unit_ids
|
||||
unit_ids,
|
||||
)
|
||||
_log(
|
||||
log_buffer,
|
||||
f" [7.2] Fetch {len(all_candidates)} candidate neighbors (1 query): {time_mod.time() - fetch_neighbors_start:.3f}s",
|
||||
)
|
||||
_log(log_buffer, f" [7.2] Fetch {len(all_candidates)} candidate neighbors (1 query): {time_mod.time() - fetch_neighbors_start:.3f}s")
|
||||
|
||||
# Filter and create links in memory (much faster than N queries)
|
||||
link_gen_start = time_mod.time()
|
||||
links = compute_temporal_links(new_units, all_candidates, time_window_hours)
|
||||
|
||||
# Also compute temporal links WITHIN the new batch (new units to each other)
|
||||
if len(new_units) > 1:
|
||||
# Convert new_units dict to candidate format for within-batch linking
|
||||
new_unit_items = list(new_units.items())
|
||||
for i, (unit_id, event_date) in enumerate(new_unit_items):
|
||||
unit_event_date_norm = _normalize_datetime(event_date)
|
||||
|
||||
# Compare with other new units (only those after this one to avoid duplicates)
|
||||
for j in range(i + 1, len(new_unit_items)):
|
||||
other_id, other_event_date = new_unit_items[j]
|
||||
other_event_date_norm = _normalize_datetime(other_event_date)
|
||||
|
||||
# Check if within time window
|
||||
time_diff_hours = abs((unit_event_date_norm - other_event_date_norm).total_seconds() / 3600)
|
||||
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))
|
||||
|
||||
_log(log_buffer, f" [7.3] Generate {len(links)} temporal links: {time_mod.time() - link_gen_start:.3f}s")
|
||||
|
||||
if links:
|
||||
insert_start = time_mod.time()
|
||||
await conn.executemany(
|
||||
"""
|
||||
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
|
||||
f"""
|
||||
INSERT INTO {fq_table("memory_links")} (from_unit_id, to_unit_id, link_type, weight, entity_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
|
||||
""",
|
||||
links
|
||||
links,
|
||||
)
|
||||
_log(log_buffer, f" [7.4] Insert {len(links)} temporal links: {time_mod.time() - insert_start:.3f}s")
|
||||
|
||||
return len(links)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create temporal links: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
@@ -410,12 +502,12 @@ 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.
|
||||
|
||||
@@ -429,28 +521,35 @@ async def create_semantic_links_batch(
|
||||
top_k: Number of top similar units to link
|
||||
threshold: Minimum similarity threshold
|
||||
log_buffer: Optional buffer for logging
|
||||
|
||||
Returns:
|
||||
Number of semantic links created
|
||||
"""
|
||||
if not unit_ids or not embeddings:
|
||||
return
|
||||
return 0
|
||||
|
||||
try:
|
||||
import time as time_mod
|
||||
|
||||
import numpy as np
|
||||
|
||||
# Fetch ALL existing units with embeddings in ONE query
|
||||
fetch_start = time_mod.time()
|
||||
all_existing = await conn.fetch(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, embedding
|
||||
FROM memory_units
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $1
|
||||
AND embedding IS NOT NULL
|
||||
AND id::text != ALL($2)
|
||||
""",
|
||||
bank_id,
|
||||
unit_ids
|
||||
unit_ids,
|
||||
)
|
||||
_log(
|
||||
log_buffer,
|
||||
f" [8.1] Fetch {len(all_existing)} existing embeddings (1 query): {time_mod.time() - fetch_start:.3f}s",
|
||||
)
|
||||
_log(log_buffer, f" [8.1] Fetch {len(all_existing)} existing embeddings (1 query): {time_mod.time() - fetch_start:.3f}s")
|
||||
|
||||
# Convert to numpy for vectorized similarity computation
|
||||
compute_start = time_mod.time()
|
||||
@@ -458,15 +557,16 @@ async def create_semantic_links_batch(
|
||||
|
||||
if all_existing:
|
||||
# Convert existing embeddings to numpy array
|
||||
existing_ids = [str(row['id']) for row in all_existing]
|
||||
existing_ids = [str(row["id"]) for row in all_existing]
|
||||
# Stack embeddings as 2D array: (num_embeddings, embedding_dim)
|
||||
embedding_arrays = []
|
||||
for row in all_existing:
|
||||
raw_emb = row['embedding']
|
||||
raw_emb = row["embedding"]
|
||||
# Handle different pgvector formats
|
||||
if isinstance(raw_emb, str):
|
||||
# Parse string format: "[1.0, 2.0, ...]"
|
||||
import json
|
||||
|
||||
emb = np.array(json.loads(raw_emb), dtype=np.float32)
|
||||
elif isinstance(raw_emb, (list, tuple)):
|
||||
emb = np.array(raw_emb, dtype=np.float32)
|
||||
@@ -505,55 +605,137 @@ async def create_semantic_links_batch(
|
||||
|
||||
for idx in sorted_indices:
|
||||
similar_id = existing_ids[idx]
|
||||
similarity = float(similarities[idx])
|
||||
all_links.append((unit_id, similar_id, 'semantic', similarity, None))
|
||||
# Clamp to [0, 1] to handle floating point precision issues
|
||||
similarity = float(min(1.0, max(0.0, similarities[idx])))
|
||||
all_links.append((unit_id, similar_id, "semantic", similarity, None))
|
||||
|
||||
_log(log_buffer, f" [8.2] Compute similarities & generate {len(all_links)} semantic links: {time_mod.time() - compute_start:.3f}s")
|
||||
# Also compute similarities WITHIN the new batch (new units to each other)
|
||||
# Apply the same top_k limit per unit as we do for existing units
|
||||
if len(unit_ids) > 1:
|
||||
new_embeddings_matrix = np.array(embeddings)
|
||||
|
||||
for i, unit_id in enumerate(unit_ids):
|
||||
# Compute similarities with all OTHER new units
|
||||
other_indices = [j for j in range(len(unit_ids)) if j != i]
|
||||
if not other_indices:
|
||||
continue
|
||||
|
||||
other_embeddings = new_embeddings_matrix[other_indices]
|
||||
similarities = np.dot(other_embeddings, new_embeddings_matrix[i])
|
||||
|
||||
# Find top-k above threshold (same logic as existing units)
|
||||
above_threshold = np.where(similarities >= threshold)[0]
|
||||
|
||||
if len(above_threshold) > 0:
|
||||
# Sort by similarity (descending) and take top-k
|
||||
sorted_local_indices = above_threshold[np.argsort(-similarities[above_threshold])][:top_k]
|
||||
|
||||
for local_idx in sorted_local_indices:
|
||||
other_idx = other_indices[local_idx]
|
||||
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))
|
||||
|
||||
_log(
|
||||
log_buffer,
|
||||
f" [8.2] Compute similarities & generate {len(all_links)} semantic links: {time_mod.time() - compute_start:.3f}s",
|
||||
)
|
||||
|
||||
if all_links:
|
||||
insert_start = time_mod.time()
|
||||
await conn.executemany(
|
||||
"""
|
||||
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
|
||||
f"""
|
||||
INSERT INTO {fq_table("memory_links")} (from_unit_id, to_unit_id, link_type, weight, entity_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
|
||||
""",
|
||||
all_links
|
||||
all_links,
|
||||
)
|
||||
_log(log_buffer, f" [8.3] Insert {len(all_links)} semantic links: {time_mod.time() - insert_start:.3f}s")
|
||||
_log(
|
||||
log_buffer, f" [8.3] Insert {len(all_links)} semantic links: {time_mod.time() - insert_start:.3f}s"
|
||||
)
|
||||
|
||||
return len(all_links)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create semantic links: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
|
||||
async def insert_entity_links_batch(conn, links: List[tuple]):
|
||||
async def insert_entity_links_batch(conn, links: list[EntityLink], chunk_size: int = 50000):
|
||||
"""
|
||||
Insert all entity links in a single batch.
|
||||
Insert all entity links using COPY to temp table + INSERT for maximum speed.
|
||||
|
||||
Uses PostgreSQL COPY (via copy_records_to_table) for bulk loading,
|
||||
then INSERT ... ON CONFLICT from temp table. This is the fastest
|
||||
method for bulk inserts with conflict handling.
|
||||
|
||||
Args:
|
||||
conn: Database connection
|
||||
links: List of tuples (from_unit_id, to_unit_id, link_type, weight, entity_id)
|
||||
links: List of EntityLink objects
|
||||
chunk_size: Number of rows per batch (default 50000)
|
||||
"""
|
||||
if not links:
|
||||
return
|
||||
|
||||
await conn.executemany(
|
||||
"""
|
||||
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
|
||||
import time as time_mod
|
||||
|
||||
total_start = time_mod.time()
|
||||
|
||||
# Create temp table for bulk loading
|
||||
create_start = time_mod.time()
|
||||
await conn.execute("""
|
||||
CREATE TEMP TABLE IF NOT EXISTS _temp_entity_links (
|
||||
from_unit_id uuid,
|
||||
to_unit_id uuid,
|
||||
link_type text,
|
||||
weight float,
|
||||
entity_id uuid
|
||||
) ON COMMIT DROP
|
||||
""")
|
||||
logger.debug(f" [9.1] Create temp table: {time_mod.time() - create_start:.3f}s")
|
||||
|
||||
# Clear any existing data in temp table
|
||||
truncate_start = time_mod.time()
|
||||
await conn.execute("TRUNCATE _temp_entity_links")
|
||||
logger.debug(f" [9.2] Truncate temp table: {time_mod.time() - truncate_start:.3f}s")
|
||||
|
||||
# Convert EntityLink objects to tuples for COPY
|
||||
convert_start = time_mod.time()
|
||||
records = []
|
||||
for link in links:
|
||||
records.append((link.from_unit_id, link.to_unit_id, link.link_type, link.weight, link.entity_id))
|
||||
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",
|
||||
records=records,
|
||||
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)
|
||||
SELECT from_unit_id, to_unit_id, link_type, weight, entity_id
|
||||
FROM _temp_entity_links
|
||||
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
|
||||
""")
|
||||
logger.debug(f" [9.5] INSERT from temp table: {time_mod.time() - insert_start:.3f}s")
|
||||
logger.debug(f" [9.TOTAL] Entity links batch insert: {time_mod.time() - total_start:.3f}s")
|
||||
|
||||
|
||||
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.
|
||||
@@ -581,6 +763,7 @@ async def create_causal_links_batch(
|
||||
|
||||
try:
|
||||
import time as time_mod
|
||||
|
||||
create_start = time_mod.time()
|
||||
|
||||
# Build links list
|
||||
@@ -592,12 +775,12 @@ async def create_causal_links_batch(
|
||||
from_unit_id = unit_ids[fact_idx]
|
||||
|
||||
for relation in causal_relations:
|
||||
target_idx = relation['target_fact_index']
|
||||
relation_type = relation['relation_type']
|
||||
strength = relation.get('strength', 1.0)
|
||||
target_idx = relation["target_fact_index"]
|
||||
relation_type = relation["relation_type"]
|
||||
strength = relation.get("strength", 1.0)
|
||||
|
||||
# Validate relation_type - must match database constraint
|
||||
valid_types = {'causes', 'caused_by', 'enables', 'prevents'}
|
||||
valid_types = {"causes", "caused_by", "enables", "prevents"}
|
||||
if relation_type not in valid_types:
|
||||
logger.error(
|
||||
f"Invalid relation_type '{relation_type}' (type: {type(relation_type).__name__}) "
|
||||
@@ -622,24 +805,25 @@ async def create_causal_links_batch(
|
||||
# weight is the strength of the relationship
|
||||
links.append((from_unit_id, to_unit_id, relation_type, strength, None))
|
||||
|
||||
|
||||
if links:
|
||||
insert_start = time_mod.time()
|
||||
try:
|
||||
await conn.executemany(
|
||||
"""
|
||||
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
|
||||
f"""
|
||||
INSERT INTO {fq_table("memory_links")} (from_unit_id, to_unit_id, link_type, weight, entity_id)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
|
||||
""",
|
||||
links
|
||||
links,
|
||||
)
|
||||
except Exception as db_error:
|
||||
# Log the actual data being inserted for debugging
|
||||
logger.error(f"Database insert failed for causal links. Error: {db_error}")
|
||||
logger.error(f"Attempted to insert {len(links)} links. First few:")
|
||||
for i, link in enumerate(links[:3]):
|
||||
logger.error(f" Link {i}: from={link[0]}, to={link[1]}, type='{link[2]}' (repr={repr(link[2])}), weight={link[3]}, entity={link[4]}")
|
||||
logger.error(
|
||||
f" Link {i}: from={link[0]}, to={link[1]}, type='{link[2]}' (repr={repr(link[2])}), weight={link[3]}, entity={link[4]}"
|
||||
)
|
||||
raise
|
||||
|
||||
return len(links)
|
||||
@@ -647,5 +831,6 @@ async def create_causal_links_batch(
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create causal links: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
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 ..memory_engine import fq_table
|
||||
from ..search import observation_utils
|
||||
from . import embedding_utils
|
||||
from .types import EntityLink
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def utcnow():
|
||||
"""Get current UTC time."""
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
# Simple dataclass-like container for facts (avoid importing from memory_engine)
|
||||
class MemoryFactForObservation:
|
||||
def __init__(self, id: str, text: str, fact_type: str, context: str, occurred_start: str | None):
|
||||
self.id = id
|
||||
self.text = text
|
||||
self.fact_type = fact_type
|
||||
self.context = context
|
||||
self.occurred_start = occurred_start
|
||||
|
||||
|
||||
async def regenerate_observations_batch(
|
||||
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.
|
||||
|
||||
Called INSIDE the retain transaction for atomicity - if observations
|
||||
fail, the entire retain batch is rolled back.
|
||||
|
||||
Args:
|
||||
conn: Database connection (from the retain transaction)
|
||||
embeddings_model: Embeddings model for generating observation embeddings
|
||||
llm_config: LLM configuration for observation extraction
|
||||
bank_id: Bank identifier
|
||||
entity_links: Entity links from this batch
|
||||
log_buffer: Optional log buffer for timing
|
||||
"""
|
||||
TOP_N_ENTITIES = 5
|
||||
MIN_FACTS_THRESHOLD = 5
|
||||
|
||||
if not entity_links:
|
||||
return
|
||||
|
||||
# Count mentions per entity in this batch
|
||||
entity_mention_counts: dict[str, int] = {}
|
||||
for link in entity_links:
|
||||
if link.entity_id:
|
||||
entity_id = str(link.entity_id)
|
||||
entity_mention_counts[entity_id] = entity_mention_counts.get(entity_id, 0) + 1
|
||||
|
||||
if not entity_mention_counts:
|
||||
return
|
||||
|
||||
# Sort by mention count descending and take top N
|
||||
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()
|
||||
|
||||
# Convert to UUIDs
|
||||
entity_uuids = [uuid.UUID(eid) if isinstance(eid, str) else eid for eid in entities_to_process]
|
||||
|
||||
# Batch query for entity names
|
||||
entity_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, canonical_name FROM {fq_table("entities")}
|
||||
WHERE id = ANY($1) AND bank_id = $2
|
||||
""",
|
||||
entity_uuids,
|
||||
bank_id,
|
||||
)
|
||||
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
|
||||
WHERE ue.entity_id = ANY($1) AND mu.bank_id = $2
|
||||
GROUP BY ue.entity_id
|
||||
""",
|
||||
entity_uuids,
|
||||
bank_id,
|
||||
)
|
||||
entity_fact_counts = {row["entity_id"]: row["cnt"] for row in fact_counts}
|
||||
|
||||
# Filter entities that meet the threshold
|
||||
entities_with_names = []
|
||||
for entity_id in entities_to_process:
|
||||
entity_uuid = uuid.UUID(entity_id) if isinstance(entity_id, str) else entity_id
|
||||
if entity_uuid not in entity_names:
|
||||
continue
|
||||
fact_count = entity_fact_counts.get(entity_uuid, 0)
|
||||
if fact_count >= MIN_FACTS_THRESHOLD:
|
||||
entities_with_names.append((entity_id, entity_names[entity_uuid]))
|
||||
|
||||
if not entities_with_names:
|
||||
return
|
||||
|
||||
# Process entities SEQUENTIALLY (asyncpg doesn't allow concurrent queries on same connection)
|
||||
# We must use the same connection to stay in the retain transaction
|
||||
total_observations = 0
|
||||
|
||||
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
|
||||
)
|
||||
total_observations += len(obs_ids)
|
||||
except Exception as e:
|
||||
logger.error(f"[OBSERVATIONS] Error processing entity {entity_id}: {e}")
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
async def _regenerate_entity_observations(
|
||||
conn, embeddings_model, llm_config, bank_id: str, entity_id: str, entity_name: str
|
||||
) -> list[str]:
|
||||
"""
|
||||
Regenerate observations for a single entity.
|
||||
|
||||
Uses the provided connection (part of retain transaction).
|
||||
|
||||
Args:
|
||||
conn: Database connection (from the retain transaction)
|
||||
embeddings_model: Embeddings model
|
||||
llm_config: LLM configuration
|
||||
bank_id: Bank identifier
|
||||
entity_id: Entity UUID
|
||||
entity_name: Canonical name of the entity
|
||||
|
||||
Returns:
|
||||
List of created observation IDs
|
||||
"""
|
||||
entity_uuid = uuid.UUID(entity_id) if isinstance(entity_id, str) else entity_id
|
||||
|
||||
# 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
|
||||
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,
|
||||
)
|
||||
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# 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,
|
||||
)
|
||||
)
|
||||
|
||||
# Extract observations using LLM
|
||||
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")}
|
||||
WHERE id IN (
|
||||
SELECT mu.id
|
||||
FROM {fq_table("memory_units")} mu
|
||||
JOIN {fq_table("unit_entities")} ue ON mu.id = ue.unit_id
|
||||
WHERE mu.bank_id = $1
|
||||
AND mu.fact_type = 'observation'
|
||||
AND ue.entity_id = $2
|
||||
)
|
||||
""",
|
||||
bank_id,
|
||||
entity_uuid,
|
||||
)
|
||||
|
||||
# Generate embeddings for new observations
|
||||
embeddings = await embedding_utils.generate_embeddings_batch(embeddings_model, observations)
|
||||
|
||||
# Insert new observations
|
||||
current_time = utcnow()
|
||||
created_ids = []
|
||||
|
||||
for obs_text, embedding in zip(observations, embeddings):
|
||||
result = await conn.fetchrow(
|
||||
f"""
|
||||
INSERT INTO {fq_table("memory_units")} (
|
||||
bank_id, text, embedding, context, event_date,
|
||||
occurred_start, occurred_end, mentioned_at,
|
||||
fact_type, access_count
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'observation', 0)
|
||||
RETURNING id
|
||||
""",
|
||||
bank_id,
|
||||
obs_text,
|
||||
str(embedding),
|
||||
f"observation about {entity_name}",
|
||||
current_time,
|
||||
current_time,
|
||||
current_time,
|
||||
current_time,
|
||||
)
|
||||
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)
|
||||
VALUES ($1, $2)
|
||||
""",
|
||||
uuid.UUID(obs_id),
|
||||
entity_uuid,
|
||||
)
|
||||
|
||||
return created_ids
|
||||
@@ -3,30 +3,32 @@ Main orchestrator for the retain pipeline.
|
||||
|
||||
Coordinates all retain pipeline modules to store memories efficiently.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from . import bank_utils
|
||||
from ..db_utils import acquire_with_retry
|
||||
from . import bank_utils
|
||||
|
||||
|
||||
def utcnow():
|
||||
"""Get current UTC time."""
|
||||
return datetime.now(timezone.utc)
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
from .types import RetainContent, ExtractedFact, ProcessedFact
|
||||
from . import (
|
||||
fact_extraction,
|
||||
embedding_processing,
|
||||
deduplication,
|
||||
chunk_storage,
|
||||
fact_storage,
|
||||
deduplication,
|
||||
embedding_processing,
|
||||
entity_processing,
|
||||
link_creation
|
||||
fact_extraction,
|
||||
fact_storage,
|
||||
link_creation,
|
||||
observation_regeneration,
|
||||
)
|
||||
from .types import ExtractedFact, ProcessedFact, RetainContent, RetainContentDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,14 +41,13 @@ async def retain_batch(
|
||||
task_backend,
|
||||
format_date_fn,
|
||||
duplicate_checker_fn,
|
||||
regenerate_observations_fn,
|
||||
bank_id: str,
|
||||
contents_dicts: List[Dict[str, Any]],
|
||||
document_id: Optional[str] = None,
|
||||
contents_dicts: list[RetainContentDict],
|
||||
document_id: str | None = None,
|
||||
is_first_batch: bool = True,
|
||||
fact_type_override: Optional[str] = None,
|
||||
confidence_score: Optional[float] = None,
|
||||
) -> List[List[str]]:
|
||||
fact_type_override: str | None = None,
|
||||
confidence_score: float | None = None,
|
||||
) -> list[list[str]]:
|
||||
"""
|
||||
Process a batch of content through the retain pipeline.
|
||||
|
||||
@@ -58,7 +59,6 @@ async def retain_batch(
|
||||
task_backend: Task backend for background jobs
|
||||
format_date_fn: Function to format datetime to readable string
|
||||
duplicate_checker_fn: Function to check for duplicate facts
|
||||
regenerate_observations_fn: Async function to regenerate observations for entities
|
||||
bank_id: Bank identifier
|
||||
contents_dicts: List of content dictionaries
|
||||
document_id: Optional document ID
|
||||
@@ -74,10 +74,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,23 +90,79 @@ 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
|
||||
contents, llm_config, agent_name, extract_opinions
|
||||
)
|
||||
log_buffer.append(
|
||||
f"[1] Extract facts: {len(extracted_facts)} facts, {len(chunks)} chunks from {len(contents)} contents in {time.time() - step_start:.3f}s"
|
||||
)
|
||||
log_buffer.append(f"[1] Extract facts: {len(extracted_facts)} facts, {len(chunks)} chunks from {len(contents)} contents in {time.time() - step_start:.3f}s")
|
||||
|
||||
if not extracted_facts:
|
||||
# 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
|
||||
@@ -131,6 +187,7 @@ async def retain_batch(
|
||||
|
||||
# Group contents by document_id for document tracking and chunk storage
|
||||
from collections import defaultdict
|
||||
|
||||
contents_by_doc = defaultdict(list)
|
||||
for idx, content_dict in enumerate(contents_dicts):
|
||||
doc_id = content_dict.get("document_id")
|
||||
@@ -156,7 +213,11 @@ async def retain_batch(
|
||||
if first_item.get("context"):
|
||||
retain_params["context"] = first_item["context"]
|
||||
if first_item.get("event_date"):
|
||||
retain_params["event_date"] = first_item["event_date"].isoformat() if hasattr(first_item["event_date"], "isoformat") else str(first_item["event_date"])
|
||||
retain_params["event_date"] = (
|
||||
first_item["event_date"].isoformat()
|
||||
if hasattr(first_item["event_date"], "isoformat")
|
||||
else str(first_item["event_date"])
|
||||
)
|
||||
if first_item.get("metadata"):
|
||||
retain_params["metadata"] = first_item["metadata"]
|
||||
|
||||
@@ -196,7 +257,11 @@ async def retain_batch(
|
||||
if first_item.get("context"):
|
||||
retain_params["context"] = first_item["context"]
|
||||
if first_item.get("event_date"):
|
||||
retain_params["event_date"] = first_item["event_date"].isoformat() if hasattr(first_item["event_date"], "isoformat") else str(first_item["event_date"])
|
||||
retain_params["event_date"] = (
|
||||
first_item["event_date"].isoformat()
|
||||
if hasattr(first_item["event_date"], "isoformat")
|
||||
else str(first_item["event_date"])
|
||||
)
|
||||
if first_item.get("metadata"):
|
||||
retain_params["metadata"] = first_item["metadata"]
|
||||
|
||||
@@ -206,7 +271,9 @@ async def retain_batch(
|
||||
document_ids_added.append(actual_doc_id)
|
||||
|
||||
if document_ids_added:
|
||||
log_buffer.append(f"[2.5] Document tracking: {len(document_ids_added)} documents in {time.time() - step_start:.3f}s")
|
||||
log_buffer.append(
|
||||
f"[2.5] Document tracking: {len(document_ids_added)} documents in {time.time() - step_start:.3f}s"
|
||||
)
|
||||
|
||||
# Store chunks and map to facts for all documents
|
||||
step_start = time.time()
|
||||
@@ -231,7 +298,9 @@ async def retain_batch(
|
||||
for chunk_idx, chunk_id in chunk_id_map.items():
|
||||
chunk_id_map_by_doc[(doc_id, chunk_idx)] = chunk_id
|
||||
|
||||
log_buffer.append(f"[3] Store chunks: {len(chunks)} chunks for {len(chunks_by_doc)} documents in {time.time() - step_start:.3f}s")
|
||||
log_buffer.append(
|
||||
f"[3] Store chunks: {len(chunks)} chunks for {len(chunks_by_doc)} documents in {time.time() - step_start:.3f}s"
|
||||
)
|
||||
|
||||
# Map chunk_ids and document_ids to facts
|
||||
for fact, processed_fact in zip(extracted_facts, processed_facts):
|
||||
@@ -266,7 +335,9 @@ 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)
|
||||
@@ -288,59 +359,60 @@ async def retain_batch(
|
||||
|
||||
# Create temporal links
|
||||
step_start = time.time()
|
||||
await link_creation.create_temporal_links_batch(conn, bank_id, unit_ids)
|
||||
log_buffer.append(f"[7] Temporal links: {time.time() - step_start:.3f}s")
|
||||
temporal_link_count = await link_creation.create_temporal_links_batch(conn, bank_id, unit_ids)
|
||||
log_buffer.append(f"[7] Temporal links: {temporal_link_count} links in {time.time() - step_start:.3f}s")
|
||||
|
||||
# Create semantic links
|
||||
step_start = time.time()
|
||||
embeddings_for_links = [fact.embedding for fact in non_duplicate_facts]
|
||||
await link_creation.create_semantic_links_batch(conn, bank_id, unit_ids, embeddings_for_links)
|
||||
log_buffer.append(f"[8] Semantic links: {time.time() - step_start:.3f}s")
|
||||
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: {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()
|
||||
causal_link_count = await link_creation.create_causal_links_batch(conn, unit_ids, non_duplicate_facts)
|
||||
log_buffer.append(f"[10] Causal links: {causal_link_count} links in {time.time() - step_start:.3f}s")
|
||||
|
||||
# Map results back to original content items
|
||||
result_unit_ids = _map_results_to_contents(
|
||||
contents, extracted_facts, is_duplicate_flags, unit_ids
|
||||
# Regenerate observations INSIDE transaction for atomicity
|
||||
await observation_regeneration.regenerate_observations_batch(
|
||||
conn, embeddings_model, llm_config, bank_id, entity_links, log_buffer
|
||||
)
|
||||
|
||||
total_time = time.time() - start_time
|
||||
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}")
|
||||
# Map results back to original content items
|
||||
result_unit_ids = _map_results_to_contents(contents, extracted_facts, is_duplicate_flags, unit_ids)
|
||||
|
||||
logger.info("\n" + "\n".join(log_buffer) + "\n")
|
||||
# Trigger background tasks AFTER transaction commits (opinion reinforcement only)
|
||||
await _trigger_background_tasks(task_backend, bank_id, unit_ids, non_duplicate_facts)
|
||||
|
||||
# Trigger background tasks AFTER transaction commits
|
||||
await _trigger_background_tasks(
|
||||
task_backend,
|
||||
regenerate_observations_fn,
|
||||
bank_id,
|
||||
unit_ids,
|
||||
non_duplicate_facts,
|
||||
entity_links
|
||||
)
|
||||
# Log final summary
|
||||
total_time = time.time() - start_time
|
||||
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}")
|
||||
|
||||
logger.info("\n" + "\n".join(log_buffer) + "\n")
|
||||
|
||||
return result_unit_ids
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -367,39 +439,20 @@ def _map_results_to_contents(
|
||||
|
||||
async def _trigger_background_tasks(
|
||||
task_backend,
|
||||
regenerate_observations_fn,
|
||||
bank_id: str,
|
||||
unit_ids: List[str],
|
||||
facts: List[ProcessedFact],
|
||||
entity_links: List
|
||||
unit_ids: list[str],
|
||||
facts: list[ProcessedFact],
|
||||
) -> None:
|
||||
"""Trigger opinion reinforcement and observation regeneration (sync)."""
|
||||
"""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
|
||||
})
|
||||
|
||||
# Regenerate observations synchronously for top entities
|
||||
TOP_N_ENTITIES = 5
|
||||
MIN_FACTS_THRESHOLD = 5
|
||||
|
||||
if entity_links and regenerate_observations_fn:
|
||||
unique_entity_ids = set()
|
||||
for link in entity_links:
|
||||
# links are tuples: (unit_id, entity_id, confidence)
|
||||
if len(link) >= 2 and link[1]:
|
||||
unique_entity_ids.add(str(link[1]))
|
||||
|
||||
if unique_entity_ids:
|
||||
# Run observation regeneration synchronously
|
||||
await regenerate_observations_fn(
|
||||
bank_id=bank_id,
|
||||
entity_ids=list(unique_entity_ids)[:TOP_N_ENTITIES],
|
||||
min_facts=MIN_FACTS_THRESHOLD
|
||||
)
|
||||
await task_backend.submit_task(
|
||||
{
|
||||
"type": "reinforce_opinion",
|
||||
"bank_id": bank_id,
|
||||
"created_unit_ids": unit_ids,
|
||||
"unit_texts": [fact.fact_text for fact in facts],
|
||||
"unit_entities": fact_entities,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,11 +6,34 @@ from content input to fact storage.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import TypedDict
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class RetainContentDict(TypedDict, total=False):
|
||||
"""Type definition for content items in retain_batch_async.
|
||||
|
||||
Fields:
|
||||
content: Text content to store (required)
|
||||
context: Context about the content (optional)
|
||||
event_date: When the content occurred (optional, defaults to now)
|
||||
metadata: Custom key-value metadata (optional)
|
||||
document_id: Document ID for this content item (optional)
|
||||
"""
|
||||
|
||||
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:
|
||||
"""
|
||||
@@ -18,16 +41,11 @@ class RetainContent:
|
||||
|
||||
Represents a single piece of content to extract facts from.
|
||||
"""
|
||||
|
||||
content: str
|
||||
context: str = ""
|
||||
event_date: Optional[datetime] = None
|
||||
metadata: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Ensure event_date is set."""
|
||||
if self.event_date is None:
|
||||
from datetime import datetime, timezone
|
||||
self.event_date = datetime.now(timezone.utc)
|
||||
event_date: datetime = field(default_factory=_now_utc)
|
||||
metadata: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -37,6 +55,7 @@ class ChunkMetadata:
|
||||
|
||||
Used to track which facts were extracted from which chunks.
|
||||
"""
|
||||
|
||||
chunk_text: str
|
||||
fact_count: int
|
||||
content_index: int # Index of the source content
|
||||
@@ -50,9 +69,10 @@ class EntityRef:
|
||||
|
||||
Entities are extracted by the LLM during fact extraction.
|
||||
"""
|
||||
|
||||
name: str
|
||||
canonical_name: Optional[str] = None # Resolved canonical name
|
||||
entity_id: Optional[UUID] = None # Resolved entity ID
|
||||
canonical_name: str | None = None # Resolved canonical name
|
||||
entity_id: UUID | None = None # Resolved entity ID
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -62,6 +82,7 @@ class CausalRelation:
|
||||
|
||||
Represents how one fact causes, enables, or prevents another.
|
||||
"""
|
||||
|
||||
relation_type: str # "causes", "enables", "prevents", "caused_by"
|
||||
target_fact_index: int # Index of the target fact in the batch
|
||||
strength: float = 1.0 # Strength of the causal relationship
|
||||
@@ -74,20 +95,21 @@ class ExtractedFact:
|
||||
|
||||
This is the raw output from fact extraction before processing.
|
||||
"""
|
||||
|
||||
fact_text: str
|
||||
fact_type: str # "world", "bank", "opinion", "observation"
|
||||
entities: List[str] = field(default_factory=list)
|
||||
occurred_start: Optional[datetime] = None
|
||||
occurred_end: Optional[datetime] = None
|
||||
where: Optional[str] = None # WHERE the fact occurred or is about
|
||||
causal_relations: List[CausalRelation] = field(default_factory=list)
|
||||
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)
|
||||
|
||||
# Context from the content item
|
||||
content_index: int = 0 # Which content this fact came from
|
||||
chunk_index: int = 0 # Which chunk this fact came from
|
||||
context: str = ""
|
||||
mentioned_at: Optional[datetime] = None
|
||||
metadata: Dict[str, str] = field(default_factory=dict)
|
||||
mentioned_at: datetime | None = None
|
||||
metadata: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -97,37 +119,38 @@ class ProcessedFact:
|
||||
|
||||
Includes resolved entities, embeddings, and all necessary fields.
|
||||
"""
|
||||
|
||||
# Core fact data
|
||||
fact_text: str
|
||||
fact_type: str
|
||||
embedding: List[float]
|
||||
embedding: list[float]
|
||||
|
||||
# Temporal data
|
||||
occurred_start: Optional[datetime]
|
||||
occurred_end: Optional[datetime]
|
||||
occurred_start: datetime | None
|
||||
occurred_end: datetime | None
|
||||
mentioned_at: datetime
|
||||
|
||||
# Context and metadata
|
||||
context: str
|
||||
metadata: Dict[str, str]
|
||||
metadata: dict[str, str]
|
||||
|
||||
# Location data
|
||||
where: Optional[str] = None
|
||||
where: str | None = None
|
||||
|
||||
# Entities
|
||||
entities: List[EntityRef] = field(default_factory=list)
|
||||
entities: list[EntityRef] = field(default_factory=list)
|
||||
|
||||
# Causal relations
|
||||
causal_relations: List[CausalRelation] = field(default_factory=list)
|
||||
causal_relations: list[CausalRelation] = field(default_factory=list)
|
||||
|
||||
# Chunk reference
|
||||
chunk_id: Optional[str] = None
|
||||
chunk_id: str | None = None
|
||||
|
||||
# Document reference (denormalized for query performance)
|
||||
document_id: Optional[str] = None
|
||||
document_id: str | None = None
|
||||
|
||||
# DB fields (set after insertion)
|
||||
unit_id: Optional[UUID] = None
|
||||
unit_id: UUID | None = None
|
||||
|
||||
@property
|
||||
def is_duplicate(self) -> bool:
|
||||
@@ -136,10 +159,8 @@ class ProcessedFact:
|
||||
|
||||
@staticmethod
|
||||
def from_extracted_fact(
|
||||
extracted_fact: 'ExtractedFact',
|
||||
embedding: List[float],
|
||||
chunk_id: Optional[str] = None
|
||||
) -> 'ProcessedFact':
|
||||
extracted_fact: "ExtractedFact", embedding: list[float], chunk_id: str | None = None
|
||||
) -> "ProcessedFact":
|
||||
"""
|
||||
Create ProcessedFact from ExtractedFact.
|
||||
|
||||
@@ -151,12 +172,12 @@ class ProcessedFact:
|
||||
Returns:
|
||||
ProcessedFact ready for storage
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime
|
||||
|
||||
# Use occurred dates only if explicitly provided by LLM
|
||||
occurred_start = extracted_fact.occurred_start
|
||||
occurred_end = extracted_fact.occurred_end
|
||||
mentioned_at = extracted_fact.mentioned_at or datetime.now(timezone.utc)
|
||||
mentioned_at = extracted_fact.mentioned_at or datetime.now(UTC)
|
||||
|
||||
# Convert entity strings to EntityRef objects
|
||||
entities = [EntityRef(name=name) for name in extracted_fact.entities]
|
||||
@@ -172,10 +193,25 @@ class ProcessedFact:
|
||||
metadata=extracted_fact.metadata,
|
||||
entities=entities,
|
||||
causal_relations=extracted_fact.causal_relations,
|
||||
chunk_id=chunk_id
|
||||
chunk_id=chunk_id,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EntityLink:
|
||||
"""
|
||||
Link between two memory units through a shared entity.
|
||||
|
||||
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"
|
||||
weight: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class RetainBatch:
|
||||
"""
|
||||
@@ -183,24 +219,25 @@ class RetainBatch:
|
||||
|
||||
Tracks all facts, chunks, and metadata for a batch operation.
|
||||
"""
|
||||
|
||||
bank_id: str
|
||||
contents: List[RetainContent]
|
||||
document_id: Optional[str] = None
|
||||
fact_type_override: Optional[str] = None
|
||||
confidence_score: Optional[float] = None
|
||||
contents: list[RetainContent]
|
||||
document_id: str | None = None
|
||||
fact_type_override: str | None = None
|
||||
confidence_score: float | None = None
|
||||
|
||||
# Extracted data (populated during processing)
|
||||
extracted_facts: List[ExtractedFact] = field(default_factory=list)
|
||||
processed_facts: List[ProcessedFact] = field(default_factory=list)
|
||||
chunks: List[ChunkMetadata] = field(default_factory=list)
|
||||
extracted_facts: list[ExtractedFact] = field(default_factory=list)
|
||||
processed_facts: list[ProcessedFact] = field(default_factory=list)
|
||||
chunks: list[ChunkMetadata] = field(default_factory=list)
|
||||
|
||||
# Results (populated after storage)
|
||||
unit_ids_by_content: List[List[str]] = field(default_factory=list)
|
||||
unit_ids_by_content: list[list[str]] = field(default_factory=list)
|
||||
|
||||
def get_facts_for_content(self, content_index: int) -> List[ExtractedFact]:
|
||||
def get_facts_for_content(self, content_index: int) -> list[ExtractedFact]:
|
||||
"""Get all extracted facts for a specific content item."""
|
||||
return [f for f in self.extracted_facts if f.content_index == content_index]
|
||||
|
||||
def get_chunks_for_content(self, content_index: int) -> List[ChunkMetadata]:
|
||||
def get_chunks_for_content(self, content_index: int) -> list[ChunkMetadata]:
|
||||
"""Get all chunks for a specific content item."""
|
||||
return [c for c in self.chunks if c.content_index == content_index]
|
||||
|
||||
@@ -3,13 +3,27 @@ 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 .retrieval import retrieve_parallel
|
||||
from .graph_retrieval import BFSGraphRetriever, GraphRetriever
|
||||
from .mpfp_retrieval import MPFPGraphRetriever
|
||||
from .reranking import CrossEncoderReranker
|
||||
from .retrieval import (
|
||||
ParallelRetrievalResult,
|
||||
get_default_graph_retriever,
|
||||
retrieve_parallel,
|
||||
set_default_graph_retriever,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"retrieve_parallel",
|
||||
"get_default_graph_retriever",
|
||||
"set_default_graph_retriever",
|
||||
"ParallelRetrievalResult",
|
||||
"GraphRetriever",
|
||||
"BFSGraphRetriever",
|
||||
"MPFPGraphRetriever",
|
||||
"CrossEncoderReranker",
|
||||
]
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
Helper functions for hybrid search (semantic + BM25 + graph).
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Tuple
|
||||
import asyncio
|
||||
from .types import RetrievalResult, MergedCandidate
|
||||
from typing import Any
|
||||
|
||||
from .types import MergedCandidate, RetrievalResult
|
||||
|
||||
|
||||
def reciprocal_rank_fusion(
|
||||
result_lists: List[List[RetrievalResult]],
|
||||
k: int = 60
|
||||
) -> List[MergedCandidate]:
|
||||
def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 60) -> list[MergedCandidate]:
|
||||
"""
|
||||
Merge multiple ranked result lists using Reciprocal Rank Fusion.
|
||||
|
||||
@@ -73,20 +70,14 @@ def reciprocal_rank_fusion(
|
||||
sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True), start=1
|
||||
):
|
||||
merged_candidate = MergedCandidate(
|
||||
retrieval=all_retrievals[doc_id],
|
||||
rrf_score=rrf_score,
|
||||
rrf_rank=rrf_rank,
|
||||
source_ranks=source_ranks[doc_id]
|
||||
retrieval=all_retrievals[doc_id], rrf_score=rrf_score, rrf_rank=rrf_rank, source_ranks=source_ranks[doc_id]
|
||||
)
|
||||
merged_results.append(merged_candidate)
|
||||
|
||||
return merged_results
|
||||
|
||||
|
||||
def normalize_scores_on_deltas(
|
||||
results: List[Dict[str, Any]],
|
||||
score_keys: List[str]
|
||||
) -> List[Dict[str, Any]]:
|
||||
def normalize_scores_on_deltas(results: list[dict[str, Any]], score_keys: list[str]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Normalize scores based on deltas (min-max normalization within result set).
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
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,18 +16,17 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class Observation(BaseModel):
|
||||
"""An observation about an entity."""
|
||||
|
||||
observation: str = Field(description="The observation text - a factual statement about the entity")
|
||||
|
||||
|
||||
class ObservationExtractionResponse(BaseModel):
|
||||
"""Response containing extracted observations."""
|
||||
observations: List[Observation] = Field(
|
||||
default_factory=list,
|
||||
description="List of observations about the entity"
|
||||
)
|
||||
|
||||
observations: list[Observation] = Field(default_factory=list, description="List of observations about the entity")
|
||||
|
||||
|
||||
def format_facts_for_observation_prompt(facts: List[MemoryFact]) -> str:
|
||||
def format_facts_for_observation_prompt(facts: list[MemoryFact]) -> str:
|
||||
"""Format facts as text for observation extraction prompt."""
|
||||
import json
|
||||
|
||||
@@ -35,9 +34,7 @@ def format_facts_for_observation_prompt(facts: List[MemoryFact]) -> str:
|
||||
return "[]"
|
||||
formatted = []
|
||||
for fact in facts:
|
||||
fact_obj = {
|
||||
"text": fact.text
|
||||
}
|
||||
fact_obj = {"text": fact.text}
|
||||
|
||||
# Add context if available
|
||||
if fact.context:
|
||||
@@ -92,11 +89,7 @@ def get_observation_system_message() -> str:
|
||||
return "You are an objective observer synthesizing facts about an entity. Generate clear, factual observations without opinions or personality influence. Be concise and accurate."
|
||||
|
||||
|
||||
async def extract_observations_from_facts(
|
||||
llm_config,
|
||||
entity_name: str,
|
||||
facts: List[MemoryFact]
|
||||
) -> List[str]:
|
||||
async def extract_observations_from_facts(llm_config, entity_name: str, facts: list[MemoryFact]) -> list[str]:
|
||||
"""
|
||||
Extract observations from facts about an entity using LLM.
|
||||
|
||||
@@ -118,10 +111,10 @@ async def extract_observations_from_facts(
|
||||
result = await llm_config.call(
|
||||
messages=[
|
||||
{"role": "system", "content": get_observation_system_message()},
|
||||
{"role": "user", "content": prompt}
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
response_format=ObservationExtractionResponse,
|
||||
scope="memory_extract_observation"
|
||||
scope="memory_extract_observation",
|
||||
)
|
||||
|
||||
observations = [op.observation for op in result.observations]
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
Cross-encoder neural reranking for search results.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
from .types import MergedCandidate, ScoredResult
|
||||
|
||||
|
||||
@@ -10,10 +9,8 @@ class CrossEncoderReranker:
|
||||
"""
|
||||
Neural reranking using a cross-encoder model.
|
||||
|
||||
Uses cross-encoder/ms-marco-MiniLM-L-6-v2 by default:
|
||||
- Fast inference (~80ms for 100 pairs on CPU)
|
||||
- Small model (80MB)
|
||||
- Trained for passage re-ranking
|
||||
Configured via environment variables (see cross_encoder.py).
|
||||
Default local model is cross-encoder/ms-marco-MiniLM-L-6-v2.
|
||||
"""
|
||||
|
||||
def __init__(self, cross_encoder=None):
|
||||
@@ -21,21 +18,33 @@ class CrossEncoderReranker:
|
||||
Initialize cross-encoder reranker.
|
||||
|
||||
Args:
|
||||
cross_encoder: CrossEncoderReranker instance. If None, uses default
|
||||
SentenceTransformersCrossEncoder with ms-marco-MiniLM-L-6-v2
|
||||
(loaded lazily for faster startup)
|
||||
cross_encoder: CrossEncoderModel instance. If None, creates one from
|
||||
environment variables (defaults to local provider)
|
||||
"""
|
||||
if cross_encoder is None:
|
||||
from hindsight_api.engine.cross_encoder import SentenceTransformersCrossEncoder
|
||||
# Model is loaded lazily - call ensure_loaded() during initialize()
|
||||
cross_encoder = SentenceTransformersCrossEncoder()
|
||||
self.cross_encoder = cross_encoder
|
||||
from hindsight_api.engine.cross_encoder import create_cross_encoder_from_env
|
||||
|
||||
def rerank(
|
||||
self,
|
||||
query: str,
|
||||
candidates: List[MergedCandidate]
|
||||
) -> List[ScoredResult]:
|
||||
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]:
|
||||
"""
|
||||
Rerank candidates using cross-encoder scores.
|
||||
|
||||
@@ -81,6 +90,7 @@ class CrossEncoderReranker:
|
||||
# Normalize scores using sigmoid to [0, 1] range
|
||||
# Cross-encoder returns logits which can be negative
|
||||
import numpy as np
|
||||
|
||||
def sigmoid(x):
|
||||
return 1 / (1 + np.exp(-x))
|
||||
|
||||
@@ -93,7 +103,7 @@ class CrossEncoderReranker:
|
||||
candidate=candidate,
|
||||
cross_encoder_score=float(raw_score),
|
||||
cross_encoder_score_normalized=float(norm_score),
|
||||
weight=float(norm_score) # Initial weight is just cross-encoder score
|
||||
weight=float(norm_score), # Initial weight is just cross-encoder score
|
||||
)
|
||||
scored_results.append(scored_result)
|
||||
|
||||
|
||||
@@ -4,24 +4,69 @@ Retrieval module for 4-way parallel search.
|
||||
Implements:
|
||||
1. Semantic retrieval (vector similarity)
|
||||
2. BM25 retrieval (keyword/full-text search)
|
||||
3. Graph retrieval (spreading activation)
|
||||
3. Graph retrieval (via pluggable GraphRetriever interface)
|
||||
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.
|
||||
|
||||
@@ -36,10 +81,10 @@ async def retrieve_semantic(
|
||||
List of RetrievalResult objects
|
||||
"""
|
||||
results = await conn.fetch(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM memory_units
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
AND embedding IS NOT NULL
|
||||
AND fact_type = $3
|
||||
@@ -47,18 +92,15 @@ async def retrieve_semantic(
|
||||
ORDER BY embedding <=> $1::vector
|
||||
LIMIT $4
|
||||
""",
|
||||
query_emb_str, bank_id, fact_type, limit
|
||||
query_emb_str,
|
||||
bank_id,
|
||||
fact_type,
|
||||
limit,
|
||||
)
|
||||
return [RetrievalResult.from_db_row(dict(r)) for r in results]
|
||||
|
||||
|
||||
async def retrieve_bm25(
|
||||
conn,
|
||||
query_text: str,
|
||||
bank_id: str,
|
||||
fact_type: str,
|
||||
limit: int
|
||||
) -> List[RetrievalResult]:
|
||||
async def retrieve_bm25(conn, query_text: str, bank_id: str, fact_type: str, limit: int) -> list[RetrievalResult]:
|
||||
"""
|
||||
BM25 keyword retrieval via full-text search.
|
||||
|
||||
@@ -76,7 +118,7 @@ async def retrieve_bm25(
|
||||
|
||||
# Sanitize query text: remove special characters that have meaning in tsquery
|
||||
# Keep only alphanumeric characters and spaces
|
||||
sanitized_text = re.sub(r'[^\w\s]', ' ', query_text.lower())
|
||||
sanitized_text = re.sub(r"[^\w\s]", " ", query_text.lower())
|
||||
|
||||
# Split and filter empty strings
|
||||
tokens = [token for token in sanitized_text.split() if token]
|
||||
@@ -90,136 +132,24 @@ async def retrieve_bm25(
|
||||
query_tsquery = " | ".join(tokens)
|
||||
|
||||
results = await conn.fetch(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
|
||||
ts_rank_cd(search_vector, to_tsquery('english', $1)) AS bm25_score
|
||||
FROM memory_units
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
AND fact_type = $3
|
||||
AND search_vector @@ to_tsquery('english', $1)
|
||||
ORDER BY bm25_score DESC
|
||||
LIMIT $4
|
||||
""",
|
||||
query_tsquery, bank_id, fact_type, limit
|
||||
query_tsquery,
|
||||
bank_id,
|
||||
fact_type,
|
||||
limit,
|
||||
)
|
||||
return [RetrievalResult.from_db_row(dict(r)) for r in results]
|
||||
|
||||
|
||||
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 * 10 = ~200 for good distribution)
|
||||
if batch_nodes and budget_remaining > 0:
|
||||
max_neighbors = len(batch_nodes) * 10
|
||||
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,
|
||||
@@ -228,8 +158,8 @@ async def retrieve_temporal(
|
||||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
budget: int,
|
||||
semantic_threshold: float = 0.4
|
||||
) -> List[RetrievalResult]:
|
||||
semantic_threshold: float = 0.1,
|
||||
) -> list[RetrievalResult]:
|
||||
"""
|
||||
Temporal retrieval with spreading activation.
|
||||
|
||||
@@ -251,19 +181,18 @@ async def retrieve_temporal(
|
||||
Returns:
|
||||
List of RetrievalResult objects with temporal scores
|
||||
"""
|
||||
from datetime import timezone
|
||||
|
||||
# Ensure start_date and end_date are timezone-aware (UTC) to match database datetimes
|
||||
if start_date.tzinfo is None:
|
||||
start_date = start_date.replace(tzinfo=timezone.utc)
|
||||
start_date = start_date.replace(tzinfo=UTC)
|
||||
if end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=timezone.utc)
|
||||
end_date = end_date.replace(tzinfo=UTC)
|
||||
|
||||
entry_points = await conn.fetch(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM memory_units
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
AND fact_type = $3
|
||||
AND embedding IS NOT NULL
|
||||
@@ -284,17 +213,15 @@ 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:
|
||||
# Check if there are ANY memories with temporal metadata for this bank
|
||||
total_with_dates = await conn.fetchval(
|
||||
"""SELECT COUNT(*) FROM memory_units
|
||||
WHERE bank_id = $1 AND fact_type = $2
|
||||
AND (occurred_start IS NOT NULL OR occurred_end IS NOT NULL OR mentioned_at IS NOT NULL)""",
|
||||
bank_id, fact_type
|
||||
)
|
||||
return []
|
||||
|
||||
# Calculate temporal scores for entry points
|
||||
@@ -334,7 +261,9 @@ async def retrieve_temporal(
|
||||
results.append(ep_result)
|
||||
|
||||
# Spread through temporal links
|
||||
queue = [(RetrievalResult.from_db_row(dict(ep)), ep["similarity"], 1.0) for ep in entry_points] # (unit, semantic_sim, temporal_score)
|
||||
queue = [
|
||||
(RetrievalResult.from_db_row(dict(ep)), ep["similarity"], 1.0) for ep in entry_points
|
||||
] # (unit, semantic_sim, temporal_score)
|
||||
budget_remaining = budget - len(entry_points)
|
||||
|
||||
while queue and budget_remaining > 0:
|
||||
@@ -344,12 +273,12 @@ async def retrieve_temporal(
|
||||
# Get neighbors via temporal and causal links
|
||||
if budget_remaining > 0:
|
||||
neighbors = await conn.fetch(
|
||||
"""
|
||||
f"""
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id,
|
||||
ml.weight, ml.link_type,
|
||||
1 - (mu.embedding <=> $1::vector) AS similarity
|
||||
FROM memory_links ml
|
||||
JOIN memory_units mu ON ml.to_unit_id = mu.id
|
||||
FROM {fq_table("memory_links")} ml
|
||||
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
|
||||
WHERE ml.from_unit_id = $2
|
||||
AND ml.link_type IN ('temporal', 'causes', 'caused_by', 'enables', 'prevents')
|
||||
AND ml.weight >= 0.1
|
||||
@@ -359,7 +288,10 @@ async def retrieve_temporal(
|
||||
ORDER BY ml.weight DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
query_emb_str, current.id, fact_type, semantic_threshold
|
||||
query_emb_str,
|
||||
current.id,
|
||||
fact_type,
|
||||
semantic_threshold,
|
||||
)
|
||||
|
||||
for n in neighbors:
|
||||
@@ -383,7 +315,9 @@ async def retrieve_temporal(
|
||||
|
||||
if neighbor_best_date:
|
||||
days_from_mid = abs((neighbor_best_date - mid_date).total_seconds() / 86400)
|
||||
neighbor_temporal_proximity = 1.0 - min(days_from_mid / (total_days / 2), 1.0) if total_days > 0 else 1.0
|
||||
neighbor_temporal_proximity = (
|
||||
1.0 - min(days_from_mid / (total_days / 2), 1.0) if total_days > 0 else 1.0
|
||||
)
|
||||
else:
|
||||
neighbor_temporal_proximity = 0.3 # Lower score if no temporal data
|
||||
|
||||
@@ -425,9 +359,10 @@ async def retrieve_parallel(
|
||||
bank_id: str,
|
||||
fact_type: str,
|
||||
thinking_budget: int,
|
||||
question_date: Optional[datetime] = None,
|
||||
query_analyzer: Optional["QueryAnalyzer"] = None
|
||||
) -> Tuple[List[RetrievalResult], List[RetrievalResult], List[RetrievalResult], Optional[List[RetrievalResult]], Dict[str, float]]:
|
||||
question_date: datetime | None = None,
|
||||
query_analyzer: Optional["QueryAnalyzer"] = None,
|
||||
graph_retriever: GraphRetriever | None = None,
|
||||
) -> ParallelRetrievalResult:
|
||||
"""
|
||||
Run 3-way or 4-way parallel retrieval (adds temporal if detected).
|
||||
|
||||
@@ -435,77 +370,330 @@ async def retrieve_parallel(
|
||||
pool: Database connection pool
|
||||
query_text: Query text
|
||||
query_embedding_str: Query embedding as string
|
||||
agent_id: bank ID
|
||||
bank_id: Bank ID
|
||||
fact_type: Fact type to filter
|
||||
thinking_budget: Budget for graph traversal and retrieval limits
|
||||
question_date: Optional date when question was asked (for temporal filtering)
|
||||
query_analyzer: Query analyzer to use (defaults to TransformerQueryAnalyzer)
|
||||
graph_retriever: Graph retrieval strategy (defaults to configured retriever)
|
||||
|
||||
Returns:
|
||||
Tuple of (semantic_results, bm25_results, graph_results, temporal_results, timings)
|
||||
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
|
||||
ParallelRetrievalResult with semantic, bm25, graph, temporal results and timings
|
||||
"""
|
||||
# Detect temporal constraint
|
||||
from .temporal_extraction import extract_temporal_constraint
|
||||
import logging
|
||||
import time
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
temporal_constraint = extract_temporal_constraint(
|
||||
query_text, reference_date=question_date, analyzer=query_analyzer
|
||||
temporal_constraint = extract_temporal_constraint(query_text, reference_date=question_date, analyzer=query_analyzer)
|
||||
|
||||
retriever = graph_retriever or get_default_graph_retriever()
|
||||
|
||||
if retriever.name == "mpfp":
|
||||
return await _retrieve_parallel_mpfp(
|
||||
pool, query_text, query_embedding_str, bank_id, fact_type, thinking_budget, temporal_constraint, retriever
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
# Wrapper to track timing for each retrieval method
|
||||
async def timed_retrieval(name: str, coro):
|
||||
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:
|
||||
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:
|
||||
return await retrieve_semantic(conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget)
|
||||
results = await retrieve_semantic(conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget)
|
||||
return _TimedResult(results, time.time() - start)
|
||||
|
||||
async def run_bm25():
|
||||
async def run_bm25() -> _TimedResult:
|
||||
start = time.time()
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
return await retrieve_bm25(conn, query_text, bank_id, fact_type, limit=thinking_budget)
|
||||
results = await retrieve_bm25(conn, query_text, bank_id, fact_type, limit=thinking_budget)
|
||||
return _TimedResult(results, time.time() - start)
|
||||
|
||||
async def run_graph():
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
return await retrieve_graph(conn, query_embedding_str, bank_id, fact_type, budget=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(start_date, end_date):
|
||||
async def run_temporal(tc_start, tc_end) -> _TimedResult:
|
||||
start = time.time()
|
||||
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.4
|
||||
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 retrievals in parallel with timing
|
||||
timings = {}
|
||||
if 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))
|
||||
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,
|
||||
)
|
||||
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:
|
||||
results = await asyncio.gather(
|
||||
timed_retrieval("semantic", run_semantic()),
|
||||
timed_retrieval("bm25", run_bm25()),
|
||||
timed_retrieval("graph", run_graph())
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
||||
@@ -4,11 +4,11 @@ Scoring functions for memory search and retrieval.
|
||||
Includes recency weighting, frequency weighting, temporal proximity,
|
||||
and similarity calculations used in memory activation and ranking.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
|
||||
def cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
|
||||
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
|
||||
"""
|
||||
Calculate cosine similarity between two vectors.
|
||||
|
||||
@@ -58,6 +58,7 @@ def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -
|
||||
Weight between 0 and 1
|
||||
"""
|
||||
import math
|
||||
|
||||
# Logarithmic decay: 1 / (1 + log(1 + days_since/half_life))
|
||||
# This decays much slower than exponential, giving better long-term differentiation
|
||||
normalized_age = days_since / half_life_days
|
||||
@@ -79,6 +80,7 @@ def calculate_frequency_weight(access_count: int, max_boost: float = 2.0) -> flo
|
||||
Weight between 1.0 and max_boost
|
||||
"""
|
||||
import math
|
||||
|
||||
if access_count <= 0:
|
||||
return 1.0
|
||||
|
||||
@@ -116,11 +118,7 @@ def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime)
|
||||
return midpoint
|
||||
|
||||
|
||||
def calculate_temporal_proximity(
|
||||
anchor_a: datetime,
|
||||
anchor_b: datetime,
|
||||
half_life_days: float = 30.0
|
||||
) -> float:
|
||||
def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float:
|
||||
"""
|
||||
Calculate temporal proximity between two temporal anchors.
|
||||
|
||||
|
||||
@@ -4,47 +4,47 @@ Temporal extraction for time-aware search queries.
|
||||
Handles natural language temporal expressions using transformer-based query analysis.
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from hindsight_api.engine.query_analyzer import QueryAnalyzer, TransformerQueryAnalyzer
|
||||
from datetime import datetime
|
||||
|
||||
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer, QueryAnalyzer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global default analyzer instance
|
||||
# Can be overridden by passing a custom analyzer to extract_temporal_constraint
|
||||
_default_analyzer: Optional[QueryAnalyzer] = None
|
||||
_default_analyzer: QueryAnalyzer | None = None
|
||||
|
||||
|
||||
def get_default_analyzer() -> QueryAnalyzer:
|
||||
"""
|
||||
Get or create the default query analyzer.
|
||||
|
||||
Uses lazy initialization to avoid loading model at import time.
|
||||
Uses lazy initialization to avoid loading at import time.
|
||||
|
||||
Returns:
|
||||
Default TransformerQueryAnalyzer instance
|
||||
Default DateparserQueryAnalyzer instance
|
||||
"""
|
||||
global _default_analyzer
|
||||
if _default_analyzer is None:
|
||||
_default_analyzer = TransformerQueryAnalyzer()
|
||||
_default_analyzer = DateparserQueryAnalyzer()
|
||||
return _default_analyzer
|
||||
|
||||
|
||||
def extract_temporal_constraint(
|
||||
query: str,
|
||||
reference_date: Optional[datetime] = None,
|
||||
analyzer: Optional[QueryAnalyzer] = None,
|
||||
) -> Optional[Tuple[datetime, datetime]]:
|
||||
reference_date: datetime | None = None,
|
||||
analyzer: QueryAnalyzer | None = None,
|
||||
) -> tuple[datetime, datetime] | None:
|
||||
"""
|
||||
Extract temporal constraint from query using transformer-based analysis.
|
||||
Extract temporal constraint from query.
|
||||
|
||||
Returns (start_date, end_date) tuple if temporal constraint found, else None.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
reference_date: Reference date for relative terms (defaults to now)
|
||||
analyzer: Custom query analyzer (defaults to TransformerQueryAnalyzer)
|
||||
analyzer: Custom query analyzer (defaults to DateparserQueryAnalyzer)
|
||||
|
||||
Returns:
|
||||
(start_date, end_date) tuple or None
|
||||
@@ -55,10 +55,7 @@ def extract_temporal_constraint(
|
||||
analysis = analyzer.analyze(query, reference_date)
|
||||
|
||||
if analysis.temporal_constraint:
|
||||
result = (
|
||||
analysis.temporal_constraint.start_date,
|
||||
analysis.temporal_constraint.end_date
|
||||
)
|
||||
result = (analysis.temporal_constraint.start_date, analysis.temporal_constraint.end_date)
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
@@ -2,59 +2,71 @@
|
||||
Think operation utilities for formulating answers based on agent and world facts.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Any
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..response_models import ReflectResult, MemoryFact, PersonalityTraits
|
||||
from ..response_models import DispositionTraits, MemoryFact
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Opinion(BaseModel):
|
||||
"""An opinion formed by the bank."""
|
||||
|
||||
opinion: str = Field(description="The opinion or perspective with reasoning included")
|
||||
confidence: float = Field(description="Confidence score for this opinion (0.0 to 1.0, where 1.0 is very confident)")
|
||||
|
||||
|
||||
class OpinionExtractionResponse(BaseModel):
|
||||
"""Response containing extracted opinions."""
|
||||
opinions: List[Opinion] = Field(
|
||||
default_factory=list,
|
||||
description="List of opinions formed with their supporting reasons and confidence scores"
|
||||
|
||||
opinions: list[Opinion] = Field(
|
||||
default_factory=list, description="List of opinions formed with their supporting reasons and confidence scores"
|
||||
)
|
||||
|
||||
|
||||
def describe_trait(name: str, value: float) -> str:
|
||||
"""Convert trait value to descriptive text."""
|
||||
if value >= 0.8:
|
||||
return f"very high {name}"
|
||||
elif value >= 0.6:
|
||||
return f"high {name}"
|
||||
elif value >= 0.4:
|
||||
return f"moderate {name}"
|
||||
elif value >= 0.2:
|
||||
return f"low {name}"
|
||||
else:
|
||||
return f"very low {name}"
|
||||
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"}
|
||||
return levels.get(value, "moderate")
|
||||
|
||||
|
||||
def build_personality_description(personality: PersonalityTraits) -> str:
|
||||
"""Build a personality description string from personality traits."""
|
||||
return f"""Your personality traits:
|
||||
- {describe_trait('openness to new ideas', personality.openness)}
|
||||
- {describe_trait('conscientiousness and organization', personality.conscientiousness)}
|
||||
- {describe_trait('extraversion and sociability', personality.extraversion)}
|
||||
- {describe_trait('agreeableness and cooperation', personality.agreeableness)}
|
||||
- {describe_trait('emotional sensitivity', personality.neuroticism)}
|
||||
def build_disposition_description(disposition: DispositionTraits) -> str:
|
||||
"""Build a disposition description string from disposition traits."""
|
||||
skepticism_desc = {
|
||||
1: "You are very trusting and tend to take information at face value.",
|
||||
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.",
|
||||
}
|
||||
|
||||
Personality influence strength: {int(personality.bias_strength * 100)}% (how much your personality shapes your opinions)"""
|
||||
literalism_desc = {
|
||||
1: "You interpret information very flexibly, reading between the lines and inferring intent.",
|
||||
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.",
|
||||
}
|
||||
|
||||
empathy_desc = {
|
||||
1: "You focus primarily on facts and data, setting aside emotional context.",
|
||||
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.",
|
||||
}
|
||||
|
||||
return f"""Your disposition traits:
|
||||
- Skepticism ({describe_trait_level(disposition.skepticism)}): {skepticism_desc.get(disposition.skepticism, skepticism_desc[3])}
|
||||
- Literalism ({describe_trait_level(disposition.literalism)}): {literalism_desc.get(disposition.literalism, literalism_desc[3])}
|
||||
- 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
|
||||
|
||||
@@ -62,9 +74,7 @@ def format_facts_for_prompt(facts: List[MemoryFact]) -> str:
|
||||
return "[]"
|
||||
formatted = []
|
||||
for fact in facts:
|
||||
fact_obj = {
|
||||
"text": fact.text
|
||||
}
|
||||
fact_obj = {"text": fact.text}
|
||||
|
||||
# Add context if available
|
||||
if fact.context:
|
||||
@@ -76,11 +86,7 @@ def format_facts_for_prompt(facts: List[MemoryFact]) -> str:
|
||||
if isinstance(occurred_start, str):
|
||||
fact_obj["occurred_start"] = occurred_start
|
||||
elif isinstance(occurred_start, datetime):
|
||||
fact_obj["occurred_start"] = occurred_start.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# Add activation if available
|
||||
if fact.activation is not None:
|
||||
fact_obj["score"] = fact.activation
|
||||
fact_obj["occurred_start"] = occurred_start.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
formatted.append(fact_obj)
|
||||
|
||||
@@ -93,12 +99,12 @@ def build_think_prompt(
|
||||
opinion_facts_text: str,
|
||||
query: str,
|
||||
name: str,
|
||||
personality: PersonalityTraits,
|
||||
disposition: DispositionTraits,
|
||||
background: str,
|
||||
context: str = None,
|
||||
context: str | None = None,
|
||||
) -> str:
|
||||
"""Build the think prompt for the LLM."""
|
||||
personality_desc = build_personality_description(personality)
|
||||
disposition_desc = build_disposition_description(disposition)
|
||||
|
||||
name_section = f"""
|
||||
|
||||
@@ -132,31 +138,44 @@ WHAT I KNOW ABOUT THE WORLD:
|
||||
MY EXISTING OPINIONS & BELIEFS:
|
||||
{opinion_facts_text}
|
||||
|
||||
{context_section}{name_section}{personality_desc}{background_section}
|
||||
{context_section}{name_section}{disposition_desc}{background_section}
|
||||
|
||||
QUESTION: {query}
|
||||
|
||||
Based on everything I know, believe, and who I am (including my name, personality and background), here's what I genuinely think about this question. I'll draw on my experiences, knowledge, opinions, and personal traits to give you my honest perspective."""
|
||||
Based on everything I know, believe, and who I am (including my name, disposition and background), here's what I genuinely think about this question. I'll draw on my experiences, knowledge, opinions, and personal traits to give you my honest perspective."""
|
||||
|
||||
|
||||
def get_system_message(personality: PersonalityTraits) -> str:
|
||||
def get_system_message(disposition: DispositionTraits) -> str:
|
||||
"""Get the system message for the think LLM call."""
|
||||
bias_strength = personality.bias_strength
|
||||
if bias_strength >= 0.7:
|
||||
personality_instruction = "Your personality strongly influences your thinking. Let your traits guide how you interpret facts and form opinions. Don't be afraid to be biased based on your personality."
|
||||
elif bias_strength >= 0.4:
|
||||
personality_instruction = "Your personality moderately influences your thinking. Balance your personal traits with objective analysis."
|
||||
else:
|
||||
personality_instruction = "Your personality has minimal influence on your thinking. Focus primarily on facts while keeping your traits in mind."
|
||||
# Build disposition-specific instructions based on trait values
|
||||
instructions = []
|
||||
|
||||
return f"You are a person with your own thoughts, experiences, opinions, and personality. When asked a question, respond naturally like a human would - share what you genuinely think based on what you know and have experienced. {personality_instruction} Be direct, express your views confidently, and use 'I think', 'I believe', 'in my view', etc. Respond in plain text without markdown formatting."
|
||||
# Skepticism influences how much to question/doubt information
|
||||
if disposition.skepticism >= 4:
|
||||
instructions.append("Be skeptical of claims and look for potential issues or inconsistencies.")
|
||||
elif disposition.skepticism <= 2:
|
||||
instructions.append("Trust the information provided and take statements at face value.")
|
||||
|
||||
# Literalism influences interpretation style
|
||||
if disposition.literalism >= 4:
|
||||
instructions.append("Interpret information literally and focus on exact commitments and wording.")
|
||||
elif disposition.literalism <= 2:
|
||||
instructions.append("Read between the lines and consider implied meaning and context.")
|
||||
|
||||
# Empathy influences consideration of emotional factors
|
||||
if disposition.empathy >= 4:
|
||||
instructions.append("Consider the emotional state and circumstances behind the information.")
|
||||
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."
|
||||
)
|
||||
|
||||
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.
|
||||
|
||||
@@ -209,11 +228,14 @@ If no genuine opinions are expressed (e.g., the response just says "I don't know
|
||||
try:
|
||||
result = await llm_config.call(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are converting opinions from text into first-person statements. Always use 'I think', 'I believe', 'I feel', etc. NEVER use third-person like 'The speaker' or 'They'."},
|
||||
{"role": "user", "content": extraction_prompt}
|
||||
{
|
||||
"role": "system",
|
||||
"content": "You are converting opinions from text into first-person statements. Always use 'I think', 'I believe', 'I feel', etc. NEVER use third-person like 'The speaker' or 'They'.",
|
||||
},
|
||||
{"role": "user", "content": extraction_prompt},
|
||||
],
|
||||
response_format=OpinionExtractionResponse,
|
||||
scope="memory_extract_opinion"
|
||||
scope="memory_extract_opinion",
|
||||
)
|
||||
|
||||
# Format opinions with confidence score and convert to first-person
|
||||
@@ -224,14 +246,18 @@ If no genuine opinions are expressed (e.g., the response just says "I don't know
|
||||
|
||||
# Replace common third-person patterns with first-person
|
||||
def singularize_verb(verb):
|
||||
if verb.endswith('es'):
|
||||
if verb.endswith("es"):
|
||||
return verb[:-1] # believes -> believe
|
||||
elif verb.endswith('s'):
|
||||
elif verb.endswith("s"):
|
||||
return verb[:-1] # thinks -> think
|
||||
return verb
|
||||
|
||||
# Pattern: "The speaker/user [verb]..." -> "I [verb]..."
|
||||
match = re.match(r'^(The speaker|The user|They|It is believed) (believes?|thinks?|feels?|says|asserts?|considers?)(\s+that)?(.*)$', opinion_text, re.IGNORECASE)
|
||||
match = re.match(
|
||||
r"^(The speaker|The user|They|It is believed) (believes?|thinks?|feels?|says|asserts?|considers?)(\s+that)?(.*)$",
|
||||
opinion_text,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if match:
|
||||
verb = singularize_verb(match.group(2))
|
||||
that_part = match.group(3) or "" # Keep " that" if present
|
||||
@@ -239,17 +265,96 @@ If no genuine opinions are expressed (e.g., the response just says "I don't know
|
||||
opinion_text = f"I {verb}{that_part}{rest}"
|
||||
|
||||
# If still doesn't start with first-person, prepend "I believe that "
|
||||
first_person_starters = ["I think", "I believe", "I feel", "In my view", "I've come to believe", "Previously I"]
|
||||
first_person_starters = [
|
||||
"I think",
|
||||
"I believe",
|
||||
"I feel",
|
||||
"In my view",
|
||||
"I've come to believe",
|
||||
"Previously I",
|
||||
]
|
||||
if not any(opinion_text.startswith(starter) for starter in first_person_starters):
|
||||
opinion_text = "I believe that " + opinion_text[0].lower() + opinion_text[1:]
|
||||
|
||||
formatted_opinions.append(Opinion(
|
||||
opinion=opinion_text,
|
||||
confidence=op.confidence
|
||||
))
|
||||
formatted_opinions.append(Opinion(opinion=opinion_text, confidence=op.confidence))
|
||||
|
||||
return formatted_opinions
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract opinions: {str(e)}")
|
||||
return []
|
||||
|
||||
|
||||
async def reflect(
|
||||
llm_config,
|
||||
query: str,
|
||||
experience_facts: list[str] = None,
|
||||
world_facts: list[str] = None,
|
||||
opinion_facts: list[str] = None,
|
||||
name: str = "Assistant",
|
||||
disposition: DispositionTraits = None,
|
||||
background: str = "",
|
||||
context: str = None,
|
||||
) -> str:
|
||||
"""
|
||||
Standalone reflect function for generating answers based on facts.
|
||||
|
||||
This is a static version of the reflect operation that can be called
|
||||
without a MemoryEngine instance, useful for testing.
|
||||
|
||||
Args:
|
||||
llm_config: LLM provider instance
|
||||
query: Question to answer
|
||||
experience_facts: List of experience/agent fact strings
|
||||
world_facts: List of world fact strings
|
||||
opinion_facts: List of opinion fact strings
|
||||
name: Name of the agent/persona
|
||||
disposition: Disposition traits (defaults to neutral)
|
||||
background: Background information
|
||||
context: Additional context for the prompt
|
||||
|
||||
Returns:
|
||||
Generated answer text
|
||||
"""
|
||||
# Default disposition if not provided
|
||||
if disposition is None:
|
||||
disposition = DispositionTraits(skepticism=3, literalism=3, empathy=3)
|
||||
|
||||
# Convert string lists to MemoryFact format for formatting
|
||||
def to_memory_facts(facts: list[str], fact_type: str) -> list[MemoryFact]:
|
||||
if not facts:
|
||||
return []
|
||||
return [MemoryFact(id=f"test-{i}", text=f, fact_type=fact_type) for i, f in enumerate(facts)]
|
||||
|
||||
agent_results = to_memory_facts(experience_facts or [], "experience")
|
||||
world_results = to_memory_facts(world_facts or [], "world")
|
||||
opinion_results = to_memory_facts(opinion_facts or [], "opinion")
|
||||
|
||||
# Format facts for prompt
|
||||
agent_facts_text = format_facts_for_prompt(agent_results)
|
||||
world_facts_text = format_facts_for_prompt(world_results)
|
||||
opinion_facts_text = format_facts_for_prompt(opinion_results)
|
||||
|
||||
# Build prompt
|
||||
prompt = build_think_prompt(
|
||||
agent_facts_text=agent_facts_text,
|
||||
world_facts_text=world_facts_text,
|
||||
opinion_facts_text=opinion_facts_text,
|
||||
query=query,
|
||||
name=name,
|
||||
disposition=disposition,
|
||||
background=background,
|
||||
context=context,
|
||||
)
|
||||
|
||||
system_message = get_system_message(disposition)
|
||||
|
||||
# Call LLM
|
||||
answer_text = await llm_config.call(
|
||||
messages=[{"role": "system", "content": system_message}, {"role": "user", "content": prompt}],
|
||||
scope="memory_think",
|
||||
temperature=0.9,
|
||||
max_completion_tokens=1000,
|
||||
)
|
||||
|
||||
return answer_text.strip()
|
||||
|
||||
@@ -4,15 +4,18 @@ Search trace models for debugging and visualization.
|
||||
These Pydantic models define the structure of search traces, capturing
|
||||
every step of the spreading activation search process for analysis.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Dict, Any, Literal
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class QueryInfo(BaseModel):
|
||||
"""Information about the search query."""
|
||||
|
||||
query_text: str = Field(description="Original query text")
|
||||
query_embedding: List[float] = Field(description="Generated query embedding vector")
|
||||
query_embedding: list[float] = Field(description="Generated query embedding vector")
|
||||
timestamp: datetime = Field(description="When the query was executed")
|
||||
budget: int = Field(description="Maximum nodes to explore")
|
||||
max_tokens: int = Field(description="Maximum tokens to return in results")
|
||||
@@ -20,6 +23,7 @@ class QueryInfo(BaseModel):
|
||||
|
||||
class EntryPoint(BaseModel):
|
||||
"""An entry point node selected for search."""
|
||||
|
||||
node_id: str = Field(description="Memory unit ID")
|
||||
text: str = Field(description="Memory unit text content")
|
||||
similarity_score: float = Field(description="Cosine similarity to query", ge=0.0, le=1.0)
|
||||
@@ -28,6 +32,7 @@ class EntryPoint(BaseModel):
|
||||
|
||||
class WeightComponents(BaseModel):
|
||||
"""Breakdown of weight calculation components."""
|
||||
|
||||
activation: float = Field(description="Activation from spreading (can exceed 1.0 through accumulation)", ge=0.0)
|
||||
semantic_similarity: float = Field(description="Semantic similarity to query", ge=0.0, le=1.0)
|
||||
recency: float = Field(description="Recency weight", ge=0.0, le=1.0)
|
||||
@@ -43,98 +48,120 @@ class WeightComponents(BaseModel):
|
||||
|
||||
class LinkInfo(BaseModel):
|
||||
"""Information about a link to a neighbor."""
|
||||
|
||||
to_node_id: str = Field(description="Target node ID")
|
||||
link_type: Literal["temporal", "semantic", "entity"] = Field(description="Type of link")
|
||||
link_weight: float = Field(description="Weight of the link (can exceed 1.0 when aggregating multiple connections)", ge=0.0)
|
||||
entity_id: Optional[str] = Field(default=None, description="Entity ID if link_type is 'entity'")
|
||||
new_activation: Optional[float] = Field(default=None, description="Activation that would be passed to neighbor (None for supplementary links)")
|
||||
link_weight: float = Field(
|
||||
description="Weight of the link (can exceed 1.0 when aggregating multiple connections)", ge=0.0
|
||||
)
|
||||
entity_id: str | None = Field(default=None, description="Entity ID if link_type is 'entity'")
|
||||
new_activation: float | None = Field(
|
||||
default=None, description="Activation that would be passed to neighbor (None for supplementary links)"
|
||||
)
|
||||
followed: bool = Field(description="Whether this link was followed (or pruned)")
|
||||
prune_reason: Optional[str] = Field(default=None, description="Why link was not followed (if not followed)")
|
||||
is_supplementary: bool = Field(default=False, description="Whether this is a supplementary link (multiple connections to same node)")
|
||||
prune_reason: str | None = Field(default=None, description="Why link was not followed (if not followed)")
|
||||
is_supplementary: bool = Field(
|
||||
default=False, description="Whether this is a supplementary link (multiple connections to same node)"
|
||||
)
|
||||
|
||||
|
||||
class NodeVisit(BaseModel):
|
||||
"""Information about visiting a node during search."""
|
||||
|
||||
step: int = Field(description="Step number in search (1-based)")
|
||||
node_id: str = Field(description="Memory unit ID")
|
||||
text: str = Field(description="Memory unit text content")
|
||||
context: str = Field(description="Memory unit context")
|
||||
event_date: Optional[datetime] = Field(default=None, description="When the memory occurred")
|
||||
event_date: datetime | None = Field(default=None, description="When the memory occurred")
|
||||
access_count: int = Field(description="Number of times accessed before this search")
|
||||
|
||||
# How this node was reached
|
||||
is_entry_point: bool = Field(description="Whether this is an entry point")
|
||||
parent_node_id: Optional[str] = Field(default=None, description="Node that led to this one")
|
||||
link_type: Optional[Literal["temporal", "semantic", "entity"]] = Field(default=None, description="Type of link from parent")
|
||||
link_weight: Optional[float] = Field(default=None, description="Weight of link from parent")
|
||||
parent_node_id: str | None = Field(default=None, description="Node that led to this one")
|
||||
link_type: Literal["temporal", "semantic", "entity"] | None = Field(
|
||||
default=None, description="Type of link from parent"
|
||||
)
|
||||
link_weight: float | None = Field(default=None, description="Weight of link from parent")
|
||||
|
||||
# Weights
|
||||
weights: WeightComponents = Field(description="Weight calculation breakdown")
|
||||
|
||||
# Neighbors discovered from this node
|
||||
neighbors_explored: List[LinkInfo] = Field(default_factory=list, description="Links explored from this node")
|
||||
neighbors_explored: list[LinkInfo] = Field(default_factory=list, description="Links explored from this node")
|
||||
|
||||
# Ranking
|
||||
final_rank: Optional[int] = Field(default=None, description="Final rank in results (1-based, None if not in top-k)")
|
||||
final_rank: int | None = Field(default=None, description="Final rank in results (1-based, None if not in top-k)")
|
||||
|
||||
|
||||
class PruningDecision(BaseModel):
|
||||
"""Records when a node was considered but not visited."""
|
||||
|
||||
node_id: str = Field(description="Node that was pruned")
|
||||
reason: Literal["already_visited", "activation_too_low", "budget_exhausted"] = Field(description="Why it was pruned")
|
||||
reason: Literal["already_visited", "activation_too_low", "budget_exhausted"] = Field(
|
||||
description="Why it was pruned"
|
||||
)
|
||||
activation: float = Field(description="Activation value when pruned")
|
||||
would_have_been_step: int = Field(description="What step it would have been if visited")
|
||||
|
||||
|
||||
class SearchPhaseMetrics(BaseModel):
|
||||
"""Performance metrics for a search phase."""
|
||||
|
||||
phase_name: str = Field(description="Name of the phase")
|
||||
duration_seconds: float = Field(description="Time taken in seconds")
|
||||
details: Dict[str, Any] = Field(default_factory=dict, description="Additional phase-specific metrics")
|
||||
details: dict[str, Any] = Field(default_factory=dict, description="Additional phase-specific metrics")
|
||||
|
||||
|
||||
class RetrievalResult(BaseModel):
|
||||
"""A single result from a retrieval method."""
|
||||
|
||||
rank: int = Field(description="Rank in this retrieval method (1-based)")
|
||||
node_id: str = Field(description="Memory unit ID")
|
||||
text: str = Field(description="Memory unit text content")
|
||||
context: str = Field(default="", description="Memory unit context")
|
||||
event_date: Optional[datetime] = Field(default=None, description="When the memory occurred")
|
||||
fact_type: Optional[str] = Field(default=None, description="Fact type (world, bank, opinion)")
|
||||
event_date: datetime | None = Field(default=None, description="When the memory occurred")
|
||||
fact_type: str | None = Field(default=None, description="Fact type (world, experience, opinion)")
|
||||
score: float = Field(description="Score from this retrieval method")
|
||||
score_name: str = Field(description="Name of the score (e.g., 'similarity', 'bm25_score', 'activation')")
|
||||
|
||||
|
||||
class RetrievalMethodResults(BaseModel):
|
||||
"""Results from a single retrieval method."""
|
||||
|
||||
method_name: Literal["semantic", "bm25", "graph", "temporal"] = Field(description="Name of retrieval method")
|
||||
results: List[RetrievalResult] = Field(description="Retrieved results with ranks")
|
||||
fact_type: str | None = Field(
|
||||
default=None, description="Fact type this retrieval was for (world, experience, opinion)"
|
||||
)
|
||||
results: list[RetrievalResult] = Field(description="Retrieved results with ranks")
|
||||
duration_seconds: float = Field(description="Time taken for this retrieval")
|
||||
metadata: Dict[str, Any] = Field(default_factory=dict, description="Method-specific metadata")
|
||||
metadata: dict[str, Any] = Field(default_factory=dict, description="Method-specific metadata")
|
||||
|
||||
|
||||
class RRFMergeResult(BaseModel):
|
||||
"""A result after RRF merging."""
|
||||
|
||||
node_id: str = Field(description="Memory unit ID")
|
||||
text: str = Field(description="Memory unit text content")
|
||||
rrf_score: float = Field(description="Reciprocal Rank Fusion score")
|
||||
source_ranks: Dict[str, int] = Field(description="Rank in each source that contributed (method_name -> rank)")
|
||||
source_ranks: dict[str, int] = Field(description="Rank in each source that contributed (method_name -> rank)")
|
||||
final_rrf_rank: int = Field(description="Rank after RRF merge (1-based)")
|
||||
|
||||
|
||||
class RerankedResult(BaseModel):
|
||||
"""A result after reranking."""
|
||||
|
||||
node_id: str = Field(description="Memory unit ID")
|
||||
text: str = Field(description="Memory unit text content")
|
||||
rerank_score: float = Field(description="Final reranking score")
|
||||
rerank_rank: int = Field(description="Rank after reranking (1-based)")
|
||||
rrf_rank: int = Field(description="Original RRF rank before reranking")
|
||||
rank_change: int = Field(description="Change in rank (positive = moved up)")
|
||||
score_components: Dict[str, float] = Field(default_factory=dict, description="Score breakdown")
|
||||
score_components: dict[str, float] = Field(default_factory=dict, description="Score breakdown")
|
||||
|
||||
|
||||
class SearchSummary(BaseModel):
|
||||
"""Summary statistics about the search."""
|
||||
|
||||
total_nodes_visited: int = Field(description="Total nodes visited")
|
||||
total_nodes_pruned: int = Field(description="Total nodes pruned")
|
||||
entry_points_found: int = Field(description="Number of entry points")
|
||||
@@ -149,33 +176,36 @@ class SearchSummary(BaseModel):
|
||||
entity_links_followed: int = Field(default=0, description="Entity links followed")
|
||||
|
||||
# Phase timings
|
||||
phase_metrics: List[SearchPhaseMetrics] = Field(default_factory=list, description="Metrics for each phase")
|
||||
phase_metrics: list[SearchPhaseMetrics] = Field(default_factory=list, description="Metrics for each phase")
|
||||
|
||||
|
||||
class SearchTrace(BaseModel):
|
||||
"""Complete trace of a search operation."""
|
||||
|
||||
query: QueryInfo = Field(description="Query information")
|
||||
|
||||
# New 4-way retrieval architecture
|
||||
retrieval_results: List[RetrievalMethodResults] = Field(default_factory=list, description="Results from each retrieval method")
|
||||
rrf_merged: List[RRFMergeResult] = Field(default_factory=list, description="Results after RRF merging")
|
||||
reranked: List[RerankedResult] = Field(default_factory=list, description="Results after reranking")
|
||||
retrieval_results: list[RetrievalMethodResults] = Field(
|
||||
default_factory=list, description="Results from each retrieval method"
|
||||
)
|
||||
rrf_merged: list[RRFMergeResult] = Field(default_factory=list, description="Results after RRF merging")
|
||||
reranked: list[RerankedResult] = Field(default_factory=list, description="Results after reranking")
|
||||
|
||||
# Legacy fields (kept for backward compatibility with graph/temporal visualizations)
|
||||
entry_points: List[EntryPoint] = Field(default_factory=list, description="Entry points selected for search (legacy)")
|
||||
visits: List[NodeVisit] = Field(default_factory=list, description="All nodes visited during search (legacy, for graph viz)")
|
||||
pruned: List[PruningDecision] = Field(default_factory=list, description="Nodes that were pruned (legacy)")
|
||||
entry_points: list[EntryPoint] = Field(
|
||||
default_factory=list, description="Entry points selected for search (legacy)"
|
||||
)
|
||||
visits: list[NodeVisit] = Field(
|
||||
default_factory=list, description="All nodes visited during search (legacy, for graph viz)"
|
||||
)
|
||||
pruned: list[PruningDecision] = Field(default_factory=list, description="Nodes that were pruned (legacy)")
|
||||
|
||||
summary: SearchSummary = Field(description="Summary statistics")
|
||||
|
||||
# Final results (for comparison with visits)
|
||||
final_results: List[Dict[str, Any]] = Field(description="Final ranked results returned to user")
|
||||
final_results: list[dict[str, Any]] = Field(description="Final ranked results returned to user")
|
||||
|
||||
model_config = {
|
||||
"json_encoders": {
|
||||
datetime: lambda v: v.isoformat()
|
||||
}
|
||||
}
|
||||
model_config = {"json_encoders": {datetime: lambda v: v.isoformat()}}
|
||||
|
||||
def to_json(self, **kwargs) -> str:
|
||||
"""Export trace as JSON string."""
|
||||
@@ -185,14 +215,14 @@ class SearchTrace(BaseModel):
|
||||
"""Export trace as dictionary."""
|
||||
return self.model_dump()
|
||||
|
||||
def get_visit_by_node_id(self, node_id: str) -> Optional[NodeVisit]:
|
||||
def get_visit_by_node_id(self, node_id: str) -> NodeVisit | None:
|
||||
"""Find a visit by node ID."""
|
||||
for visit in self.visits:
|
||||
if visit.node_id == node_id:
|
||||
return visit
|
||||
return None
|
||||
|
||||
def get_search_path_to_node(self, node_id: str) -> List[NodeVisit]:
|
||||
def get_search_path_to_node(self, node_id: str) -> list[NodeVisit]:
|
||||
"""Get the path from entry point to a specific node."""
|
||||
path = []
|
||||
current_visit = self.get_visit_by_node_id(node_id)
|
||||
@@ -206,10 +236,10 @@ class SearchTrace(BaseModel):
|
||||
|
||||
return path
|
||||
|
||||
def get_nodes_by_link_type(self, link_type: Literal["temporal", "semantic", "entity"]) -> List[NodeVisit]:
|
||||
def get_nodes_by_link_type(self, link_type: Literal["temporal", "semantic", "entity"]) -> list[NodeVisit]:
|
||||
"""Get all nodes reached via a specific link type."""
|
||||
return [v for v in self.visits if v.link_type == link_type]
|
||||
|
||||
def get_entry_point_nodes(self) -> List[NodeVisit]:
|
||||
def get_entry_point_nodes(self) -> list[NodeVisit]:
|
||||
"""Get all entry point visits."""
|
||||
return [v for v in self.visits if v.is_entry_point]
|
||||
|
||||
@@ -4,24 +4,25 @@ Search tracer for collecting detailed search execution traces.
|
||||
The SearchTracer collects comprehensive information about each step
|
||||
of the spreading activation search process for debugging and visualization.
|
||||
"""
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Dict, Any, Literal
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from .trace import (
|
||||
SearchTrace,
|
||||
QueryInfo,
|
||||
EntryPoint,
|
||||
NodeVisit,
|
||||
WeightComponents,
|
||||
LinkInfo,
|
||||
NodeVisit,
|
||||
PruningDecision,
|
||||
SearchSummary,
|
||||
SearchPhaseMetrics,
|
||||
RetrievalResult,
|
||||
RetrievalMethodResults,
|
||||
RRFMergeResult,
|
||||
QueryInfo,
|
||||
RerankedResult,
|
||||
RetrievalMethodResults,
|
||||
RetrievalResult,
|
||||
RRFMergeResult,
|
||||
SearchPhaseMetrics,
|
||||
SearchSummary,
|
||||
SearchTrace,
|
||||
WeightComponents,
|
||||
)
|
||||
|
||||
|
||||
@@ -58,17 +59,17 @@ class SearchTracer:
|
||||
self.max_tokens = max_tokens
|
||||
|
||||
# Trace data
|
||||
self.query_embedding: Optional[List[float]] = None
|
||||
self.start_time: Optional[float] = None
|
||||
self.entry_points: List[EntryPoint] = []
|
||||
self.visits: List[NodeVisit] = []
|
||||
self.pruned: List[PruningDecision] = []
|
||||
self.phase_metrics: List[SearchPhaseMetrics] = []
|
||||
self.query_embedding: list[float] | None = None
|
||||
self.start_time: float | None = None
|
||||
self.entry_points: list[EntryPoint] = []
|
||||
self.visits: list[NodeVisit] = []
|
||||
self.pruned: list[PruningDecision] = []
|
||||
self.phase_metrics: list[SearchPhaseMetrics] = []
|
||||
|
||||
# New 4-way retrieval tracking
|
||||
self.retrieval_results: List[RetrievalMethodResults] = []
|
||||
self.rrf_merged: List[RRFMergeResult] = []
|
||||
self.reranked: List[RerankedResult] = []
|
||||
self.retrieval_results: list[RetrievalMethodResults] = []
|
||||
self.rrf_merged: list[RRFMergeResult] = []
|
||||
self.reranked: list[RerankedResult] = []
|
||||
|
||||
# Tracking state
|
||||
self.current_step = 0
|
||||
@@ -83,7 +84,7 @@ class SearchTracer:
|
||||
"""Start timing the search."""
|
||||
self.start_time = time.time()
|
||||
|
||||
def record_query_embedding(self, embedding: List[float]):
|
||||
def record_query_embedding(self, embedding: list[float]):
|
||||
"""Record the query embedding."""
|
||||
self.query_embedding = embedding
|
||||
|
||||
@@ -114,12 +115,12 @@ class SearchTracer:
|
||||
node_id: str,
|
||||
text: str,
|
||||
context: str,
|
||||
event_date: datetime,
|
||||
event_date: datetime | None,
|
||||
access_count: int,
|
||||
is_entry_point: bool,
|
||||
parent_node_id: Optional[str],
|
||||
link_type: Optional[Literal["temporal", "semantic", "entity"]],
|
||||
link_weight: Optional[float],
|
||||
parent_node_id: str | None,
|
||||
link_type: Literal["temporal", "semantic", "entity"] | None,
|
||||
link_weight: float | None,
|
||||
activation: float,
|
||||
semantic_similarity: float,
|
||||
recency: float,
|
||||
@@ -199,10 +200,10 @@ class SearchTracer:
|
||||
to_node_id: str,
|
||||
link_type: Literal["temporal", "semantic", "entity"],
|
||||
link_weight: float,
|
||||
entity_id: Optional[str],
|
||||
new_activation: Optional[float],
|
||||
entity_id: str | None,
|
||||
new_activation: float | None,
|
||||
followed: bool,
|
||||
prune_reason: Optional[str] = None,
|
||||
prune_reason: str | None = None,
|
||||
is_supplementary: bool = False,
|
||||
):
|
||||
"""
|
||||
@@ -266,7 +267,7 @@ class SearchTracer:
|
||||
)
|
||||
)
|
||||
|
||||
def add_phase_metric(self, phase_name: str, duration_seconds: float, details: Optional[Dict[str, Any]] = None):
|
||||
def add_phase_metric(self, phase_name: str, duration_seconds: float, details: dict[str, Any] | None = None):
|
||||
"""
|
||||
Record metrics for a search phase.
|
||||
|
||||
@@ -286,10 +287,11 @@ class SearchTracer:
|
||||
def add_retrieval_results(
|
||||
self,
|
||||
method_name: Literal["semantic", "bm25", "graph", "temporal"],
|
||||
results: List[tuple], # List of (doc_id, data) tuples
|
||||
results: list[tuple], # List of (doc_id, data) tuples
|
||||
duration_seconds: float,
|
||||
score_field: str, # e.g., "similarity", "bm25_score"
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
metadata: dict[str, Any] | None = None,
|
||||
fact_type: str | None = None,
|
||||
):
|
||||
"""
|
||||
Record results from a single retrieval method.
|
||||
@@ -300,6 +302,7 @@ 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):
|
||||
@@ -313,7 +316,7 @@ class SearchTracer:
|
||||
text=data.get("text", ""),
|
||||
context=data.get("context", ""),
|
||||
event_date=data.get("event_date"),
|
||||
fact_type=data.get("fact_type"),
|
||||
fact_type=data.get("fact_type") or fact_type,
|
||||
score=score,
|
||||
score_name=score_field,
|
||||
)
|
||||
@@ -322,13 +325,14 @@ 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.
|
||||
|
||||
@@ -347,7 +351,7 @@ class SearchTracer:
|
||||
)
|
||||
)
|
||||
|
||||
def add_reranked(self, reranked_results: List[Dict[str, Any]], rrf_merged: List):
|
||||
def add_reranked(self, reranked_results: list[dict[str, Any]], rrf_merged: list):
|
||||
"""
|
||||
Record reranked results.
|
||||
|
||||
@@ -367,8 +371,18 @@ 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 ["semantic_similarity", "bm25_score", "rrf_score", "recency_normalized", "frequency_normalized"]:
|
||||
for key in [
|
||||
"cross_encoder_score",
|
||||
"cross_encoder_score_normalized",
|
||||
"rrf_score",
|
||||
"rrf_normalized",
|
||||
"temporal",
|
||||
"recency",
|
||||
"combined_score",
|
||||
]:
|
||||
if key in result and result[key] is not None:
|
||||
score_components[key] = result[key]
|
||||
|
||||
@@ -384,7 +398,7 @@ class SearchTracer:
|
||||
)
|
||||
)
|
||||
|
||||
def finalize(self, final_results: List[Dict[str, Any]]) -> SearchTrace:
|
||||
def finalize(self, final_results: list[dict[str, Any]]) -> SearchTrace:
|
||||
"""
|
||||
Finalize the trace and return the complete SearchTrace object.
|
||||
|
||||
@@ -411,7 +425,7 @@ class SearchTracer:
|
||||
query_info = QueryInfo(
|
||||
query_text=self.query_text,
|
||||
query_embedding=self.query_embedding or [],
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
timestamp=datetime.now(UTC),
|
||||
budget=self.budget,
|
||||
max_tokens=self.max_tokens,
|
||||
)
|
||||
|
||||
@@ -6,8 +6,8 @@ providing type safety and making data flow explicit.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -17,27 +17,29 @@ class RetrievalResult:
|
||||
|
||||
This represents a raw result from the database query, before merging or reranking.
|
||||
"""
|
||||
|
||||
id: str
|
||||
text: str
|
||||
fact_type: str
|
||||
context: Optional[str] = None
|
||||
event_date: Optional[datetime] = None
|
||||
occurred_start: Optional[datetime] = None
|
||||
occurred_end: Optional[datetime] = None
|
||||
mentioned_at: Optional[datetime] = None
|
||||
document_id: Optional[str] = None
|
||||
chunk_id: Optional[str] = None
|
||||
context: str | None = None
|
||||
event_date: datetime | None = None
|
||||
occurred_start: datetime | None = None
|
||||
occurred_end: datetime | None = None
|
||||
mentioned_at: datetime | None = None
|
||||
document_id: str | None = None
|
||||
chunk_id: str | None = None
|
||||
access_count: int = 0
|
||||
embedding: Optional[List[float]] = None
|
||||
embedding: list[float] | None = None
|
||||
|
||||
# Retrieval-specific scores (only one will be set depending on retrieval method)
|
||||
similarity: Optional[float] = None # Semantic/graph retrieval
|
||||
bm25_score: Optional[float] = None # BM25 retrieval
|
||||
temporal_score: Optional[float] = None # Temporal retrieval
|
||||
temporal_proximity: Optional[float] = None # Temporal retrieval
|
||||
similarity: float | None = None # Semantic retrieval
|
||||
bm25_score: float | None = None # BM25 retrieval
|
||||
activation: float | None = None # Graph retrieval (spreading activation)
|
||||
temporal_score: float | None = None # Temporal retrieval
|
||||
temporal_proximity: float | None = None # Temporal retrieval
|
||||
|
||||
@classmethod
|
||||
def from_db_row(cls, row: Dict[str, Any]) -> "RetrievalResult":
|
||||
def from_db_row(cls, row: dict[str, Any]) -> "RetrievalResult":
|
||||
"""Create from a database row (asyncpg Record converted to dict)."""
|
||||
return cls(
|
||||
id=str(row["id"]),
|
||||
@@ -54,6 +56,7 @@ 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"),
|
||||
)
|
||||
@@ -66,13 +69,14 @@ class MergedCandidate:
|
||||
|
||||
Contains the original retrieval data plus RRF metadata.
|
||||
"""
|
||||
|
||||
# Original retrieval data
|
||||
retrieval: RetrievalResult
|
||||
|
||||
# RRF metadata
|
||||
rrf_score: float
|
||||
rrf_rank: int = 0
|
||||
source_ranks: Dict[str, int] = field(default_factory=dict) # method_name -> rank
|
||||
source_ranks: dict[str, int] = field(default_factory=dict) # method_name -> rank
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
@@ -87,6 +91,7 @@ class ScoredResult:
|
||||
|
||||
Contains all retrieval/merge data plus reranking scores and combined score.
|
||||
"""
|
||||
|
||||
# Original merged candidate
|
||||
candidate: MergedCandidate
|
||||
|
||||
@@ -113,7 +118,7 @@ class ScoredResult:
|
||||
"""Convenience property to access retrieval data."""
|
||||
return self.candidate.retrieval
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Convert to dict for backwards compatibility.
|
||||
|
||||
@@ -152,6 +157,7 @@ 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,10 +6,12 @@ This provides an abstraction that can be adapted to different execution models:
|
||||
- Pub/Sub architectures (future)
|
||||
- Message brokers (future)
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, Optional, Callable, Awaitable
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,10 +31,10 @@ class TaskBackend(ABC):
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the task backend."""
|
||||
self._executor: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None
|
||||
self._executor: Callable[[dict[str, Any]], Awaitable[None]] | None = None
|
||||
self._initialized = False
|
||||
|
||||
def set_executor(self, executor: Callable[[Dict[str, Any]], Awaitable[None]]):
|
||||
def set_executor(self, executor: Callable[[dict[str, Any]], Awaitable[None]]):
|
||||
"""
|
||||
Set the executor callback for processing tasks.
|
||||
|
||||
@@ -49,7 +51,7 @@ class TaskBackend(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def submit_task(self, task_dict: Dict[str, Any]):
|
||||
async def submit_task(self, task_dict: dict[str, Any]):
|
||||
"""
|
||||
Submit a task for execution.
|
||||
|
||||
@@ -65,7 +67,7 @@ class TaskBackend(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
async def _execute_task(self, task_dict: Dict[str, Any]):
|
||||
async def _execute_task(self, task_dict: dict[str, Any]):
|
||||
"""
|
||||
Execute a task through the registered executor.
|
||||
|
||||
@@ -73,19 +75,52 @@ class TaskBackend(ABC):
|
||||
task_dict: Task dictionary to execute
|
||||
"""
|
||||
if self._executor is None:
|
||||
task_type = task_dict.get('type', 'unknown')
|
||||
task_type = task_dict.get("type", "unknown")
|
||||
logger.warning(f"No executor registered, skipping task {task_type}")
|
||||
return
|
||||
|
||||
try:
|
||||
await self._executor(task_dict)
|
||||
except Exception as e:
|
||||
task_type = task_dict.get('type', 'unknown')
|
||||
task_type = task_dict.get("type", "unknown")
|
||||
logger.error(f"Error executing task {task_type}: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
class SyncTaskBackend(TaskBackend):
|
||||
"""
|
||||
Synchronous task backend that executes tasks immediately.
|
||||
|
||||
This is useful for embedded/CLI usage where we don't want background
|
||||
workers that prevent clean exit. Tasks are executed inline rather than
|
||||
being queued.
|
||||
"""
|
||||
|
||||
async def initialize(self):
|
||||
"""No-op for sync backend."""
|
||||
self._initialized = True
|
||||
logger.debug("SyncTaskBackend initialized")
|
||||
|
||||
async def submit_task(self, task_dict: dict[str, Any]):
|
||||
"""
|
||||
Execute the task immediately (synchronously).
|
||||
|
||||
Args:
|
||||
task_dict: Task dictionary to execute
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
await self._execute_task(task_dict)
|
||||
|
||||
async def shutdown(self):
|
||||
"""No-op for sync backend."""
|
||||
self._initialized = False
|
||||
logger.debug("SyncTaskBackend shutdown")
|
||||
|
||||
|
||||
class AsyncIOQueueBackend(TaskBackend):
|
||||
"""
|
||||
Task backend implementation using asyncio queues.
|
||||
@@ -94,11 +129,7 @@ class AsyncIOQueueBackend(TaskBackend):
|
||||
and a periodic consumer worker.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
batch_size: int = 100,
|
||||
batch_interval: float = 1.0
|
||||
):
|
||||
def __init__(self, batch_size: int = 100, batch_interval: float = 1.0):
|
||||
"""
|
||||
Initialize AsyncIO queue backend.
|
||||
|
||||
@@ -107,9 +138,9 @@ class AsyncIOQueueBackend(TaskBackend):
|
||||
batch_interval: Maximum time (seconds) to wait before processing batch
|
||||
"""
|
||||
super().__init__()
|
||||
self._queue: Optional[asyncio.Queue] = None
|
||||
self._worker_task: Optional[asyncio.Task] = None
|
||||
self._shutdown_event: Optional[asyncio.Event] = None
|
||||
self._queue: asyncio.Queue | None = None
|
||||
self._worker_task: asyncio.Task | None = None
|
||||
self._shutdown_event: asyncio.Event | None = None
|
||||
self._batch_size = batch_size
|
||||
self._batch_interval = batch_interval
|
||||
|
||||
@@ -124,7 +155,7 @@ class AsyncIOQueueBackend(TaskBackend):
|
||||
self._initialized = True
|
||||
logger.info("AsyncIOQueueBackend initialized")
|
||||
|
||||
async def submit_task(self, task_dict: Dict[str, Any]):
|
||||
async def submit_task(self, task_dict: dict[str, Any]):
|
||||
"""
|
||||
Submit a task by putting it in the queue.
|
||||
|
||||
@@ -135,8 +166,8 @@ class AsyncIOQueueBackend(TaskBackend):
|
||||
await self.initialize()
|
||||
|
||||
await self._queue.put(task_dict)
|
||||
task_type = task_dict.get('type', 'unknown')
|
||||
task_id = task_dict.get('id')
|
||||
task_type = task_dict.get("type", "unknown")
|
||||
task_id = task_dict.get("id")
|
||||
|
||||
async def wait_for_pending_tasks(self, timeout: float = 5.0):
|
||||
"""
|
||||
@@ -200,20 +231,16 @@ class AsyncIOQueueBackend(TaskBackend):
|
||||
while len(tasks) < self._batch_size and asyncio.get_event_loop().time() < deadline:
|
||||
try:
|
||||
remaining_time = max(0.1, deadline - asyncio.get_event_loop().time())
|
||||
task_dict = await asyncio.wait_for(
|
||||
self._queue.get(),
|
||||
timeout=remaining_time
|
||||
)
|
||||
task_dict = await asyncio.wait_for(self._queue.get(), timeout=remaining_time)
|
||||
tasks.append(task_dict)
|
||||
except asyncio.TimeoutError:
|
||||
except TimeoutError:
|
||||
break
|
||||
|
||||
# Process batch
|
||||
if tasks:
|
||||
# Execute tasks concurrently
|
||||
await asyncio.gather(
|
||||
*[self._execute_task(task_dict) for task_dict in tasks],
|
||||
return_exceptions=True
|
||||
*[self._execute_task(task_dict) for task_dict in tasks], return_exceptions=True
|
||||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""
|
||||
Utility functions for memory system.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .llm_wrapper import LLMConfig
|
||||
@@ -12,7 +13,14 @@ if TYPE_CHECKING:
|
||||
from .retain.fact_extraction import extract_facts_from_text
|
||||
|
||||
|
||||
async def extract_facts(text: str, event_date: datetime, context: str = "", llm_config: 'LLMConfig' = None, agent_name: str = None, extract_opinions: bool = False) -> tuple[List['Fact'], List[tuple[str, int]]]:
|
||||
async def extract_facts(
|
||||
text: str,
|
||||
event_date: datetime,
|
||||
context: str = "",
|
||||
llm_config: "LLMConfig" = None,
|
||||
agent_name: str = None,
|
||||
extract_opinions: bool = False,
|
||||
) -> tuple[list["Fact"], list[tuple[str, int]]]:
|
||||
"""
|
||||
Extract semantic facts from text using LLM.
|
||||
|
||||
@@ -41,16 +49,25 @@ async def extract_facts(text: str, event_date: datetime, context: str = "", llm_
|
||||
if not text or not text.strip():
|
||||
return [], []
|
||||
|
||||
facts, chunks = await extract_facts_from_text(text, event_date, context=context, llm_config=llm_config, agent_name=agent_name, extract_opinions=extract_opinions)
|
||||
facts, chunks = await extract_facts_from_text(
|
||||
text,
|
||||
event_date,
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name=agent_name,
|
||||
extract_opinions=extract_opinions,
|
||||
)
|
||||
|
||||
if not facts:
|
||||
logging.warning(f"LLM extracted 0 facts from text of length {len(text)}. This may indicate the text contains no meaningful information, or the LLM failed to extract facts. Full text: {text}")
|
||||
logging.warning(
|
||||
f"LLM extracted 0 facts from text of length {len(text)}. This may indicate the text contains no meaningful information, or the LLM failed to extract facts. Full text: {text}"
|
||||
)
|
||||
return [], chunks
|
||||
|
||||
return facts, chunks
|
||||
|
||||
|
||||
def cosine_similarity(vec1: List[float], vec2: List[float]) -> float:
|
||||
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
|
||||
"""
|
||||
Calculate cosine similarity between two vectors.
|
||||
|
||||
@@ -100,6 +117,7 @@ def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -
|
||||
Weight between 0 and 1
|
||||
"""
|
||||
import math
|
||||
|
||||
# Logarithmic decay: 1 / (1 + log(1 + days_since/half_life))
|
||||
# This decays much slower than exponential, giving better long-term differentiation
|
||||
normalized_age = days_since / half_life_days
|
||||
@@ -121,6 +139,7 @@ def calculate_frequency_weight(access_count: int, max_boost: float = 2.0) -> flo
|
||||
Weight between 1.0 and max_boost
|
||||
"""
|
||||
import math
|
||||
|
||||
if access_count <= 0:
|
||||
return 1.0
|
||||
|
||||
@@ -158,11 +177,7 @@ def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime)
|
||||
return midpoint
|
||||
|
||||
|
||||
def calculate_temporal_proximity(
|
||||
anchor_a: datetime,
|
||||
anchor_b: datetime,
|
||||
half_life_days: float = 30.0
|
||||
) -> float:
|
||||
def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float:
|
||||
"""
|
||||
Calculate temporal proximity between two temporal anchors.
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Hindsight Extensions System.
|
||||
|
||||
Extensions allow customizing and extending Hindsight behavior without modifying core code.
|
||||
Extensions are loaded via environment variables pointing to implementation classes.
|
||||
|
||||
Example:
|
||||
HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator
|
||||
HINDSIGHT_API_OPERATION_VALIDATOR_MAX_RETRIES=3
|
||||
|
||||
HINDSIGHT_API_HTTP_EXTENSION=mypackage.http:MyHttpExtension
|
||||
HINDSIGHT_API_HTTP_SOME_CONFIG=value
|
||||
|
||||
Extensions receive an ExtensionContext that provides a controlled API for interacting
|
||||
with the system (e.g., running migrations for tenant schemas).
|
||||
"""
|
||||
|
||||
from hindsight_api.extensions.base import Extension
|
||||
from hindsight_api.extensions.builtin import ApiKeyTenantExtension
|
||||
from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionContext
|
||||
from hindsight_api.extensions.http import HttpExtension
|
||||
from hindsight_api.extensions.loader import load_extension
|
||||
from hindsight_api.extensions.operation_validator import (
|
||||
OperationValidationError,
|
||||
OperationValidatorExtension,
|
||||
RecallContext,
|
||||
RecallResult,
|
||||
ReflectContext,
|
||||
ReflectResultContext,
|
||||
RetainContext,
|
||||
RetainResult,
|
||||
ValidationResult,
|
||||
)
|
||||
from hindsight_api.extensions.tenant import (
|
||||
AuthenticationError,
|
||||
TenantContext,
|
||||
TenantExtension,
|
||||
)
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
__all__ = [
|
||||
# Base
|
||||
"Extension",
|
||||
"load_extension",
|
||||
# Context
|
||||
"ExtensionContext",
|
||||
"DefaultExtensionContext",
|
||||
# HTTP Extension
|
||||
"HttpExtension",
|
||||
# Operation Validator
|
||||
"OperationValidationError",
|
||||
"OperationValidatorExtension",
|
||||
"RecallContext",
|
||||
"RecallResult",
|
||||
"ReflectContext",
|
||||
"ReflectResultContext",
|
||||
"RetainContext",
|
||||
"RetainResult",
|
||||
"ValidationResult",
|
||||
# Tenant/Auth
|
||||
"ApiKeyTenantExtension",
|
||||
"AuthenticationError",
|
||||
"RequestContext",
|
||||
"TenantContext",
|
||||
"TenantExtension",
|
||||
]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Base Extension class for all Hindsight extensions."""
|
||||
|
||||
from abc import ABC
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from hindsight_api.extensions.context import ExtensionContext
|
||||
|
||||
|
||||
class Extension(ABC):
|
||||
"""
|
||||
Base class for all Hindsight extensions.
|
||||
|
||||
Extensions are loaded via environment variables and receive configuration
|
||||
from prefixed environment variables.
|
||||
|
||||
Example:
|
||||
HINDSIGHT_API_MY_EXTENSION=mypackage.ext:MyExtension
|
||||
HINDSIGHT_API_MY_SOME_CONFIG=value
|
||||
|
||||
The extension receives: {"some_config": "value"}
|
||||
|
||||
Extensions also receive an ExtensionContext that provides a controlled API
|
||||
for interacting with the system (e.g., running migrations for tenant schemas).
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict[str, str]):
|
||||
"""
|
||||
Initialize the extension with configuration.
|
||||
|
||||
Args:
|
||||
config: Dictionary of configuration values from environment variables.
|
||||
Keys are lowercased with the prefix stripped.
|
||||
"""
|
||||
self.config = config
|
||||
self._context: "ExtensionContext | None" = None
|
||||
|
||||
def set_context(self, context: "ExtensionContext") -> None:
|
||||
"""
|
||||
Set the extension context.
|
||||
|
||||
Called by the extension loader after instantiation.
|
||||
Extensions should not call this directly.
|
||||
|
||||
Args:
|
||||
context: The ExtensionContext providing system APIs.
|
||||
"""
|
||||
self._context = context
|
||||
|
||||
@property
|
||||
def context(self) -> "ExtensionContext":
|
||||
"""
|
||||
Get the extension context.
|
||||
|
||||
Returns:
|
||||
The ExtensionContext providing system APIs.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If context has not been set yet.
|
||||
"""
|
||||
if self._context is None:
|
||||
raise RuntimeError(
|
||||
"Extension context not set. Context is available after the extension is loaded by the system."
|
||||
)
|
||||
return self._context
|
||||
|
||||
async def on_startup(self) -> None:
|
||||
"""
|
||||
Called when the application starts.
|
||||
|
||||
Override to perform initialization tasks like connecting to external services.
|
||||
"""
|
||||
pass
|
||||
|
||||
async def on_shutdown(self) -> None:
|
||||
"""
|
||||
Called when the application shuts down.
|
||||
|
||||
Override to perform cleanup tasks like closing connections.
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Built-in extension implementations.
|
||||
|
||||
These are ready-to-use implementations of the extension interfaces.
|
||||
They can be used directly or serve as examples for custom implementations.
|
||||
|
||||
Available built-in extensions:
|
||||
- ApiKeyTenantExtension: Simple API key validation with public schema
|
||||
|
||||
Example usage:
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
||||
"""
|
||||
|
||||
from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension
|
||||
|
||||
__all__ = [
|
||||
"ApiKeyTenantExtension",
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Built-in tenant extension implementations."""
|
||||
|
||||
from hindsight_api.extensions.tenant import AuthenticationError, TenantContext, TenantExtension
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
|
||||
class ApiKeyTenantExtension(TenantExtension):
|
||||
"""
|
||||
Built-in tenant extension that validates API key against an environment variable.
|
||||
|
||||
This is a simple implementation that:
|
||||
1. Validates the API key matches HINDSIGHT_API_TENANT_API_KEY
|
||||
2. Returns 'public' as the schema for all authenticated requests
|
||||
|
||||
Configuration:
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
||||
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
|
||||
|
||||
For multi-tenant setups with separate schemas per tenant, implement a custom
|
||||
TenantExtension that looks up the schema based on the API key or token claims.
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict[str, str]):
|
||||
super().__init__(config)
|
||||
self.expected_api_key = config.get("api_key")
|
||||
if not self.expected_api_key:
|
||||
raise ValueError("HINDSIGHT_API_TENANT_API_KEY is required when using ApiKeyTenantExtension")
|
||||
|
||||
async def authenticate(self, context: RequestContext) -> TenantContext:
|
||||
"""Validate API key and return public schema context."""
|
||||
if context.api_key != self.expected_api_key:
|
||||
raise AuthenticationError("Invalid API key")
|
||||
return TenantContext(schema_name="public")
|
||||
@@ -0,0 +1,110 @@
|
||||
"""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
|
||||
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
HTTP Extension for adding custom endpoints to the Hindsight API.
|
||||
|
||||
This extension allows adding custom HTTP endpoints under the /ext/ path prefix.
|
||||
The extension provides a FastAPI router that is mounted on the main application.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from hindsight_api.extensions.base import Extension
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from hindsight_api import MemoryEngine
|
||||
|
||||
|
||||
class HttpExtension(Extension, ABC):
|
||||
"""
|
||||
Base class for HTTP extensions that add custom API endpoints.
|
||||
|
||||
HTTP extensions provide a FastAPI router that gets mounted under /ext/.
|
||||
The extension has full control over the routes, request/response models, and handlers.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from fastapi import APIRouter
|
||||
from hindsight_api.extensions import HttpExtension
|
||||
|
||||
class MyHttpExtension(HttpExtension):
|
||||
def get_router(self, memory: MemoryEngine) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/hello")
|
||||
async def hello():
|
||||
return {"message": "Hello from extension!"}
|
||||
|
||||
@router.post("/custom/{bank_id}/action")
|
||||
async def custom_action(bank_id: str):
|
||||
# Access memory engine for database operations
|
||||
pool = await memory._get_pool()
|
||||
# ... custom logic
|
||||
return {"status": "ok"}
|
||||
|
||||
return router
|
||||
```
|
||||
|
||||
The routes will be available at:
|
||||
- GET /ext/hello
|
||||
- POST /ext/custom/{bank_id}/action
|
||||
|
||||
Configuration via environment variables:
|
||||
HINDSIGHT_API_HTTP_EXTENSION=mypackage.ext:MyHttpExtension
|
||||
HINDSIGHT_API_HTTP_SOME_CONFIG=value
|
||||
|
||||
The extension receives config: {"some_config": "value"}
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_router(self, memory: "MemoryEngine") -> APIRouter:
|
||||
"""
|
||||
Return a FastAPI router with custom endpoints.
|
||||
|
||||
The router will be mounted at /ext/ on the main application.
|
||||
All routes defined in the router will be prefixed with /ext/.
|
||||
|
||||
Args:
|
||||
memory: The MemoryEngine instance for database access and core operations.
|
||||
Use this to access the connection pool, run queries, or call
|
||||
memory operations like retain, recall, etc.
|
||||
|
||||
Returns:
|
||||
A FastAPI APIRouter with the custom endpoints defined.
|
||||
|
||||
Example:
|
||||
```python
|
||||
def get_router(self, memory: MemoryEngine) -> APIRouter:
|
||||
router = APIRouter(tags=["My Extension"])
|
||||
|
||||
@router.get("/status")
|
||||
async def status():
|
||||
health = await memory.health_check()
|
||||
return {"extension": "healthy", "memory": health}
|
||||
|
||||
return router
|
||||
```
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Extension loader utilities."""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING, TypeVar
|
||||
|
||||
from hindsight_api.extensions.base import Extension
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from hindsight_api.extensions.context import ExtensionContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T", bound=Extension)
|
||||
|
||||
|
||||
class ExtensionLoadError(Exception):
|
||||
"""Raised when an extension fails to load."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def load_extension(
|
||||
prefix: str,
|
||||
base_class: type[T],
|
||||
env_prefix: str = "HINDSIGHT_API",
|
||||
context: "ExtensionContext | None" = None,
|
||||
) -> T | None:
|
||||
"""
|
||||
Load an extension from environment variable configuration.
|
||||
|
||||
The extension class is specified via {env_prefix}_{prefix}_EXTENSION environment
|
||||
variable in the format "module.path:ClassName".
|
||||
|
||||
Configuration for the extension is collected from all environment variables
|
||||
matching {env_prefix}_{prefix}_* (excluding the EXTENSION variable itself).
|
||||
|
||||
Args:
|
||||
prefix: The extension prefix (e.g., "OPERATION_VALIDATOR").
|
||||
base_class: The base class that the extension must inherit from.
|
||||
env_prefix: The environment variable prefix (default: "HINDSIGHT_API").
|
||||
context: Optional ExtensionContext to provide system APIs to the extension.
|
||||
|
||||
Returns:
|
||||
An instance of the extension, or None if not configured.
|
||||
|
||||
Raises:
|
||||
ExtensionLoadError: If the extension fails to load or validate.
|
||||
|
||||
Example:
|
||||
HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator
|
||||
HINDSIGHT_API_OPERATION_VALIDATOR_MAX_REQUESTS=100
|
||||
|
||||
ext = load_extension("OPERATION_VALIDATOR", OperationValidatorExtension)
|
||||
# ext.config == {"max_requests": "100"}
|
||||
"""
|
||||
env_var = f"{env_prefix}_{prefix}_EXTENSION"
|
||||
ext_path = os.getenv(env_var)
|
||||
|
||||
if not ext_path:
|
||||
logger.debug(f"No extension configured for {env_var}")
|
||||
return None
|
||||
|
||||
logger.info(f"Loading extension from {env_var}={ext_path}")
|
||||
|
||||
# Parse "module.path:ClassName"
|
||||
if ":" not in ext_path:
|
||||
raise ExtensionLoadError(f"Invalid extension path '{ext_path}'. Expected format: 'module.path:ClassName'")
|
||||
|
||||
module_path, class_name = ext_path.rsplit(":", 1)
|
||||
|
||||
# Import the module
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
except ImportError as e:
|
||||
raise ExtensionLoadError(f"Failed to import extension module '{module_path}': {e}") from e
|
||||
|
||||
# Get the class
|
||||
try:
|
||||
ext_class = getattr(module, class_name)
|
||||
except AttributeError as e:
|
||||
raise ExtensionLoadError(f"Extension class '{class_name}' not found in module '{module_path}'") from e
|
||||
|
||||
# Validate inheritance
|
||||
if not isinstance(ext_class, type) or not issubclass(ext_class, base_class):
|
||||
raise ExtensionLoadError(f"Extension class '{ext_class.__name__}' must inherit from '{base_class.__name__}'")
|
||||
|
||||
# Collect configuration from environment variables
|
||||
config = _collect_config(env_prefix, prefix)
|
||||
|
||||
logger.info(f"Loaded extension {ext_class.__name__} with config keys: {list(config.keys())}")
|
||||
|
||||
# Instantiate the extension
|
||||
try:
|
||||
extension = ext_class(config)
|
||||
except Exception as e:
|
||||
raise ExtensionLoadError(f"Failed to instantiate extension '{ext_class.__name__}': {e}") from e
|
||||
|
||||
# Set the context if provided
|
||||
if context is not None:
|
||||
extension.set_context(context)
|
||||
logger.debug(f"Set context on extension {ext_class.__name__}")
|
||||
|
||||
return extension
|
||||
|
||||
|
||||
def _collect_config(env_prefix: str, prefix: str) -> dict[str, str]:
|
||||
"""
|
||||
Collect configuration from environment variables.
|
||||
|
||||
Collects all variables matching {env_prefix}_{prefix}_* except for
|
||||
{env_prefix}_{prefix}_EXTENSION, strips the prefix, and lowercases keys.
|
||||
"""
|
||||
config = {}
|
||||
full_prefix = f"{env_prefix}_{prefix}_"
|
||||
extension_var = f"{full_prefix}EXTENSION"
|
||||
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith(full_prefix) and key != extension_var:
|
||||
# Strip prefix and lowercase the key
|
||||
config_key = key[len(full_prefix) :].lower()
|
||||
config[config_key] = value
|
||||
|
||||
return config
|
||||
@@ -0,0 +1,325 @@
|
||||
"""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
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tenant Extension for multi-tenancy and API key authentication."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
from hindsight_api.extensions.base import Extension
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
|
||||
class AuthenticationError(Exception):
|
||||
"""Raised when authentication fails."""
|
||||
|
||||
def __init__(self, reason: str):
|
||||
self.reason = reason
|
||||
super().__init__(f"Authentication failed: {reason}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class TenantContext:
|
||||
"""
|
||||
Tenant context returned by authentication.
|
||||
|
||||
Contains the PostgreSQL schema name for tenant isolation.
|
||||
All database queries will use fully-qualified table names
|
||||
with this schema (e.g., schema_name.memory_units).
|
||||
"""
|
||||
|
||||
schema_name: str
|
||||
|
||||
|
||||
class TenantExtension(Extension, ABC):
|
||||
"""
|
||||
Extension for multi-tenancy and API key authentication.
|
||||
|
||||
This extension validates incoming requests and returns the tenant context
|
||||
including the PostgreSQL schema to use for database operations.
|
||||
|
||||
Built-in implementation:
|
||||
hindsight_api.extensions.builtin.tenant.ApiKeyTenantExtension
|
||||
|
||||
Enable via environment variable:
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
||||
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
|
||||
|
||||
The returned schema_name is used for fully-qualified table names in queries,
|
||||
enabling tenant isolation at the database level.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
async def authenticate(self, context: RequestContext) -> TenantContext:
|
||||
"""
|
||||
Authenticate the action context and return tenant context.
|
||||
|
||||
Args:
|
||||
context: The action context containing API key and other auth data.
|
||||
|
||||
Returns:
|
||||
TenantContext with the schema_name for database operations.
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If authentication fails.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
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
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import uvicorn
|
||||
|
||||
from . import MemoryEngine
|
||||
from .api import create_app
|
||||
from .banner import print_banner
|
||||
from .config import HindsightConfig, get_config
|
||||
from .daemon import (
|
||||
DEFAULT_DAEMON_PORT,
|
||||
DEFAULT_IDLE_TIMEOUT,
|
||||
DaemonLock,
|
||||
IdleTimeoutMiddleware,
|
||||
daemonize,
|
||||
)
|
||||
|
||||
# Filter deprecation warnings from third-party libraries
|
||||
warnings.filterwarnings("ignore", message="websockets.legacy is deprecated")
|
||||
warnings.filterwarnings("ignore", message="websockets.server.WebSocketServerProtocol is deprecated")
|
||||
|
||||
# Disable tokenizers parallelism to avoid warnings
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
||||
|
||||
# Global reference for cleanup
|
||||
_memory: MemoryEngine | None = None
|
||||
|
||||
|
||||
def _cleanup():
|
||||
"""Synchronous cleanup function to stop resources on exit."""
|
||||
global _memory
|
||||
if _memory is not None and _memory._pg0 is not None:
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
loop.run_until_complete(_memory._pg0.stop())
|
||||
loop.close()
|
||||
print("\npg0 stopped.")
|
||||
except Exception as e:
|
||||
print(f"\nError stopping pg0: {e}")
|
||||
|
||||
|
||||
def _signal_handler(signum, frame):
|
||||
"""Handle SIGINT/SIGTERM to ensure cleanup."""
|
||||
print(f"\nReceived signal {signum}, shutting down...")
|
||||
_cleanup()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the CLI."""
|
||||
global _memory
|
||||
|
||||
# Load configuration from environment (for CLI args defaults)
|
||||
config = get_config()
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="hindsight-api",
|
||||
description="Hindsight API Server",
|
||||
)
|
||||
|
||||
# Server options
|
||||
parser.add_argument(
|
||||
"--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)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--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)",
|
||||
)
|
||||
|
||||
# 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)")
|
||||
|
||||
# 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.set_defaults(access_log=False)
|
||||
|
||||
# Proxy options
|
||||
parser.add_argument(
|
||||
"--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"
|
||||
)
|
||||
|
||||
# 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)",
|
||||
)
|
||||
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})",
|
||||
)
|
||||
|
||||
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:
|
||||
config = HindsightConfig(
|
||||
database_url=config.database_url,
|
||||
llm_provider=config.llm_provider,
|
||||
llm_api_key=config.llm_api_key,
|
||||
llm_model=config.llm_model,
|
||||
llm_base_url=config.llm_base_url,
|
||||
embeddings_provider=config.embeddings_provider,
|
||||
embeddings_local_model=config.embeddings_local_model,
|
||||
embeddings_tei_url=config.embeddings_tei_url,
|
||||
reranker_provider=config.reranker_provider,
|
||||
reranker_local_model=config.reranker_local_model,
|
||||
reranker_tei_url=config.reranker_tei_url,
|
||||
host=args.host,
|
||||
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)
|
||||
signal.signal(signal.SIGINT, _signal_handler)
|
||||
signal.signal(signal.SIGTERM, _signal_handler)
|
||||
|
||||
# Create MemoryEngine (reads configuration from environment)
|
||||
_memory = MemoryEngine()
|
||||
|
||||
# Create FastAPI app
|
||||
app = create_app(
|
||||
memory=_memory,
|
||||
http_api_enabled=True,
|
||||
mcp_api_enabled=config.mcp_enabled,
|
||||
mcp_mount_path="/mcp",
|
||||
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,
|
||||
"host": args.host,
|
||||
"port": args.port,
|
||||
"log_level": args.log_level,
|
||||
"access_log": args.access_log,
|
||||
"proxy_headers": args.proxy_headers,
|
||||
"ws": "wsproto", # Use wsproto instead of websockets to avoid deprecation warnings
|
||||
}
|
||||
|
||||
# Add optional parameters if provided
|
||||
if args.reload:
|
||||
uvicorn_config["reload"] = True
|
||||
if args.workers > 1:
|
||||
uvicorn_config["workers"] = args.workers
|
||||
if args.forwarded_allow_ips:
|
||||
uvicorn_config["forwarded_allow_ips"] = args.forwarded_allow_ips
|
||||
if args.ssl_keyfile:
|
||||
uvicorn_config["ssl_keyfile"] = args.ssl_keyfile
|
||||
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
|
||||
|
||||
def run_idle_checker():
|
||||
import time
|
||||
|
||||
time.sleep(2) # Wait for uvicorn to start
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(idle_middleware._check_idle())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=run_idle_checker, daemon=True).start()
|
||||
|
||||
uvicorn.run(**uvicorn_config) # type: ignore[invalid-argument-type] - dict kwargs
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user